instrument: snap live params onto a fresh voice, roll a live drag back on capture loss, serialize the seqlock's two writers

This commit is contained in:
2026-07-30 21:39:56 -04:00
parent 1dade0bfcf
commit bbc7dc70bb
15 changed files with 621 additions and 143 deletions
+5 -5
View File
@@ -68,7 +68,8 @@ scattered `#ifdef`s in the VST shell, except the one described below).
(`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` predicate — see
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
@@ -76,10 +77,9 @@ which route a control takes is decided once, by the pure `isLiveDeckParam` predi
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.
`liveParams_` is declared ahead of the instrument slots so it outlives every snapshot pointing at
it. The editor's `commitLive` is the tier-3 peer of `commitAndReload` and still writes the
parameter set, so persistence is unchanged — `getState` serializes `params_`, never a reload
artifact, and the reload was never the persistence trigger.
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
+1 -1
View File
@@ -105,7 +105,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
return;
}
// A live control already reached the voices during the drag; its release commits the
// final value the same way — no bridge read, no WAV re-decode, no snapshot rebuild.
// final value through the same tier.
if (dragCommitsLive(kind, paramId)) {
commitLive();
invalidate();
+16 -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,25 @@ 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 parameter, so dragStartParams_ is
// not a rollback target for them — reset drag state only. Every
// parameter-editing drag restores the pre-grab snapshot.
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)
+8 -7
View File
@@ -144,13 +144,14 @@ void ReaSamplerEditor::commitLive() {
}
bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
if (kind == DragKind::kDeckKnob) {
// Negative ids are the processor-side sentinels (preview velocity), not parameter-set
// controls, so they never reach the enum.
return paramId >= 0 &&
instrument::ui::isLiveDeckParam(static_cast<ParamControl>(paramId));
}
return kind == DragKind::kEnvNode && params_.play.playMode == PlayMode::Gate;
// 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) {
+7
View File
@@ -153,6 +153,13 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
}
void ReaSamplerProcessor::publishLiveParams() {
// reloadMutex_ enforces the block's SINGLE-WRITER contract (live_params.h), not the
// reload's slot bookkeeping: reloadInstrument publishes the block too, and two concurrent
// seqlock writers can leave the generation even mid-write, which a reader would accept as
// a coherent — but torn — block. The audio thread never takes this mutex, so the cost is
// an off-thread wait behind a reload. Lock order matches reloadInstrument's
// (reloadMutex_ then paramsMutex_, taken by instrumentParams below).
std::lock_guard<std::mutex> lock(reloadMutex_);
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
if (rate <= 0) return;
liveParams_.publish(
+11 -5
View File
@@ -154,11 +154,10 @@ public:
// 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: no bridge read, no WAV re-decode, no engine
// rebuild, no snapshot swap — the sounding note follows the knob. Persistence is
// unaffected: getState still serializes params_, so 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.
// 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
@@ -243,6 +242,13 @@ private:
// 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) ---