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
+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