Fix spline-drag rollback, bake contour leak, held-note live miss, amp clamp

Editor close mid-drag now rolls back published contours like capture-loss does; bake detaches liveContours so a printed capture can't observe a live drag; a held note now receives a live publish that lands in the same block as a note-on; amp spline read clamped to [0,1].
This commit is contained in:
2026-09-25 13:59:37 -04:00
parent f7505d0e53
commit 4567744b8f
10 changed files with 109 additions and 18 deletions
+1
View File
@@ -36,6 +36,7 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
// depend on what the user happened to be dragging. The dialed values are already in
// this SampleData's own play params, which is what the bake is meant to print.
sample.live = nullptr;
sample.liveContours = nullptr;
const int channels = sample.channelCount();
// The limiter delays its output by its lookahead, so the buffers carry that many extra
+3 -2
View File
@@ -225,8 +225,9 @@ private:
// 128-point ceiling.
//
// Holds a RAW POINTER into the bound point array: the caller guarantees the array outlives the
// cursor, or rebinds it first. The voice binds against its SampleData, which has exactly that
// lifetime, or against its engine's live contour copy, which the engine rebinds before reusing.
// cursor, or rebinds it first. The voice binds against its SampleData (that lifetime), or
// against its engine's live contour copy — the double-buffer contract VoiceEngine::contours_
// owns (voice_engine.h) is what makes that second binding safe.
class SplineCursor {
public:
// Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so
+2 -2
View File
@@ -335,8 +335,8 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
namespace {
// One EG's half of Voice::applyContours. `before` is read through the OLD binding, which is
// still intact: the engine only overwrites a buffer no active voice is bound to.
// One EG's half of Voice::applyContours. `before` is read through the OLD binding — still
// intact, by the double-buffer contract VoiceEngine::contours_ owns (voice_engine.h).
void rebindContour(SplineCursor& cursor, StepSmoother& smooth,
const instrument::engine::ContourPoints& pts, double phase, bool snap) {
if (!cursor.active()) return;
+6
View File
@@ -302,10 +302,16 @@ private:
// Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's
// finished()) — onFinalSegment()/segmentEndValue()'s own doc comments own the why.
amp = readSpline(ampSplineCur_, ampSplineSmooth_);
// A swap-step absorbed by ampSplineSmooth_ can briefly push the sum outside [0,1]
// (a contour's own points are drawn in-range, but the smoother's addend isn't); the
// filter clamps its own read for the same reason (below). The finished-check reads
// the PRE-clamp value so a genuine zero-terminus isn't masked by the clamp itself.
if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
ampSplineCur_.segmentEndValue() == 0.0) {
amplitudeDone_ = true;
}
if (amp < 0.0) amp = 0.0;
else if (amp > 1.0) amp = 1.0;
} else if (playMode_ == PlayMode::Gate) {
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
+6 -2
View File
@@ -69,9 +69,13 @@ void VoiceEngine::applyContoursToActive() {
}
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
// FIRST, so no sounding voice is left bound to a buffer the next read overwrites.
// FIRST, so no sounding voice is left bound to a buffer the next read overwrites. A held
// note sharing this generation with the note-on must see it too — refreshLive() alone
// would consume the generation without applying it, and since applyLiveToActive() only
// acts on a NEW generation, the held voice would then never see this publish at all
// (not merely late a block), only catching up whenever the next one arrives.
applyContoursToActive();
refreshLive();
applyLiveToActive();
// THE read of the note-on-latched commit class, and the only one: a published block outranks
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
// touches none of these three — so a move reaches the next note and no sounding one.
+16 -9
View File
@@ -91,8 +91,9 @@ else — no parameter set, no generation, no host callback — and its release i
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_`.
(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
@@ -241,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
+9
View File
@@ -162,6 +162,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
+2 -3
View File
@@ -394,9 +394,8 @@ 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.
// 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