Merge Θ-W3-T1: live parameter delivery to sounding voices, holding normalized stage position across time edits

This commit is contained in:
2026-07-31 06:44:04 -04:00
26 changed files with 1857 additions and 88 deletions
+14
View File
@@ -67,6 +67,20 @@ scattered `#ifdef`s in the VST shell, except the one described below).
- **Verify** all identity/factory wiring against the vendored Steinberg SDK
(`DEF_CLASS2` / `INLINE_UID` / `FUID` from `pluginfactory.h` + `funknown.h`).
**The three commit tiers (Θ-W3).** An edit reaches the audio by exactly one of three routes, and
which route a control takes is decided once, by the pure `isLiveDeckParam` / `liveCommitFor` pair
(`core/instrument/ui/deck_groups`) that the editor's `dragCommitsLive` only maps onto — see
`core/instrument/CLAUDE.md`'s "Live parameter delivery" for the rule and its rationale.
1. **Full reload**`reloadInstrument`: bridge read, WAV re-decode, fresh engine, snapshot swap.
2. **Engine rebuild**`rebuildVoiceEngine`: same drain-slot swap around the already-decoded
`SampleData`. Voice count / mode / mono trigger.
3. **Live**`publishLiveParams` (and `masterGain_`, the original of the shape): a lock-free
publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk.
The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still writes the
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`.
**Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a
read-only act over the bank. Any instrument path that captures, places a timeline
+7
View File
@@ -104,6 +104,13 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
invalidate();
return;
}
// A live control already reached the voices during the drag; its release commits the
// final value through the same tier.
if (dragCommitsLive(kind, paramId)) {
commitLive();
invalidate();
return;
}
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
// its amp keeps the last clamped drag value).
@@ -107,6 +107,9 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
// grab. Live feedback; parameter-set commits land on WM_LBUTTONUP.
(void)x;
applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_));
// A live control is delivered on every move, not only on release — that is the whole
// point: the note already sounding tracks the hand on the knob.
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
invalidate();
}
@@ -78,6 +78,9 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds,
envClampBounds(), dx, y - dragStartY_);
unpackEnvelope(edited, frames, dragStartFrame_, params_.play);
// In Gate the node IS a live AHDSR control, so the sounding note follows the drag;
// Trigger's nodes rewrite the play span and still commit on release.
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
invalidate(); // live feedback; commit on WM_LBUTTONUP
return;
}
+20 -6
View File
@@ -205,8 +205,8 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
case WM_RBUTTONUP:
return 0; // claimed so the pair never reaches DefWindowProc (no context menu)
case WM_CAPTURECHANGED:
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its
// pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore params_ to its
// pre-grab snapshot so the in-flight drag mutation is rolled back, then reset
// the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
// Mirror of the panel shell's WM_CAPTURECHANGED handler (panel_window.cpp).
if (self) {
@@ -219,15 +219,29 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
}
if (self->drag_ != DragKind::kNone) {
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
// voice count / master gain) are transient (they mutate no parameter, so
// dragStartParams_ is not a rollback target) — reset drag state only.
// Every parameter-editing drag rolls its live mutation back to the snapshot.
// voice count / master gain) mutate no params_ field, so dragStartParams_
// is not a rollback target for them — reset drag state only. Every
// params_-editing drag restores the pre-grab snapshot. Voice count and
// master gain are pre-existing exceptions to that: both write straight
// through on every move (editor voiceCount_ / processor masterGain_)
// rather than through params_, so an abandoned drag leaves them at the
// abandoned value indefinitely instead of rolling back.
const bool transient = self->drag_ == DragKind::kScrollThumb ||
(self->drag_ == DragKind::kDeckKnob &&
(self->dragParamId_ == -2 ||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
if (!transient) self->params_ = self->dragStartParams_;
if (!transient) {
self->params_ = self->dragStartParams_;
// A live drag already reached the voices AND the processor's own
// parameter set on every move, so restoring params_ alone would leave
// the face painting one value while the audio plays — and getState
// persists — the abandoned one. Roll back through the same tier the
// drag used.
if (self->dragCommitsLive(self->drag_, self->dragParamId_)) {
self->commitLive();
}
}
self->drag_ = DragKind::kNone;
self->dragParamId_ = -1;
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
+18
View File
@@ -136,6 +136,24 @@ void ReaSamplerEditor::commitAndReload() {
#endif
}
void ReaSamplerEditor::commitLive() {
// UI thread only. See the declaration for why this still writes the parameter set.
if (!processor_) return;
processor_->setInstrumentParams(params_);
processor_->publishLiveParams();
}
bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
// The decision itself is the pure liveCommitFor's; this is only the shell's drag-kind
// vocabulary mapped onto it, so the routing is pinned by deck_groups' tests rather than
// by inspection of this file.
using instrument::ui::LiveDragKind;
const LiveDragKind k = kind == DragKind::kDeckKnob ? LiveDragKind::kDeckKnob
: kind == DragKind::kEnvNode ? LiveDragKind::kEnvNode
: LiveDragKind::kOther;
return instrument::ui::liveCommitFor(k, paramId, params_.play.playMode);
}
void ReaSamplerEditor::loadSelection(const std::string& id) {
// A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch
// engine, key-track, velocity curve) are NOT reset — the one set governs whatever is
+15 -1
View File
@@ -148,7 +148,21 @@ std::string ReaSamplerProcessor::reloadInstrument() {
if (pcm) {
sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm));
havePlayable = sample.playable();
if (havePlayable) resolvedId = selId; // the concrete pick that resolved
if (havePlayable) {
// Point the built snapshot at the instance's ONE live block and seed it from
// the very PlayParams the voices latch, so an untouched knob folds to the same
// frames the build resolved and a note-on with a live block sounds identical
// to one without.
sample.live = &liveParams_;
{
// 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));
}
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
}
}
}
+11
View File
@@ -152,6 +152,17 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
params_ = params;
}
void ReaSamplerProcessor::publishLiveParams() {
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
if (rate <= 0) return;
const instrument::engine::LiveValues block =
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate));
// livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against
// reloadInstrument's publish — held for the publish call only, not the fold above.
std::lock_guard<std::mutex> lock(livePublishMutex_);
liveParams_.publish(block);
}
SampleRefs ReaSamplerProcessor::sampleRefs() {
std::lock_guard<std::mutex> lock(refsMutex_);
return sampleRefs_;
+11
View File
@@ -233,6 +233,17 @@ private:
// instrument off the audio thread. UI thread only.
void commitAndReload();
// The live peer of commitAndReload for a continuously-valued control (isLiveDeckParam):
// the same parameter-set write — so a saved project carries the edit exactly as before —
// followed by a live publish instead of a rebuild, so the note already sounding follows
// the knob. Does not repaint; callers already do. UI thread only.
void commitLive();
// Whether an in-flight drag commits live rather than through a reload. A deck knob is
// live per isLiveDeckParam; an envelope-node drag is live only in Gate, where it edits the
// AHDSR — in Trigger the same drag rewrites the play span, which is not a live control.
bool dragCommitsLive(DragKind kind, int paramId = -1) const;
// Commits `id` as the loaded capture. The one parameter set carries over — it governs
// whatever is loaded, so a load swaps the sound, not the settings.
void loadSelection(const std::string& id);
@@ -19,6 +19,7 @@
#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/live_params.h" // LiveParams (the live-parameter block)
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::vst {
@@ -151,6 +152,14 @@ public:
InstrumentParams instrumentParams();
void setInstrumentParams(const InstrumentParams& params);
// 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
// directory's CLAUDE.md). Callers pair this with setInstrumentParams exactly as they
// paired it with reloadInstrument. No-op before anything has been decoded (the next reload
// bakes and publishes). UI thread; serialized against reloadInstrument's own publish.
void publishLiveParams();
// 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.
@@ -225,6 +234,28 @@ private:
ReaperBridge bridge_;
// The ONE live-parameter block for this instance, declared ahead of the instrument slots
// so it outlives every snapshot that points at it (members destruct in reverse order).
// 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_;
// 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.
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
// built yet.
//
// ONE BLOCK, ONE RATE: this is stamped by whichever capture built last, and a reload
// publishes the new block before installing the new instrument. Swapping to a capture at a
// different rate therefore hands drain voices still ringing from the old-rate capture
// envelope frame counts resolved at the NEW rate (~8.8% timing shift on a 48k->44.1k swap).
// Unavoidable while one block sits above every snapshot, and it touches a release tail
// only.
std::atomic<int> builtSampleRate_{0};
// --- The audio-thread handoff (drain slot) ---
// process() atomically loads live_ + draining_ at block start (two acquires, no lock).
// reloadInstrument() (off-thread, serialized by reloadMutex_) swaps a new build into