Merge dev into ux-w1-t1-sample-rate: take the spline live-drag and plugin-drop work

This commit is contained in:
2026-09-25 14:17:20 -04:00
47 changed files with 2150 additions and 757 deletions
+30 -11
View File
@@ -84,6 +84,17 @@ 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 drawn-contour drag is the one gesture that publishes WITHOUT committing.** Each move
couriers the editor's contours to `liveContours_` (`publishLiveContours`) and writes nothing
else — no parameter set, no generation, no host callback — and its release is the
`commitAndReload` it always was. That is sound because no contour is a host parameter, so no
host-facing reader needs the model to carry it mid-gesture; it is what keeps a motion event off
the host entirely (an unlatched `setInstrumentParams` opens a group edit even when nothing
exposed moved). `reloadInstrument` republishes the model's contours, so a release, a click-edit
(add/delete/hard) and a restore all reach the voices through the reload; capture-lost and the
editor closing mid-drag (`removedFromParent`) are the two exits that commit nothing — both
re-courier the restored `params_`.
**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.
@@ -109,7 +120,8 @@ 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.
writers genuinely differ in thread. The drawn-contour block `liveContours_` has no such pair:
no host automates a contour, so the engines read it straight from its off-thread writers.
### THE AUTHORITY MODEL — who may write a parameter's value, and until when
@@ -123,6 +135,7 @@ the single authority.** Everything else that holds these values is a cache or a
| Writer | Authority begins | Authority ends |
|---|---|---|
| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model |
| Contour drag's courier (`publishLiveContours`) | mouse-down | never writes the model — the release's `commitAndReload` does |
| 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 |
@@ -130,9 +143,9 @@ the single authority.** Everything else that holds these values is a cache or a
| Reload seed (`reloadInstrument`) | never — it does not write `params_` | it only republishes a live block folded from whatever the model already holds |
| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** |
Every writer above except the last two writes the model directly, so for those "authority ends"
is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never
touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193`
Every writer above except the contour courier and the last two writes the model directly, so for
those "authority ends" is just "the write happened". Reload seed is not itself a model write —
`reloadInstrument` never touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193`
— which is why its row states no authority window of its own. The automation lane is the only one
that cannot write directly: the SDK delivers it on the audio thread, where the model path
allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the
@@ -229,13 +242,19 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
## Gotchas
- **`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.
- **`reasampler_processor.h` carries a small, documented ceiling exception.** It was described as
ONE class declaration; it also carried two namespace-scope aggregates (`MasterBusMeter`,
`LoadedInstrument`) and the automation lane's own state. All three are 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
the class declaration itself, a handful of lines over the ceiling since the live-contour block
(`liveContours_` + `publishLiveContours`) landed beside `liveParams_` under the same ordering
constraint. No further seam exists that isn't behaviour: every remaining aggregate-shaped chunk
(the preview mailbox, the drain-slot triple) is read and written inline by `process()` on the
audio thread, so splitting it into its own header would either duplicate the RT-discipline
comments at a second site or turn a plain member access into a cross-header one for no gain.
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
@@ -178,6 +178,7 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
// At the ceiling: refused, contour untouched, nothing to roll back. Swallow the click
// rather than letting it fall through to a marker grab under the cursor.
if (index < 0) return true;
publishContoursLive(); // the new point is heard before the first move, not after it
}
drag_ = DragKind::kSplineNode;
curvePointIndex_ = index;
@@ -188,7 +189,7 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
dragCurveRect_ = waveArea.rect;
dragStartX_ = x;
dragStartY_ = y;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
invalidate(); // the model commit lands on WM_LBUTTONUP
return true;
}
@@ -235,7 +236,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag(
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
splineOverlayBox(overlay), dx, y - dragStartY_);
invalidate(); // live feedback; commit on release
publishContoursLive(); // heard now; the model write stays the release's commit
invalidate();
return;
}
+45 -5
View File
@@ -13,6 +13,7 @@
#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only)
#include "shell/instrument/reasampler_processor.h"
#include "core/version/app_version.h" // vstOutputName — channel-forked window class name
using namespace Steinberg;
@@ -20,7 +21,32 @@ namespace reasampler::vst {
#ifdef _WIN32
namespace {
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// Channel-forked so stable and beta never fight over one class registration (a second
// RegisterClassW against an already-taken name silently fails, leaving that channel's editors
// running the FIRST-registered channel's wndProc). vstOutputName() is ASCII (CMake OUTPUT_NAME),
// so a plain widen is safe.
const std::wstring& childClassName() {
static const std::wstring name = [] {
const std::string& base = version::vstOutputName();
return std::wstring(base.begin(), base.end()) + L"VstEditor";
}();
return name;
}
// The module holding a given code address — used to resolve THIS wndProc's own module rather
// than the parent's hInstance REAPER hands us. Registering against the parent would put the class
// in REAPER's own module while the wndproc still points into our DLL — a dangling procedure
// pointer once our DLL unloads but REAPER's class registration outlives it. Takes the address
// rather than naming wndProc directly so a free function in this anonymous namespace doesn't need
// access to the private member.
HMODULE moduleHandleFor(void* codeAddress) {
HMODULE mod = nullptr;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(codeAddress), &mod);
return mod;
}
// The change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
// timer: responsive enough that a recapture/ingest/assign refreshes within a bounded
@@ -105,8 +131,9 @@ void ReaSamplerEditor::attachedToParent() {
HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return;
HINSTANCE hInst =
reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE));
// Own module, not the parent's hInstance — see moduleHandleFor() above.
HINSTANCE hInst = reinterpret_cast<HINSTANCE>(
moduleHandleFor(reinterpret_cast<void*>(&ReaSamplerEditor::wndProc)));
if (!hInst) hInst = GetModuleHandle(nullptr);
static bool classRegistered = false;
@@ -114,7 +141,7 @@ void ReaSamplerEditor::attachedToParent() {
WNDCLASSW wc{};
wc.lpfnWndProc = &ReaSamplerEditor::wndProc;
wc.hInstance = hInst;
wc.lpszClassName = kChildClassName;
wc.lpszClassName = childClassName().c_str();
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
// CS_DBLCLKS is what makes WM_LBUTTONDBLCLK arrive at all. It also REPLACES the second
// WM_LBUTTONDOWN of a double-click, so every surface that counted two downs (Browse's
@@ -134,7 +161,7 @@ void ReaSamplerEditor::attachedToParent() {
refreshFromBank();
const ViewRect& r = getRect();
childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0,
childHwnd_ = CreateWindowExW(0, childClassName().c_str(), L"", WS_CHILD | WS_VISIBLE, 0, 0,
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
if (childHwnd_) {
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
@@ -162,6 +189,15 @@ void ReaSamplerEditor::removedFromParent() {
// an edit forever and every later internal write to that parameter would emit a bare
// performEdit. The capture-lost path normally closes it; this does not rely on Windows
// delivering WM_CAPTURECHANGED before the window goes away.
if (drag_ == DragKind::kSplineNode) {
// A contour drag reaches the voices without the model (see publishContoursLive) — the
// window closing mid-drag is a second exit alongside WM_CAPTURECHANGED that must roll
// the voices back the same way, or an abandoned drag stays audible after the editor
// that started it is gone.
params_ = dragStartParams_;
publishContoursLive();
drag_ = DragKind::kNone;
}
if (processor_) processor_->endParamGesture();
if (childHwnd_) {
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
@@ -312,6 +348,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
// drag used.
if (self->dragCommitsLive(self->drag_, self->dragParamId_)) {
self->commitLive();
} else if (self->drag_ == DragKind::kSplineNode) {
// The contour drag reached the voices without the model; restore
// the voices to it the same way (see publishContoursLive).
self->publishContoursLive();
}
}
// Peer of onMouseUp's bracket close, and after the rollback for the same
+4
View File
@@ -251,6 +251,10 @@ void ReaSamplerEditor::commitLive() {
processor_->publishLiveParams();
}
void ReaSamplerEditor::publishContoursLive() {
if (processor_) processor_->publishLiveContours(params_.play);
}
bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) {
return paramId == -2 ||
paramId == static_cast<int>(ParamControl::kVoiceCount) ||
@@ -164,6 +164,7 @@ 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_;
sample.liveContours = &liveContours_;
// 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
@@ -175,6 +176,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// 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));
// The model's contours, so a reload-tier contour edit (a point added, deleted or
// made hard, a drag's release) reaches the drain's sounding voices too, and a
// courier publish from an abandoned gesture cannot outlive the reload.
liveContours_.publish(instrument::engine::foldContours(sample.play));
}
resolvedId = selId; // the concrete pick that resolved
}
+8
View File
@@ -293,6 +293,14 @@ AutomationChannel::ReleaseProof ReaSamplerProcessor::publishLiveParams() {
return AutomationChannel::ReleaseProof::fromPublish(before, liveParams_.generation());
}
void ReaSamplerProcessor::publishLiveContours(const PlaySeconds& play) {
// Folded outside the lock, as publishLiveParams' block is. A contour is normalized time, so
// unlike that block it needs no rate and publishes even before anything is built.
const instrument::engine::LiveContours contours = instrument::engine::foldContours(play);
std::lock_guard<std::mutex> lock(livePublishMutex_);
liveContours_.publish(contours);
}
SampleRefs ReaSamplerProcessor::sampleRefs() {
std::lock_guard<std::mutex> lock(refsMutex_);
return sampleRefs_;
+6
View File
@@ -267,6 +267,12 @@ private:
// the knob. Does not repaint; callers already do. UI thread only.
void commitLive();
// The drawn-contour drag's per-move publish: params_' three contours straight to the voices,
// with NO parameter-set write — why is this directory's CLAUDE.md (the contour-drag note
// under the commit tiers). Every path that ends such a drag without its release's
// commitAndReload — capture-lost — must call this again after restoring params_. UI thread.
void publishContoursLive();
// Whether an in-flight drag commits live rather than through a reload. A deck knob is
// live per deckParamCommit; an envelope-node drag is live in EITHER mode — see
// liveCommitFor (deck_groups.h) for why.
+14 -4
View File
@@ -23,6 +23,7 @@
#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_contours.h" // LiveContourBlock (the drawn-contour block)
#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/param/param_id.h" // the frozen ParamId space + DeckParam binding
@@ -31,6 +32,7 @@ namespace reasampler::vst {
using instrument::map::ComponentState;
using instrument::map::InstrumentParams;
using instrument::map::PlaySeconds;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault;
@@ -226,6 +228,11 @@ public:
// bakes and publishes). UI thread; serialized against reloadInstrument's own publish. Returns drainAutomationToModel's release proof (automation_channel.h); other callers ignore it.
AutomationChannel::ReleaseProof publishLiveParams();
// Publishes `play`'s three drawn contours to every sounding voice WITHOUT writing the
// model — the contour drag's courier (this directory's CLAUDE.md, the authority table).
// UI thread; serialized against reloadInstrument's own contour publish.
void publishLiveContours(const PlaySeconds& play);
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
// on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
// fixed stereo, so a mode change never renegotiates host I/O.
@@ -415,6 +422,9 @@ private:
// Both live_ and draining_ observe this same block — a block owned by a snapshot would
// leave the drain's still-sounding voices deaf to the knob under them.
instrument::engine::LiveParams liveParams_;
// SampleData::liveContours' one target, under liveParams_' ordering constraint above and for
// its reason (this directory's CLAUDE.md owns why it needs no automation merge).
instrument::engine::LiveContourBlock liveContours_;
// 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 the RESULT moved — so neither an unchanged model nor a lane
@@ -432,10 +442,10 @@ private:
// 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
// LiveParams::read's lock-free seqlock retry.
// Serializes the writer sites of liveParams_ (reloadInstrument, publishLiveParams) and of
// liveContours_ (reloadInstrument, publishLiveContours) only — separate from reloadMutex_ so
// a drag's publish never blocks behind a reload's WAV decode. The audio thread never takes
// this; it only reads via the seqlock's lock-free retry.
std::mutex livePublishMutex_;
// The output rate the loaded capture's play params were resolved at (SampleData::
// outputRate — the host's), so a live republish resolves the stored wall-clock seconds to