instrument: deliver continuous playback params live to sounding voices via a seqlock block, holding normalized stage position across time edits

This commit is contained in:
2026-07-30 21:03:05 -04:00
parent 7bd911d58b
commit 1dade0bfcf
25 changed files with 1352 additions and 66 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` predicate — 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.
`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.
**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 the same way — no bridge read, no WAV re-decode, no snapshot rebuild.
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;
}
+17
View File
@@ -136,6 +136,23 @@ 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 {
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;
}
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
+10 -1
View File
@@ -148,7 +148,16 @@ 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_;
liveParams_.publish(instrument::engine::foldLive(sample.play));
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
}
}
}
+7
View File
@@ -152,6 +152,13 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
params_ = params;
}
void ReaSamplerProcessor::publishLiveParams() {
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
if (rate <= 0) return;
liveParams_.publish(
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate)));
}
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,15 @@ 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: 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.
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 +235,16 @@ 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_;
// 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.
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