Bound the automation hold to the window the model has not caught up on, and make that authority model stated, enforced and tested

This commit is contained in:
2026-08-02 17:16:02 -04:00
parent de5654fb6f
commit 1fd38bbd57
35 changed files with 1155 additions and 302 deletions
+75 -13
View File
@@ -109,17 +109,76 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
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.
writers genuinely differ in thread.
### THE AUTHORITY MODEL — who may write a parameter's value, and until when
Two passes got this subtly wrong in opposite directions (the first delivered automation on the
wrong channel; the second made a point's authority permanent), because the model was in nobody's
head and nowhere in the tree. It is here, and the code follows it.
**`ReaSamplerProcessor::params_` — plus the two instance scalars beside it — is THE model, and
the single authority.** Everything else that holds these values is a cache or a courier:
| Writer | Authority begins | Authority ends |
|---|---|---|
| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model |
| Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) |
| State restore (`setState`) | the call | the call returns |
| Bake reset (`adoptBakedCapture`) | the call | the call returns |
| Reload seed (`reloadInstrument`) | under `reloadMutex_` | the publish (it re-folds the model) |
| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** |
Every writer except the last writes the model directly, so for those "authority ends" is just
"the write happened". The automation lane is the only one that cannot: the SDK delivers it on the
audio thread, where the model path allocates (`resolvePlay` copies velocity curves and spline
contours). So it patches the engine-facing block in place and is couriered to the UI thread,
which folds it into the model on the next tick.
**The hold is the bridge across that gap, and nothing more.** Between the point landing and the
fold — at most one UI tick — the model does not yet carry the value, so a model republish in that
window (any knob move) would revert the automated parameter until the lane's next point. The hold
re-applies the point over every merge to stop that. The instant the model carries the value, the
hold has no job and is **released**; from then on every writer above reaches the audio normally.
**Contention resolves BY RULE, not by timing.** A point outranks the model while the lane is
driving and the model has not caught up — which is VST3's own authority rule (a lane in
read/write mode outranks a plug-in-side set). It does NOT outrank a later restore, bake reset or
knob move, because by then the lane is no longer driving that value; the model is.
**Where it is enforced, and what fails if it stops holding.**
- The decision is the pure `core/instrument/param/param_merge`'s `mergeAutomation`;
`tests/test_param_merge.cpp`'s
`testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt` is the test — it asserts both halves,
including that a writer AFTER the release reaches the audio. A latch with no release fails it.
- The mechanism — the per-slot sequence the audio thread stamps and the UI thread answers, and
the acquire/release ordering that makes a release imply the publish is visible — is
`automation_channel.h`'s, at its two methods.
- **The release is stored LAST in `drainAutomationToModel`**, after `setInstrumentParams` and
`publishLiveParams`. Moving it earlier reintroduces a one-block revert.
- **`setState` therefore needs no ordering guarantee against the host's first parameter block.**
A lane that is driving re-applies over the restore; a lane that merely sent a point once, and
had it folded, does not — which is the correct reading of the SDK rule, and the one the second
pass got wrong.
**The editor's `params_` is a CACHE of the model, authoritative for one gesture only.** A commit
writes the WHOLE set back, and `notifyParamsFromModel` diffs it — so a stale copy would
`performEdit` superseded values the user never touched, which a lane in latch or write mode
records. The sync tick re-seeds it (past the drag guard) whenever `paramsGeneration_` has moved
under it: the automation fold, the host's generic panel, a state restore.
**Two independent gates keep a value-identical point off the per-voice fan-out**, and they cover
different windows: `AutomationChannel::land` drops a repeat of a standing hold whole (the flat
read-mode segment, where a host sends one point per block), and the merge publishes only when the
merged block differs from the last (a model republish that changed nothing). Neither is measured
against a performance budget — they are there because `VoiceEngine::refreshLive` runs
`voice.applyLive` over every active voice, and neither case needs it.
- **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
`[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without
@@ -155,7 +214,9 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
- `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.
- `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, the audio thread's queue drain and the UI thread's fold + release, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws, the formatter and the merge.
- `automation_channel.h` — the host automation lane's per-instance state and the mechanism of its authority lifetime: the audio thread's hold, the per-slot sequence it stamps, the UI thread's release answer, and the acquire/release ordering that makes a release imply the model publish is visible. The MODEL it enforces is the Authority section above; the pure decision it feeds is `core/instrument/param/param_merge`. Internal to this TU family.
- `processor_snapshot.h` — the two namespace-scope aggregates the processor hands across its thread boundary: `LoadedInstrument` (the decoded capture plus the engine playing it, swapped through the drain slot) and `MasterBusMeter` (what the audio thread publishes per block for the editor's meter). Split out of `reasampler_processor.h` on `editor_interaction.h`'s grounds — neither is behaviour.
- `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.
@@ -163,12 +224,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.
- **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a
false premise.** It was described as ONE class declaration; it also carried two namespace-scope
aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are
now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds
`editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is
behaviour. What remains is under the ceiling. Its bulk is the drain-slot proof and the
RT-discipline constraints, which the comment conventions name as keep-worthy.
- **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
+1 -1
View File
@@ -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_live
param_id param_units param_format param_live param_merge
limiter meter_accumulate meter_ballistics master_meter bake_hold
file_bytes curve_law stroke_aa
curve_tessellate
+95
View File
@@ -0,0 +1,95 @@
// automation_channel.h — the host automation lane's per-instance state, and the ONE place its
// AUTHORITY LIFETIME is mechanised: a point outranks the model from the block it lands in until
// the UI thread has folded it back into the model AND republished. This directory's CLAUDE.md
// states the model; `core/instrument/param/param_merge` is the pure decision this feeds.
#pragma once
#include <atomic>
#include <cstddef>
#include <cstdint>
#include "core/instrument/param/param_merge.h"
namespace reasampler::vst {
// The DeckParam ordinal space — what the automation slots and the notification diff both index.
inline constexpr std::size_t kDeckParamSlots = instrument::param::kDeckParamSlots;
class AutomationChannel {
public:
// --- AUDIO THREAD -------------------------------------------------------------------
// A point landed for `slot`. `ridesTheBlock` is false for a control that reaches the audio
// beside the live block (master gain, whose route is the processor's own atomic): such a
// point takes no hold and does not make the block dirty, so a lane on it alone cannot drive
// the per-voice fan-out every block for a value the block does not carry.
//
// Answers whether the block MOVED, which is what makes the merge conditional. A repeat of a
// standing hold moves nothing and is dropped whole — no hold rewrite, no UI publish — because
// it would only make the fold rewrite the model with the value already in it.
bool land(std::size_t slot, double normalized, bool ridesTheBlock) {
if (ridesTheBlock && !instrument::param::automationPointMoves(slots_[slot], normalized)) {
return false;
}
if (ridesTheBlock) {
slots_[slot].norm = normalized;
slots_[slot].held = true;
}
published_[slot].store(normalized, std::memory_order_relaxed);
// The sequence is stored LAST and with release: the fold reads it FIRST and only then
// trusts the value beside it.
seq_[slot].store(seq_[slot].load(std::memory_order_relaxed) + 1,
std::memory_order_release);
any_.store(true, std::memory_order_release);
return ridesTheBlock;
}
// Refreshes each held slot's release answer. Must run BEFORE the model block is read: the
// acquire here synchronizes with the UI thread's release store, which it makes only AFTER
// republishing the model — so a slot seen released is one whose value any block read after
// this point is guaranteed to already carry.
void refreshReleases() {
for (std::size_t i = 0; i < kDeckParamSlots; ++i) {
if (!slots_[i].held) continue;
slots_[i].folded = folded_[i].load(std::memory_order_acquire) ==
seq_[i].load(std::memory_order_relaxed);
}
}
instrument::param::AutomationSlot* slots() { return slots_; }
// --- UI THREAD ----------------------------------------------------------------------
// True when at least one point has landed since the last drain.
bool takePending() { return any_.exchange(false, std::memory_order_acquire); }
// The value and sequence of `slot`'s unfolded point, or false when there is nothing new.
bool takeSlot(std::size_t slot, double& value, std::uint32_t& seq) const {
seq = seq_[slot].load(std::memory_order_acquire);
if (seq == folded_[slot].load(std::memory_order_relaxed)) return false;
value = published_[slot].load(std::memory_order_relaxed);
return true;
}
// Releases `slot`'s hold. ONLY legal once the model carrying that point has been republished
// — calling it earlier would let the audio thread drop the hold ahead of the block that
// carries its value, which is a one-block revert to the superseded value.
void release(std::size_t slot, std::uint32_t seq) {
folded_[slot].store(seq, std::memory_order_release);
}
private:
instrument::param::AutomationSlot slots_[kDeckParamSlots] = {}; // audio thread only
std::atomic<double> published_[kDeckParamSlots] = {};
std::atomic<std::uint32_t> seq_[kDeckParamSlots] = {}; // written by the audio thread
std::atomic<std::uint32_t> folded_[kDeckParamSlots] = {}; // written by the UI thread
std::atomic<bool> any_{false}; // makes the UI thread's idle drain a single exchange
};
// The publication atomics above are read on the audio thread; a locked implementation would be a
// hidden mutex on it. Structural rather than assumed, for a class whose thesis is RT discipline.
static_assert(std::atomic<double>::is_always_lock_free,
"the automation publication must be lock-free — the audio thread writes it");
static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
"the automation sequence must be lock-free — the audio thread writes it");
} // namespace reasampler::vst
+4 -4
View File
@@ -36,7 +36,6 @@ using instrument::ui::kDeckKnobSize;
using instrument::ui::kPad;
using instrument::ui::deckParamNorm;
using instrument::ui::kEnvTimeMaxSeconds;
using instrument::ui::kKeyTrackMax;
using instrument::ui::resetDeckParam;
using instrument::ui::sampleDeckGroups;
using instrument::ui::setDeckParam;
@@ -119,7 +118,7 @@ double ReaSamplerEditor::deckControlNorm(int id) const {
if (id == kBakeHoldKnobId) return bakeHoldNorm();
switch (static_cast<ParamControl>(id)) {
case ParamControl::kKeyTrack:
return clamp01(params_.keyTrack / kKeyTrackMax);
return instrument::ui::keyTrackNormFrom(params_.keyTrack);
case ParamControl::kVoiceCount:
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
@@ -272,8 +271,9 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
params_.keyTrack = clamp01(value) * kKeyTrackMax;
// keyTrack sits beside the play bundle, so it takes deck_values' own pair rather than
// the PlaySeconds binding.
params_.keyTrack = instrument::ui::keyTrackFromNorm(value);
} else {
applyControl(id, params_.play, value, segment);
}
+16 -1
View File
@@ -70,7 +70,7 @@ void ReaSamplerEditor::refreshFromBank() {
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
selectedId_ = processor_->selectedSampleId();
params_ = processor_->instrumentParams();
params_ = processor_->instrumentParams(seenParamsGeneration_);
channelMode_ = processor_->channelMode();
voiceCount_ = processor_->voiceCount();
voiceMode_ = processor_->voiceMode();
@@ -146,6 +146,17 @@ void ReaSamplerEditor::onSyncTimer() {
processor_->flushGainNotify();
processor_->drainAutomationToModel();
// params_ is a CACHE of the processor's model, authoritative only for the duration of a
// gesture — which is why this sits past the drag guard. Re-seed it whenever the model has
// moved under it: the automation fold just above, the host's own generic panel, a state
// restore. Without this, commitLive writes the WHOLE stale set back and notifyParamsFromModel
// diffs it as a real edit, performEdit-ing superseded values the user never touched — which a
// lane in latch or write mode records.
if (processor_->instrumentParamsGeneration() != seenParamsGeneration_) {
params_ = processor_->instrumentParams(seenParamsGeneration_);
invalidate();
}
// 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.
const bool available = bakeAvailable(processor_->bridge());
@@ -201,6 +212,9 @@ void ReaSamplerEditor::commitAndReload() {
if (!processor_) return;
processor_->setSelectedSampleId(selectedId_);
processor_->setInstrumentParams(params_);
// This copy IS the model now, so adopt the generation it produced rather than re-seeding off
// it on the next tick. Same reason at commitLive.
seenParamsGeneration_ = processor_->instrumentParamsGeneration();
processor_->reloadInstrument();
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
// toggle draws what the engine actually decoded with.
@@ -214,6 +228,7 @@ void ReaSamplerEditor::commitLive() {
// UI thread only. See the declaration for why this still writes the parameter set.
if (!processor_) return;
processor_->setInstrumentParams(params_);
seenParamsGeneration_ = processor_->instrumentParamsGeneration();
processor_->publishLiveParams();
}
+48 -24
View File
@@ -13,6 +13,7 @@
#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_live.h" // writeHostParam (the model side of a host write)
#include "core/instrument/param/param_units.h"
#include "core/instrument/ui/deck_values.h"
@@ -133,23 +134,29 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal
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);
// WHICH home a control's value has is param::valueHomeFor's answer, not a list repeated here
// — the read peer below branches on the same predicate, the editor draws the same split at
// applyParamControl, and the instance-scalar set's ARITY is pinned by param_live's own test,
// so a third one cannot appear without a failure. The value map itself is param_live's, which
// is what makes this write and the audio thread's patch of the same point agree.
if (param::valueHomeFor(deck) == param::ValueHome::InstanceScalar) {
// Master gain never arrives here — both callers route it to setMasterGainLinear, its own
// funnel — so key-track is the whole of this arm.
if (deck == DeckParam::kKeyTrack) {
params.keyTrack = instrument::ui::keyTrackFromNorm(normalized);
}
return;
}
instrument::ui::setDeckParam(deck, params.play, normalized, /*segment=*/0);
param::writeHostParam(deck, params.play, normalized);
}
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
DeckParam deck) const {
if (deck == DeckParam::kMasterGain) {
return instrument::engine::masterGainNormFromLinear(masterGainLinear());
if (param::valueHomeFor(deck) == param::ValueHome::InstanceScalar) {
return deck == DeckParam::kMasterGain
? instrument::engine::masterGainNormFromLinear(masterGainLinear())
: instrument::ui::keyTrackNormFrom(params.keyTrack);
}
if (deck == DeckParam::kKeyTrack) return instrument::ui::keyTrackNormFrom(params.keyTrack);
return instrument::ui::deckParamNorm(deck, params.play);
}
@@ -196,7 +203,11 @@ void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalize
}
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.
// whole drag is one edit rather than a run of one-point ones. An UNRELATED writer that
// reaches here mid-drag (the sync tick's flushGainNotify) latches into the same bracket
// set, so the host sees its touch end when the drag does rather than at once — bounded by
// the drag and correctly closed, and the alternative (a second bracket state per writer)
// buys a distinction no host acts on.
openGestureIds_[openGestureCount_++] = id;
beginEdit(id);
performEdit(id, normalized);
@@ -244,34 +255,40 @@ bool ReaSamplerProcessor::drainInputParameterChanges(IParameterChanges* changes)
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) {
// automation write is the same one relaxed store the knob makes — and it takes no hold,
// which is what keeps a lane on it alone from republishing the block every block.
const bool ridesTheBlock = row->deck != DeckParam::kMasterGain;
if (!ridesTheBlock) {
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
// Also published 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);
landed |= automation_.land(static_cast<std::size_t>(row->deck), value, ridesTheBlock);
}
if (landed) automationAny_.store(true, std::memory_order_release);
return landed;
}
void ReaSamplerProcessor::drainAutomationToModel() {
if (!automationAny_.exchange(false, std::memory_order_acquire)) return;
if (!automation_.takePending()) return;
InstrumentParams params = instrumentParams();
// What was folded, and at which sequence. Held back rather than released as we go: the
// release below is a statement that the MODEL carries the point, which is only true once the
// publish has happened.
std::size_t foldedSlots[kDeckParamSlots];
std::uint32_t foldedSeqs[kDeckParamSlots];
std::size_t foldedCount = 0;
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);
double value = 0.0;
std::uint32_t seq = 0;
if (!automation_.takeSlot(slot, value, seq)) continue;
foldedSlots[foldedCount] = slot;
foldedSeqs[foldedCount] = seq;
++foldedCount;
// 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) {
@@ -290,6 +307,13 @@ void ReaSamplerProcessor::drainAutomationToModel() {
}
syncParamsFromModel();
paramNotifySuppressed_ = wasSuppressed;
// LAST, and that is the whole authority rule: the hold outranks the model only until the
// model carries the point. Released any earlier and the audio thread could drop the hold
// ahead of the block that carries its value; never released at all — the defect this
// replaces — and one point would defeat every later restore, reset and knob move.
for (std::size_t i = 0; i < foldedCount; ++i) {
automation_.release(foldedSlots[i], foldedSeqs[i]);
}
}
void ReaSamplerProcessor::endParamGesture() {
+6 -1
View File
@@ -153,13 +153,18 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// 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_;
// BEFORE the publish: the publish is what makes the audio thread re-merge, and the
// merge resolves every held automation stage time against this rate. Stored after,
// a merge in the window between them would resolve them against the previous
// capture's rate — or, on the first-ever build, against 0, where secondsToFrames
// collapses every automated envelope stage to zero frames.
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
{
// 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, sample.keyTrack));
}
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
}
}
+57
View File
@@ -0,0 +1,57 @@
// processor_snapshot.h — the two namespace-scope aggregates the processor hands ACROSS its
// thread boundary: the loaded instrument the audio thread renders, and the bus state it
// publishes back for the editor's meter. Neither is behaviour, which is what makes them a
// responsibility rather than a bisection of the processor's own declaration.
#pragma once
#include <cstddef>
#include <cstdint>
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::vst {
// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw
// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics),
// because a hold timer or a log on the audio thread would be per-block work that buys nothing.
struct MasterBusMeter {
float peakL = 0.f; // max |x| this block
float peakR = 0.f;
// Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT
// scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction)
// rather than the mute's own weight. 1 = no reduction.
float minGain = 1.f;
bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it
};
// The decoded capture + the voice engine playing it. The engine holds a reference to the
// sample, so both must live/die together at a stable address — heap-allocated,
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
struct LoadedInstrument {
SampleData sample;
VoiceEngine engine;
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
// Takeover declick is on by default here (product default; the pure core defaults it
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
// of clicking.
LoadedInstrument(SampleData sd, std::size_t maxVoices,
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly,
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
: sample(std::move(sd)),
engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
installedAt(gen) {}
// True when nothing in this snapshot is sounding; lets the off-thread retirer park an
// idle drain early. Bounded scan (<= maxVoices).
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
LoadedInstrument(const LoadedInstrument&) = delete;
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
};
} // namespace reasampler::vst
+14
View File
@@ -168,6 +168,17 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
return params_;
}
std::uint32_t ReaSamplerProcessor::instrumentParamsGeneration() {
std::lock_guard<std::mutex> lock(paramsMutex_);
return paramsGeneration_;
}
InstrumentParams ReaSamplerProcessor::instrumentParams(std::uint32_t& generation) {
std::lock_guard<std::mutex> lock(paramsMutex_);
generation = paramsGeneration_;
return params_;
}
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
// 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
@@ -179,6 +190,9 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
before[static_cast<std::size_t>(row.deck)] = modelParamNormalized(params_, row.deck);
}
params_ = params;
// Bumped inside the lock with the write it names, so a reader taking the pair together
// can never see a generation that does not describe the set beside it.
++paramsGeneration_;
}
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
// funnels through here, so mirroring the limiter flag at this one point is what keeps the
+5 -1
View File
@@ -420,7 +420,11 @@ private:
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
std::string selectedId_; // the loaded capture ("" = empty state)
InstrumentParams params_; // the ONE parameter set governing it
InstrumentParams params_; // a CACHE of the processor's model (see below)
// The model generation params_ was taken at. That copy is authoritative only for the
// duration of a gesture; between gestures the sync tick re-seeds it whenever this differs
// from the processor's, because a commit writes the WHOLE set back.
std::uint32_t seenParamsGeneration_ = 0;
ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
+22 -15
View File
@@ -17,7 +17,7 @@
#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 "core/instrument/param/param_merge.h" // the block-boundary merge decision
#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)
@@ -207,24 +207,31 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// 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_) {
if (dirty || liveParams_.generation() != seenModelGeneration_) {
// BEFORE the block is read, and the ordering is load-bearing — automation_channel.h
// owns why.
automation_.refreshReleases();
// 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);
// The generation ACTUALLY observed, not the one sampled above: a publish landing
// between the two would otherwise leave this thread re-merging an identical block
// every following quiet one.
const std::uint32_t observed = liveParams_.read(merged);
if (observed != 0) {
seenModelGeneration_ = observed;
instrument::param::mergeAutomation(
merged, automation_.slots(), kDeckParamSlots,
builtSampleRate_.load(std::memory_order_relaxed));
// The second gate, and a different window from the drain's own: this one catches
// a model republish that changed nothing (a knob committed to the value it
// already held, a released hold whose value the model now carries). Field-wise
// — live_params.h owns why it must never become a memcmp.
if (!haveMergedLive_ || merged != lastMergedLive_) {
lastMergedLive_ = merged;
haveMergedLive_ = true;
automationLive_.publish(merged);
}
automationLive_.publish(merged);
}
}
}
+36 -69
View File
@@ -17,13 +17,14 @@
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
#include "shell/instrument/automation_channel.h" // the automation hold + its release protocol
#include "shell/instrument/processor_snapshot.h" // LoadedInstrument + MasterBusMeter
#include "shell/instrument/reaper_bridge.h"
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set)
#include "core/instrument/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter
#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 {
@@ -36,48 +37,6 @@ using instrument::map::kPreviewVelocityDefault;
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw
// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics),
// because a hold timer or a log on the audio thread would be per-block work that buys nothing.
struct MasterBusMeter {
float peakL = 0.f; // max |x| this block
float peakR = 0.f;
// Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT
// scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction)
// rather than the mute's own weight. 1 = no reduction.
float minGain = 1.f;
bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it
};
// The decoded capture + the voice engine playing it. The engine holds a reference to the
// sample, so both must live/die together at a stable address — heap-allocated,
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
struct LoadedInstrument {
SampleData sample;
VoiceEngine engine;
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
// Takeover declick is on by default here (product default; the pure core defaults it
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
// of clicking.
LoadedInstrument(SampleData sd, std::size_t maxVoices,
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly,
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
: sample(std::move(sd)),
engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
installedAt(gen) {}
// True when nothing in this snapshot is sounding; lets the off-thread retirer park an
// idle drain early. Bounded scan (<= maxVoices).
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
LoadedInstrument(const LoadedInstrument&) = delete;
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
};
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
public:
ReaSamplerProcessor() = default;
@@ -140,9 +99,11 @@ public:
void syncParamsFromModel();
// 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.
// the controller cache, suppressing the host notification (those values came FROM it), then
// RELEASES each folded point's hold — which is what bounds a lane's authority to the window
// where it is actually driving. 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. This directory's CLAUDE.md owns the authority model.
void drainAutomationToModel();
// A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit
@@ -231,6 +192,12 @@ public:
InstrumentParams instrumentParams();
void setInstrumentParams(const InstrumentParams& params);
// The model's edit counter, bumped by every setInstrumentParams. A holder of a COPY (the
// editor's snapshot) re-seeds when this moves under it; the overload answers both under one
// lock, because reading them apart would let the pair disagree.
std::uint32_t instrumentParamsGeneration();
InstrumentParams instrumentParams(std::uint32_t& generation);
// Republishes the live-parameter block from the stored parameter set, resolved against the
// rate the loaded capture was built at so an unmoved value folds to exactly the frames the
// voices already latched. THE tier-3 commit (the three tiers are listed in this
@@ -320,10 +287,10 @@ private:
void buildParameterList();
// 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.
// queue wins. RT-safe — relaxed/release 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 ON A CONTROL THE BLOCK
// CARRIES, 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
@@ -421,25 +388,22 @@ private:
// 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.
// the audio thread, which merges liveParams_ with the host's automation points once per block
// and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane
// resending the value it already sent reaches the per-voice fan-out. A block carrying no
// automation and no model change costs one relaxed load plus, when the host passed a non-null
// IParameterChanges (REAPER's normal case), one cross-module getParameterCount(). 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.
// Audio thread only. The last liveParams_ generation merged, the last block published (the
// republish gate compares against it), and the automation slots themselves.
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};
instrument::engine::LiveValues lastMergedLive_{};
bool haveMergedLive_ = false;
// The host lane's state and its release protocol; automation_channel.h owns the mechanism and
// this directory's CLAUDE.md the authority model it enforces.
AutomationChannel automation_;
// 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
@@ -509,10 +473,13 @@ private:
std::mutex selectionMutex_;
std::string selectedSampleId_;
// The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData
// under the reload lock, never read directly on the audio thread.
// The one parameter set — THE model, and the authority every other holder of these values
// defers to. Off-thread only; reloadInstrument bakes it into the SampleData under the reload
// lock, never read directly on the audio thread. paramsGeneration_ moves with every write, so
// a holder of a copy can tell that it has.
std::mutex paramsMutex_;
InstrumentParams params_;
std::uint32_t paramsGeneration_ = 0;
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
// opportunistically from the bank blob when readable; never a bank dependency for