instrument: spline EGs — hard points on the one shared spline, a drawn contour per envelope beside its staged state, payload v13

This commit is contained in:
2026-07-31 21:33:59 -04:00
parent f115904e4f
commit e44bd42dd9
33 changed files with 1739 additions and 364 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
`deck_groups`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it.
## Invariants
@@ -101,7 +101,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the control-value domain maps + the node-drag bounds that must match them, plus the ONE `faceLayout` band resolve every paint and hit-test path shares), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
+2 -1
View File
@@ -51,6 +51,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# matching sets, plus the two band-independent surfaces and the platform TU.
editor_session.cpp
editor_controls.cpp
editor_models.cpp
editor_paint.cpp
editor_paint_chrome.cpp
editor_paint_waveform.cpp
@@ -85,7 +86,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
capture_browser keyboard_strip sample_bands sample_chrome
waveform_view bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups curve_popup master_gain sample_usage file_bytes curve_law)
knob_deck deck_groups curve_popup spline_edit master_gain sample_usage file_bytes curve_law)
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
+30 -141
View File
@@ -1,9 +1,9 @@
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
// applyControl — seconds/fraction/frames <-> normalized 0..1), the control-id<->value binding
// against the pure `deck_groups` module's descriptors, and the envelope pack/unpack (which
// stored struct each overlay selection maps onto). Value logic only — no painting, no window
// plumbing.
// against the pure `deck_groups` module's descriptors, and the node-drag clamp bounds that must
// match those domains. The orthogonal half — which stored struct each editor selection names —
// is editor_models. Value logic only: no painting, no window plumbing.
#include "shell/instrument/reasampler_editor.h"
@@ -99,6 +99,12 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
case ParamControl::kAmpEnvMode:
return play.ampSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case ParamControl::kPitchEnvMode:
return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case ParamControl::kFilterEnvMode:
return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
@@ -164,8 +170,26 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode:
// Gate is refused while any EG is drawn — see splineActive (play_params.h). The
// segment paints Disabled for the same reason, so the refusal is never a surprise.
if (segment == 0 && splineActive(play)) break;
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
break;
// Switching TO Spline drops Gate, which the spline model has no place for. Switching
// back does NOT restore it: the previous mode is not stored, and silently re-gating an
// instrument the user has since heard as a one-shot is the worse surprise.
case ParamControl::kAmpEnvMode:
case ParamControl::kPitchEnvMode:
case ParamControl::kFilterEnvMode: {
const EnvMode m = (segment == 1) ? EnvMode::Spline : EnvMode::Staged;
switch (static_cast<ParamControl>(id)) {
case ParamControl::kAmpEnvMode: play.ampSpline.mode = m; break;
case ParamControl::kPitchEnvMode: play.pitchSpline.mode = m; break;
default: play.filterSpline.mode = m; break;
}
if (splineActive(play)) play.playMode = PlayMode::Trigger;
break;
}
case ParamControl::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break;
@@ -409,9 +433,9 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
}
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
// Match the deck knobs' own domains so a node drag can never produce a param a knob
// couldn't. Every stage time caps at kEnvTimeMaxSeconds; the Hold fractions and the sustain
// level are [0,1] by definition and need no bound here.
// Lives here, beside controlValue/applyControl, because it must MATCH them: a node drag can
// never produce a param a knob couldn't. Every stage time caps at kEnvTimeMaxSeconds; the
// Hold fractions and the sustain level are [0,1] by definition and need no bound here.
EnvClampBounds b;
b.maxAttackSeconds = kEnvTimeMaxSeconds;
b.maxHoldSeconds = kEnvTimeMaxSeconds;
@@ -420,141 +444,6 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
return b;
}
namespace {
// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in
// one place rather than two.
void packAhdsr(const AdsrSeconds& a, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahdsr;
env.attackSeconds = a.attackSeconds;
env.holdSeconds = a.holdSeconds;
env.decaySeconds = a.decaySeconds;
env.sustainLevel = a.sustainLevel;
env.releaseSeconds = a.releaseSeconds;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.releaseCurve = a.releaseCurve;
}
void unpackAhdsr(const StageEnvelope& env, AdsrSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.holdSeconds = env.holdSeconds;
a.decaySeconds = env.decaySeconds;
a.sustainLevel = env.sustainLevel;
a.releaseSeconds = env.releaseSeconds;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
a.releaseCurve = env.releaseCurve;
}
void packAhd(const AhdSeconds& a, double originSeconds, double spanSeconds, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahd;
env.attackSeconds = a.attackSeconds;
env.decaySeconds = a.decaySeconds;
env.holdFraction = a.holdFraction;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.originSeconds = originSeconds;
env.spanSeconds = spanSeconds;
}
void unpackAhd(const StageEnvelope& env, AhdSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.decaySeconds = env.decaySeconds;
a.holdFraction = env.holdFraction;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
}
} // namespace
StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds& play,
std::int64_t frames,
std::int64_t startFrame) const {
StageEnvelope env;
const double rate = liveSampleRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
const double playSpan = rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0;
const double fullSpan =
rate > 0.0 ? static_cast<double>((std::max)(std::int64_t{0}, frames - startFrame)) / rate
: 0.0;
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
packAhd(play.pitchEnv.shape, t0, fullSpan, env);
break;
case OverlayEnv::kFilter:
if (trigger) packAhd(play.filter.trigEnv, t0, playSpan, env);
else packAhdsr(play.filter.env, env);
break;
case OverlayEnv::kAmp:
if (trigger) packAhd(play.trigAhd, t0, playSpan, env);
else packAhdsr(play.adsr, env);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active; a default-constructed StageEnvelope, not amp
}
return env;
}
void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env,
PlaySeconds& play) const {
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
unpackAhd(env, play.pitchEnv.shape);
break;
case OverlayEnv::kFilter:
if (trigger) unpackAhd(env, play.filter.trigEnv);
else unpackAhdsr(env, play.filter.env);
break;
case OverlayEnv::kAmp:
if (trigger) unpackAhd(env, play.trigAhd);
else unpackAhdsr(env, play.adsr);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active, so there is nothing a drag could have edited
}
}
const VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) const {
switch (target) {
case CurveTarget::kPitch: return params_.play.pitchVelocityCurve;
case CurveTarget::kFilter: return params_.play.filter.velocityCurve;
case CurveTarget::kAmp:
case CurveTarget::kNone:
// kNone reads as amp — a valid, harmless choice for a target-agnostic caller (a
// disabled deck cell still needs SOME curve to draw). editedCurve()'s mutable
// overload below refuses kNone rather than relying on this fallback to protect amp.
return params_.velocityCurve;
}
return params_.velocityCurve;
}
VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) {
return const_cast<VelocityCurve&>(std::as_const(*this).curveFor(target));
}
const VelocityCurve& ReaSamplerEditor::editedCurve() const { return curveFor(curvePopup_); }
VelocityCurve& ReaSamplerEditor::editedCurve() {
// Refuses kNone rather than aliasing amp (see curvePopup_'s declaration for why); should be
// unreachable now that every writer of kNone also cancels the drag, but this is the second,
// independent line of defense.
if (curvePopup_ == CurveTarget::kNone) {
static VelocityCurve sink = VelocityCurve::flat();
return sink;
}
return curveFor(curvePopup_);
}
void ReaSamplerEditor::closeCurvePopup() {
curvePopup_ = CurveTarget::kNone;
if (drag_ == DragKind::kCurveNode) {
drag_ = DragKind::kNone;
curvePointIndex_ = -1;
}
}
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
+44 -34
View File
@@ -1,6 +1,7 @@
// editor_input_curve.cpp — the velocity-curve popup's input: the modal click routing,
// node grab/add/Alt-delete inside the curve box, the live node drag, the right-click
// delete, and the popup's hover. Band-independent (the sheet floats over the whole face).
// node grab/add/toggle/delete inside the curve box, the live node drag, the right-click
// delete on EITHER spline surface, and the popup's hover. Band-independent (the sheet floats
// over the whole face).
// Windows-only.
#include "shell/instrument/reasampler_editor.h"
@@ -8,6 +9,8 @@
#ifdef _WIN32
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea (the overlay right-click)
#include "shell/instrument/editor_internal.h" // curveBoxFromRect
#include "shell/instrument/reasampler_processor.h"
@@ -42,37 +45,37 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
int idx = editedCurve().pointAtPixel(box, x, y);
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
if (editedCurve().deletePoint(static_cast<std::size_t>(idx))) {
// Alt-click delete predates the converged grammar and is kept as a landed alternate; every
// other gesture routes through the shared resolver so both spline consumers stay one
// grammar.
if ((GetKeyState(VK_MENU) & 0x8000) != 0) {
const int alt = editedCurve().pointAtPixel(box, x, y);
if (alt >= 0 && editedCurve().deletePoint(static_cast<std::size_t>(alt))) {
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
commitAndReload();
}
return;
}
const bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
const SplineEdit edit = resolveSplineEdit(
editedCurve(), box, ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft, x, y);
if (edit.kind == SplineEditKind::kToggleHard) {
if (editedCurve().toggleHard(static_cast<std::size_t>(edit.index))) commitAndReload();
return;
}
if (edit.kind == SplineEditKind::kNone) return; // ring click with no node hit
// Snapshot BEFORE any mutation so a capture-loss rollback also cancels an in-flight ADD
// (mirror of the other parameter-editing drags' dragStartParams_ contract).
dragStartParams_ = params_;
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an
// endpoint. A ring click can still grab an existing node (handled above).
if (idx < 0) {
const bool inBox = (x >= box.left && x < box.left + box.width &&
y >= box.top && y < box.top + box.height);
if (inBox) {
const VelocityPoint p = editedCurve().pointFromPixel(box, x, y);
idx = static_cast<int>(editedCurve().addPoint(p.velocity, p.value));
}
int idx = edit.index;
if (edit.kind == SplineEditKind::kAdd) {
const VelocityPoint p = editedCurve().pointFromPixel(box, x, y);
idx = editedCurve().addPoint(p.velocity, p.value);
if (idx < 0) return; // at the point ceiling: refused, curve untouched
}
if (idx < 0) return; // ring click with no node hit — nothing to grab
drag_ = DragKind::kCurveNode;
curvePointIndex_ = idx;
dragStartCurve_ = editedCurve(); // AFTER the add — resolvePointDrag's delta base
@@ -94,23 +97,30 @@ void ReaSamplerEditor::dragCurve(int x, int y) {
}
void ReaSamplerEditor::onMouseRDown(int x, int y) {
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
// and drag-off remain as landed alternates. Commits immediately through the same path as
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
// Right-clicks act only while the popup is open, and never during an in-flight left drag.
if (!processor_ || view_ == View::kBrowse || curvePopup_ == CurveTarget::kNone) return;
// Right-click deletes a node on EITHER spline surface — the primary delete affordance;
// Alt-click and drag-off remain as landed alternates in the popup. deletePoint's endpoint
// guard makes an endpoint right-click a safe no-op. Never acts during an in-flight left
// drag; the modal popup wins when it is open.
if (!processor_ || view_ == View::kBrowse) return;
if (drag_ != DragKind::kNone) return;
RECT rc{};
GetClientRect(childHwnd_, &rc);
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
if (!contains(pl.curveBox, x, y)) return;
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
const int idx = editedCurve().pointAtPixel(box, x, y);
if (idx < 0) return;
if (editedCurve().deletePoint(static_cast<std::size_t>(idx))) {
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
commitAndReload();
if (curvePopup_ != CurveTarget::kNone) {
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
if (!contains(pl.curveBox, x, y)) return;
const SplineEdit edit = resolveSplineEdit(editedCurve(), curveBoxFromRect(pl.curveBox),
SplineGesture::kRight, x, y);
if (edit.kind != SplineEditKind::kDelete) return;
if (editedCurve().deletePoint(static_cast<std::size_t>(edit.index))) {
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
commitAndReload();
}
return;
}
if (!overlayIsSpline() || !overlayEnvEnabled(overlayEnv_, deckEnableState())) return;
const FaceLayout fl = faceLayout(rc.right - rc.left, rc.bottom - rc.top);
splineOverlayClick(waveformOverlayArea(fl.bands.waveform), x, y, SplineGesture::kRight,
/*addOnEmptySpace=*/false);
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
+1 -2
View File
@@ -19,8 +19,7 @@ using namespace reasampler::instrument::ui;
bool ReaSamplerEditor::deckKnobDisabled(int id) const {
// Thin int-id wrapper over the pure, CTest-covered predicate — see deckKnobInert
// (deck_groups.h) for which cells go inert and why.
return deckKnobInert(static_cast<DeckParam>(id), params_.play.pitchEnv.enabled,
params_.play.filter.enabled);
return deckKnobInert(static_cast<DeckParam>(id), deckEnableState());
}
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
+70 -2
View File
@@ -14,6 +14,7 @@
#include "core/instrument/engine/loop/loop_span.h" // maxCrossfade (the shared drag-clamp bound)
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
@@ -30,13 +31,24 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
if (frames <= 0) return false;
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
// A drawn EG's own point actions come first, for the same reason the staged nodes do —
// they sit on top of the markers. Its ADD, which any empty-space click satisfies, is held
// back until after the markers below, or a spline overlay would make them unreachable.
const DeckEnableState gates = deckEnableState();
const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates);
const SplineGesture gesture = (GetKeyState(VK_CONTROL) & 0x8000) != 0
? SplineGesture::kControlLeft
: SplineGesture::kLeft;
if (splineLive && splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false)) {
return true;
}
// Envelope nodes first (they sit on top of the markers), then the wave markers. With no
// envelope overlay-active — or with its deck group's enable toggle off, which makes the
// same params' knobs inert — there are no grabbable nodes and the markers take every grab.
const double rate = liveSampleRate();
const bool nodesLive =
overlayEnv_ != OverlayEnv::kNone &&
!overlayEnvInert(overlayEnv_, params_.play.pitchEnv.enabled, params_.play.filter.enabled);
overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates);
if (rate > 0.0 && nodesLive) {
const std::int64_t startFrame = params_.startPoint.value_or(0);
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
@@ -71,9 +83,54 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
beginMarkerDrag(static_cast<WaveMarker>(hit), m, frames, x);
return true;
}
// Nothing else wanted the click: now the drawn contour may take the empty space.
if (splineLive) return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/true);
return false;
}
bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, int y,
SplineGesture gesture, bool addOnEmptySpace) {
const VelocityCurve::Box box = splineOverlayBox(waveArea);
VelocityCurve& contour = splineFor(overlayEnv_);
const SplineEdit edit = resolveSplineEdit(contour, box, gesture, x, y);
if (edit.kind == SplineEditKind::kAdd && !addOnEmptySpace) return false;
switch (edit.kind) {
case SplineEditKind::kNone:
return false;
case SplineEditKind::kDelete:
// deletePoint refuses the two endpoints, so a right-click on one is a safe no-op.
if (!contour.deletePoint(static_cast<std::size_t>(edit.index))) return true;
hover_ = HoverTarget{}; // a stale index would light a shifted node
commitAndReload();
return true;
case SplineEditKind::kToggleHard:
if (!contour.toggleHard(static_cast<std::size_t>(edit.index))) return true;
commitAndReload();
return true;
case SplineEditKind::kAdd:
case SplineEditKind::kGrab:
break;
}
// Snapshot BEFORE the add so a capture-loss rollback cancels the in-flight point too
// (the same contract the other parameter-editing drags keep).
dragStartParams_ = params_;
int index = edit.index;
if (edit.kind == SplineEditKind::kAdd) {
const VelocityPoint p = contour.pointFromPixel(box, x, y);
index = contour.addPoint(p.velocity, p.value);
// At the ceiling: refused, contour untouched, nothing to roll back. Swallow the click
// rather than letting it fall through to a marker grab under the cursor.
if (index < 0) return true;
}
drag_ = DragKind::kSplineNode;
curvePointIndex_ = index;
dragStartCurve_ = contour; // AFTER the add — resolvePointDrag's delta base
dragStartX_ = x;
dragStartY_ = y;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
return true;
}
void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m,
std::int64_t frames, int x) {
drag_ = DragKind::kWaveMarker;
@@ -88,6 +145,17 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
const int dx = x - dragStartX_;
if (drag_ == DragKind::kSplineNode) {
// Same absolute-delta contract as the popup's node drag, against the grab-time contour
// and the overlay's own box.
if (curvePointIndex_ < 0) return;
splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag(
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
splineOverlayBox(overlay), dx, y - dragStartY_);
invalidate(); // live feedback; commit on release
return;
}
if (drag_ == DragKind::kEnvNode) {
// Resolve the grabbed envelope node's new params from the pixel delta (through the
// pure envelope_edit inverse map, clamped), then unpack them back onto the parameter
+188
View File
@@ -0,0 +1,188 @@
// editor_models.cpp — the ReaSamplerEditor's model-selection half: which STORED struct each
// transient editor selection names. The overlay selection maps onto a staged envelope
// (pack/unpack) or a drawn contour; a curve-popup target maps onto one of the three velocity
// curves. One switch per family, so a moved field or a fourth curve is a one-place edit.
// Split from editor_controls, which owns the orthogonal half: the control-value domain maps.
#include "shell/instrument/reasampler_editor.h"
#include <cstdint>
#include <utility> // std::as_const (the const/non-const accessor pairs)
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the Trigger play span)
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam
namespace {
// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in
// one place rather than two.
void packAhdsr(const AdsrSeconds& a, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahdsr;
env.attackSeconds = a.attackSeconds;
env.holdSeconds = a.holdSeconds;
env.decaySeconds = a.decaySeconds;
env.sustainLevel = a.sustainLevel;
env.releaseSeconds = a.releaseSeconds;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.releaseCurve = a.releaseCurve;
}
void unpackAhdsr(const StageEnvelope& env, AdsrSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.holdSeconds = env.holdSeconds;
a.decaySeconds = env.decaySeconds;
a.sustainLevel = env.sustainLevel;
a.releaseSeconds = env.releaseSeconds;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
a.releaseCurve = env.releaseCurve;
}
void packAhd(const AhdSeconds& a, double originSeconds, double spanSeconds, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahd;
env.attackSeconds = a.attackSeconds;
env.decaySeconds = a.decaySeconds;
env.holdFraction = a.holdFraction;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.originSeconds = originSeconds;
env.spanSeconds = spanSeconds;
}
void unpackAhd(const StageEnvelope& env, AhdSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.decaySeconds = env.decaySeconds;
a.holdFraction = env.holdFraction;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
}
} // namespace
StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds& play,
std::int64_t frames,
std::int64_t startFrame) const {
StageEnvelope env;
const double rate = liveSampleRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
const double playSpan = rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0;
const double fullSpan =
rate > 0.0 ? static_cast<double>((std::max)(std::int64_t{0}, frames - startFrame)) / rate
: 0.0;
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
packAhd(play.pitchEnv.shape, t0, fullSpan, env);
break;
case OverlayEnv::kFilter:
if (trigger) packAhd(play.filter.trigEnv, t0, playSpan, env);
else packAhdsr(play.filter.env, env);
break;
case OverlayEnv::kAmp:
if (trigger) packAhd(play.trigAhd, t0, playSpan, env);
else packAhdsr(play.adsr, env);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active; a default-constructed StageEnvelope, not amp
}
return env;
}
void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env,
PlaySeconds& play) const {
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
unpackAhd(env, play.pitchEnv.shape);
break;
case OverlayEnv::kFilter:
if (trigger) unpackAhd(env, play.filter.trigEnv);
else unpackAhdsr(env, play.filter.env);
break;
case OverlayEnv::kAmp:
if (trigger) unpackAhd(env, play.trigAhd);
else unpackAhdsr(env, play.adsr);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active, so there is nothing a drag could have edited
}
}
instrument::ui::DeckEnableState ReaSamplerEditor::deckEnableState() const {
const PlaySeconds& play = params_.play;
return instrument::ui::DeckEnableState{
play.pitchEnv.enabled, play.filter.enabled,
play.ampSpline.mode == EnvMode::Spline,
play.pitchSpline.mode == EnvMode::Spline,
play.filterSpline.mode == EnvMode::Spline};
}
const VelocityCurve& ReaSamplerEditor::splineFor(OverlayEnv which) const {
switch (which) {
case OverlayEnv::kPitch: return params_.play.pitchSpline.contour;
case OverlayEnv::kFilter: return params_.play.filterSpline.contour;
case OverlayEnv::kAmp:
case OverlayEnv::kNone:
break;
}
return params_.play.ampSpline.contour;
}
VelocityCurve& ReaSamplerEditor::splineFor(OverlayEnv which) {
return const_cast<VelocityCurve&>(std::as_const(*this).splineFor(which));
}
bool ReaSamplerEditor::overlayIsSpline() const {
switch (overlayEnv_) {
case OverlayEnv::kAmp: return params_.play.ampSpline.mode == EnvMode::Spline;
case OverlayEnv::kPitch: return params_.play.pitchSpline.mode == EnvMode::Spline;
case OverlayEnv::kFilter: return params_.play.filterSpline.mode == EnvMode::Spline;
case OverlayEnv::kNone: return false;
}
return false;
}
const VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) const {
switch (target) {
case CurveTarget::kPitch: return params_.play.pitchVelocityCurve;
case CurveTarget::kFilter: return params_.play.filter.velocityCurve;
case CurveTarget::kAmp:
case CurveTarget::kNone:
// kNone reads as amp — a valid, harmless choice for a target-agnostic caller (a
// disabled deck cell still needs SOME curve to draw). editedCurve()'s mutable
// overload below refuses kNone rather than relying on this fallback to protect amp.
return params_.velocityCurve;
}
return params_.velocityCurve;
}
VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) {
return const_cast<VelocityCurve&>(std::as_const(*this).curveFor(target));
}
const VelocityCurve& ReaSamplerEditor::editedCurve() const { return curveFor(curvePopup_); }
VelocityCurve& ReaSamplerEditor::editedCurve() {
// Refuses kNone rather than aliasing amp (see curvePopup_'s declaration for why); should be
// unreachable now that every writer of kNone also cancels the drag, but this is the second,
// independent line of defense.
if (curvePopup_ == CurveTarget::kNone) {
static VelocityCurve sink = VelocityCurve::flat();
return sink;
}
return curveFor(curvePopup_);
}
void ReaSamplerEditor::closeCurvePopup() {
curvePopup_ = CurveTarget::kNone;
if (drag_ == DragKind::kCurveNode) {
drag_ = DragKind::kNone;
curvePointIndex_ = -1;
}
}
} // namespace reasampler::vst
+26 -15
View File
@@ -30,13 +30,16 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
// `seg0Disabled` disables ONE segment: Gate is unselectable while an EG is drawn, but
// Trigger — the mode it is stuck in — must still read as the live choice.
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
bool seg1Active, bool disabled) {
bool seg1Active, bool disabled, bool seg0Disabled = false) {
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
const bool d0 = disabled || seg0Disabled;
const InteractionState st0 =
disabled ? InteractionState::Disabled
: (!seg1Active ? InteractionState::Active
: (hov ? InteractionState::Hover : InteractionState::Rest));
d0 ? InteractionState::Disabled
: (!seg1Active ? InteractionState::Active
: (hov ? InteractionState::Hover : InteractionState::Rest));
const InteractionState st1 =
disabled ? InteractionState::Disabled
: (seg1Active ? InteractionState::Active
@@ -44,12 +47,13 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
disabled ? Role::TextDim
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
d0 ? Role::TextDim
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
disabled ? Role::TextDim
: (seg1Active ? Role::BgBase : Role::TextPrimary));
};
const bool anySpline = splineActive(play);
// The knob's short name label (swapped for the live value during hover/drag — no third
// line, no permanent value clutter).
@@ -132,25 +136,32 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
}
}
// The compact caption toggle (right-anchored in the caption row, never full-width).
if (g.captionToggle.id >= 0) {
switch (static_cast<ParamControl>(g.captionToggle.id)) {
// The compact caption toggles (right-anchored in the caption row, never full-width).
for (const DeckToggleLayout* tp : {&g.captionToggle, &g.captionToggle2}) {
if (tp->id < 0) continue;
const DeckToggleLayout& t = *tp;
switch (static_cast<ParamControl>(t.id)) {
case ParamControl::kPlayMode:
drawToggle(g.captionToggle, "Gate", "Trigger",
play.playMode == PlayMode::Trigger, false);
drawToggle(t, "Gate", "Trigger", play.playMode == PlayMode::Trigger, false,
/*seg0Disabled=*/anySpline);
break;
case ParamControl::kPitchEngine:
drawToggle(g.captionToggle, "Varisp", "Presrv",
drawToggle(t, "Varisp", "Presrv",
play.pitchEngine == PitchEngine::Preserve, false);
break;
case ParamControl::kPitchEnvEnable:
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
drawToggle(t, "Off", "On", play.pitchEnv.enabled, false);
break;
case ParamControl::kVoiceMode:
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
drawToggle(t, "Poly", "Mono", isMono, false);
break;
case ParamControl::kFilterEnable:
drawToggle(g.captionToggle, "Off", "On", play.filter.enabled, false);
drawToggle(t, "Off", "On", play.filter.enabled, false);
break;
case ParamControl::kAmpEnvMode:
case ParamControl::kPitchEnvMode:
case ParamControl::kFilterEnvMode:
drawToggle(t, "Stg", "Spl", deckControlNorm(t.id) > 0.5, false);
break;
default: break;
}
@@ -13,6 +13,7 @@
#include <vector>
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
#include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box)
#include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX
#include "shell/instrument/editor_internal.h" // kit adapters
#include "shell/instrument/reasampler_processor.h"
@@ -131,11 +132,59 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
paintEnvelopeOverlay(bmp, overlay, frames);
}
void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea) {
const VelocityCurve::Box box = splineOverlayBox(waveArea);
if (box.width <= 0 || box.height <= 1) return;
const VelocityCurve& curve = splineFor(overlayEnv_);
// One eval per drawn column, through the curve's own pixel maps, so the trace and the
// handles share the coordinate system the hit-test resolves against.
const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
int prevX = 0, prevY = 0;
for (int px = 0; px <= box.width; ++px) {
const int cx = box.left + px;
const double t = curve.pointFromPixel(box, cx, box.top).velocity;
const int cy = curve.pixelFromPoint(box, {t, curve.eval(t)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
prevX = cx;
prevY = cy;
}
// Handles carry two independent states on the same mark, so they use two independent
// channels: SIZE is the grab (the staged painter's grammar — a hotter hue reads as lower
// contrast over the lime, see its note), and HOLLOW is hard. The corner itself is the
// primary hard cue, but a corner between two near-collinear segments has none to show.
const LICE_pixel handle = toLice(roleColor(Role::OverlayTrace));
const LICE_pixel core = toLice(roleColor(Role::BgBase));
const std::vector<instrument::engine::VelocityPoint>& pts = curve.points();
for (std::size_t i = 0; i < pts.size(); ++i) {
const auto np = curve.pixelFromPoint(box, pts[i]);
const bool grabbed =
(drag_ == DragKind::kSplineNode && curvePointIndex_ == static_cast<int>(i));
const int r = grabbed ? kEnvHandleGrabbedRadius : kEnvHandleRadius;
const int ir = r - kEnvHandleRingPx;
const int hx = (std::max)(box.left + r,
(std::min)(box.left + box.width - 1 - r, np.x));
const int hy = (std::max)(box.top + r,
(std::min)(box.top + box.height - 1 - r, np.y));
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, handle, 1.0f, 0);
if (pts[i].hard && ir > 0) {
LICE_FillRect(bmp, hx - ir, hy - ir, 2 * ir, 2 * ir, core, 1.0f, 0);
}
}
}
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea,
std::int64_t frames) {
if (overlayEnv_ == OverlayEnv::kNone) return; // no envelope selected is a resting state
const Rect& area = waveArea.rect;
if (frames <= 0 || area.width <= 0 || area.height <= 0) return;
if (overlayIsSpline()) {
// The contour is a pure function of normalized position, so it needs neither the frame
// count nor the live rate the staged path below resolves its seconds against.
paintSplineOverlay(bmp, waveArea);
return;
}
const double rate = liveSampleRate();
if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
+39 -8
View File
@@ -20,6 +20,7 @@
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
#include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams
@@ -78,8 +79,11 @@ private:
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
// kSplineNode is the overlay's peer of kCurveNode: the same VelocityCurve point drag, over
// the waveform overlay's box and the overlay-active envelope's contour rather than the
// popup's box and curve.
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kDeckKnob };
kCurveNode, kSplineNode, kDeckKnob };
// Which envelope the waveform overlay is drawing and editing. The selection type and its
// whole state machine are the pure deck_groups module's; this alias keeps the shell's
@@ -168,9 +172,20 @@ private:
// The velocity transfer-curve editor (X = velocity 0-127, Y = the curve's own domain); its
// only host is the popup sheet. `r` empty -> draws nothing.
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at
// full band height (never per lane).
// Traces the overlay-active envelope + its draggable handles over `waveArea`, ONCE at
// full band height (never per lane). Dispatches on the envelope's mode: the staged
// polyline, or the drawn contour below.
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea, std::int64_t frames);
// The spline EG's contour + point handles, spanning the overlay 1:1 with the sample's time
// axis. Hard points draw hollow so a corner is legible before it is steep.
void paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea);
// A click on the spline overlay, routed through the shared grammar; true when it consumed
// the click. `addOnEmptySpace` false resolves node actions only — the waveform band calls
// it that way BEFORE the start/loop markers and again after, since the contour's box is the
// whole band and an unconditional add would make every marker unreachable.
bool splineOverlayClick(const OverlayArea& waveArea, int x, int y,
instrument::ui::SplineGesture gesture, bool addOnEmptySpace);
// --- Input: the mouse-down dispatch and its per-band branches ---
void onMouseDown(int x, int y);
@@ -194,13 +209,15 @@ private:
void onMouseMove(int x, int y);
void onMouseUp(int x, int y);
// Right-click is the curve popup's primary node-delete affordance; only acts while the
// popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op).
// Right-click is the primary node-delete affordance on both spline surfaces — the popup
// while it is open, else the spline EG overlay (deletePoint's endpoint guard makes an
// endpoint right-click a no-op).
void onMouseRDown(int x, int y);
// Mouse-down inside curve-editor box `r`: a node grab starts a kCurveNode drag;
// Alt-click on an interior node deletes it at once; an empty-space click adds a point
// and grabs it.
// Mouse-down inside curve-editor box `r`, resolved through the shared point-editing
// grammar (spline_edit): a node grab starts a kCurveNode drag, an empty-space click adds a
// point and grabs it, control-click toggles a node hard/smooth. Alt-click delete predates
// that grammar and is kept as a landed alternate.
void handleCurveMouseDown(const Rect& r, int x, int y);
// Left-click while the curve popup is open (modal over the Sample face): Close /
@@ -466,6 +483,20 @@ private:
CurveTarget curvePopup_ = CurveTarget::kNone;
void closeCurvePopup();
// The group-gate state the two pure inert predicates read, built once from the parameter
// set so paint and hit-test can never assemble it differently.
instrument::ui::DeckEnableState deckEnableState() const;
// THE one switch from an overlay selection to the drawn contour it names, mirroring
// curveFor. kNone reads as amp — harmless for a target-agnostic caller, and every mutating
// path is gated on overlayIsSpline() first.
const VelocityCurve& splineFor(OverlayEnv which) const;
VelocityCurve& splineFor(OverlayEnv which);
// Whether the overlay-active envelope is in Spline mode — the branch every overlay paint,
// hit-test and drag path takes before touching either model.
bool overlayIsSpline() const;
// THE one switch from a CurveTarget to the parameter-set curve it names — paint (button
// thumbnails, for every target) and edit (editedCurve, for curvePopup_ specifically) both
// route through it, so a fourth curve or a moved field is a one-place edit.