fix(vst): master-gain per-sample ramp (no zipper/click); voice-count commits once on release; minor review items

This commit is contained in:
2026-07-27 23:30:27 -04:00
parent 43155cf320
commit e2c73e30d6
7 changed files with 116 additions and 43 deletions
+34 -22
View File
@@ -577,16 +577,14 @@ void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
}
switch (static_cast<ParamControl>(id)) {
case ParamControl::kVoiceCount: {
// Stepped: quantize the continuous drag to the integer count and only fire the
// setter on a CHANGE (each fire is an off-thread engine rebuild — cheap, but not
// free; per-step is the right cadence).
// Stepped: quantize the continuous drag to the integer count and track it live
// for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on
// WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild,
// not thirty.
const int count =
kMinVoiceCount +
static_cast<int>(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5);
if (count != voiceCount_) {
voiceCount_ = count;
processor_->setVoiceCount(count);
}
voiceCount_ = count;
return;
}
case ParamControl::kMasterGain:
@@ -1421,16 +1419,19 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA
}
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
const PerformanceZone& zone) {
const PerformanceZone& zone,
bool drawCaption) {
if (r.width() <= 0 || r.height() <= 0) return; // suppressed (window too narrow)
// The bordered box: a panel surface + hairline border, drawn by palette role.
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
// A corner caption (decorative — the axes are velocity -> amp).
kitText(bmp, Rect{r.left + 4, r.top + 1, r.right - 4, r.top + 12}, "Vel curve",
Font::Micro, Role::TextDim);
// A corner caption (decorative — the axes are velocity -> amp). Suppressed when the caller
// (e.g. the curve popup) renders its own sheet title so the label doesn't double.
if (drawCaption)
kitText(bmp, Rect{r.left + 4, r.top + 1, r.right - 4, r.top + 12}, "Vel curve",
Font::Micro, Role::TextDim);
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
@@ -1611,8 +1612,9 @@ void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
}
// The full-size editor: the SAME draw path as the Zone inline box (paintVelocityCurve +
// the one curveBoxFromRect mapping formula), so trace/handles/drag-off cues cannot drift
// between the two surfaces. The popup edits the picked capture's one-zone site.
paintVelocityCurve(bmp, pl.curveBox, effectiveSampleZone());
// between the two surfaces. The popup edits the picked capture's one-zone site. Caption
// suppressed (the sheet's own "VELOCITY -> AMP" title above is the label for this context).
paintVelocityCurve(bmp, pl.curveBox, effectiveSampleZone(), /*drawCaption=*/false);
}
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) {
@@ -2627,9 +2629,6 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
GetClientRect(childHwnd_, &rc);
const int w = rc.right - rc.left;
const int h = rc.bottom - rc.top;
// r11: the Sample bands derive from the deck height (mode-independent width math).
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
const int dx = x - dragStartX_;
if (drag_ == DragKind::kDeckKnob) {
@@ -2642,6 +2641,11 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
return;
}
// r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted
// below the kDeckKnob early-return — that branch uses neither deckDescs nor bands.
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
if (drag_ == DragKind::kRootMarker) {
// The fenced root strip on the Sample cluster band. Setting the root materializes a
// full-keyboard zone carrying the override on the picked id (the D-B override vehicle) —
@@ -2831,15 +2835,19 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
curvePointIndex_ = -1;
dragCurveZone_ = -1;
// A scrollbar drag is transient UI (no map change), and the processor-side knobs (the
// preview-velocity -2 sentinel, voice count, master gain) are per-instance settings already
// applied live — none reloads the instrument here (voice count rebuilds per step in its
// setter; master gain is an atomic the audio thread reads directly). Every other drag is a
// coherent map edit: publish the in-flight map + reload off-thread.
// preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that
// don't reload the instrument via the map path. Master gain is an atomic the audio thread
// reads directly. Voice count: the label/needle tracks live during the drag but the engine
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
const bool deckTransient =
kind == DragKind::kDeckKnob &&
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
paramId == static_cast<int>(ParamControl::kMasterGain));
if (kind == DragKind::kScrollThumb || deckTransient) {
// Commit the voice count now that the drag is complete (one rebuild per full drag).
if (deckTransient && processor_ &&
paramId == static_cast<int>(ParamControl::kVoiceCount))
processor_->setVoiceCount(voiceCount_);
invalidate();
return;
}
@@ -2873,11 +2881,15 @@ void ReaSamplerEditor::onMouseRDown(int x, int y) {
GetClientRect(childHwnd_, &rc);
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
if (!contains(pl.curveBox, x, y)) return;
// Hit-test first (read-only, via effectiveSampleZone) so a right-click that lands between
// nodes does not materialize an uncommitted zone in map_. Materialize only on an actual hit.
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
const int idx = effectiveSampleZone().velocityCurve.pointAtPixel(box, x, y);
if (idx < 0) return;
const int zi = ensureSampleZone();
if (zi < 0) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zi)];
const int idx = z.velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
if (idx >= 0 && z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
selectedZone_ = zi;
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
commitAndReload();