Make spline EG drags audible live: held and new notes follow a dragged knot

Contours ride their own seqlock block into a double-buffered engine copy, and the swap step is smoothed. Each drag move publishes contours without a model write; release still commits and reloads.
This commit is contained in:
2026-09-25 13:42:25 -04:00
parent 9ff34b0a9e
commit f7505d0e53
21 changed files with 788 additions and 97 deletions
+16 -4
View File
@@ -84,6 +84,16 @@ 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, the one
exit that commits nothing, re-couriers 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 +119,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 +134,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 +142,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
@@ -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;
}
+4
View File
@@ -312,6 +312,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) ||
@@ -153,6 +153,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
@@ -164,6 +165,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.
+15 -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;
@@ -206,6 +208,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.
@@ -387,6 +394,10 @@ 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_;
// The drawn-contour block, SampleData::liveContours' one target, under liveParams_' ordering
// constraint and for its reason. No automation merge: no contour is a host parameter, so both
// engines read the model/editor publish directly.
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
@@ -404,10 +415,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 rate the loaded capture was decoded/built at, so a live republish resolves the
// stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing