S-VIEW-10: velocity-curve editor UI — draggable spline box beside the Sample hero + in the Zone panel, all math through the pure velocity_curve module

This commit is contained in:
2026-07-27 15:51:36 -04:00
parent c92d82df3a
commit 0e8b1b72cf
5 changed files with 356 additions and 15 deletions
+224 -13
View File
@@ -693,11 +693,22 @@ tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
namespace {
constexpr int kPad = 8;
// The S-VIEW-10 velocity-curve editor box: a compact bordered box beside the hero waveform
// (amp-over-time beside amp-over-velocity — the two share the drawn-curve-with-handles grammar).
// Suppressed gracefully (empty rect) when the window is too narrow for both, mirroring
// prune_button's suppression rule. The INSET keeps node handles + the pick radius inside the
// border so an endpoint at amp 0/1 stays grabbable.
constexpr int kVelCurveBoxW = 168; // the curve box width (incl. border)
constexpr int kVelCurveMinHeroW = 240; // hero must keep at least this much width beside the box
constexpr int kVelCurveInset = 6; // border -> mapping-box inset (>= node half-size + 3)
constexpr int kCurveDragOffMargin = 24; // release beyond box+margin -> drag-off delete
struct SampleBands {
Rect title; // top: name + Browse/Zone nav buttons
Rect navBrowse; // the "Browse" title-band button
Rect navZone; // the "Zone" title-band button
Rect hero; // the hero waveform + S-VIEW-3 envelope overlay
Rect velCurve; // the S-VIEW-10 velocity-curve editor box (empty when suppressed)
Rect cluster; // root strip + preview-trigger + velocity knob + channel toggle
Rect control; // the param control strip (Mode / Pitch / AHDSR|Trigger / AD pitch / keyTrack)
};
@@ -715,7 +726,17 @@ SampleBands computeSampleBands(int w, int h) {
int y = titleH;
const int heroH = (std::min)(kHeroWaveformHeight, (std::max)(0, h - titleH));
b.hero = Rect{kPad, y, w - kPad, y + heroH};
const Rect fullHero{kPad, y, w - kPad, y + heroH};
// Carve the velocity-curve editor from the hero band's right when there is room for both;
// otherwise the hero keeps the full width and the curve editor is suppressed (empty rect).
if (fullHero.width() >= kVelCurveBoxW + kPad + kVelCurveMinHeroW) {
b.velCurve = Rect{fullHero.right - kVelCurveBoxW, fullHero.top, fullHero.right,
fullHero.bottom};
b.hero = Rect{fullHero.left, fullHero.top, b.velCurve.left - kPad, fullHero.bottom};
} else {
b.hero = fullHero;
b.velCurve = Rect{};
}
y += heroH;
const int clusterH = (std::min)(kClusterHeight, (std::max)(0, h - y));
b.cluster = Rect{0, y, w, y + clusterH};
@@ -784,6 +805,36 @@ Rect zonesControlPanel(const Rect& content) {
content.bottom - 4};
}
// The S-VIEW-10 velocity-curve editor inside the Zone param panel: right-anchored, top-aligned,
// with the control rows keeping the panel's left. Suppressed (empty rect) when the panel is too
// narrow/short for both — the controls then keep the full panel (the pre-S-VIEW-10 layout).
// Both draw + hit-test derive from these two formulas so they never drift.
constexpr int kVelCurveBoxH = 140; // the Zone-panel curve box height (Sample uses the hero's)
constexpr int kVelCurveMinPanelW = 220; // controls keep at least this much width beside the box
Rect zonesCurveBox(const Rect& content) {
const Rect panel = zonesControlPanel(content);
if (panel.width() < kVelCurveBoxW + kPad + kVelCurveMinPanelW || panel.height() < 80) {
return Rect{};
}
const int boxH = (std::min)(kVelCurveBoxH, panel.height());
return Rect{panel.right - kVelCurveBoxW, panel.top, panel.right, panel.top + boxH};
}
Rect zonesControlsArea(const Rect& content) {
const Rect panel = zonesControlPanel(content);
const Rect curve = zonesCurveBox(content);
if (curve.width() <= 0) return panel;
return Rect{panel.left, panel.top, curve.left - kPad, panel.bottom};
}
// The pure-module mapping Box for a drawn curve rect: inset from the border so node handles and
// the pick radius stay inside the box. Every consumer (paint, hit-test, add, drag) derives the
// Box through this ONE formula, so drawn nodes and grabs can never drift apart.
VelocityCurve::Box curveBoxFromRect(const Rect& r) {
return VelocityCurve::Box{r.left + kVelCurveInset, r.top + kVelCurveInset,
(std::max)(0, r.width() - 2 * kVelCurveInset),
(std::max)(0, r.height() - 2 * kVelCurveInset)};
}
// The S7 mono/stereo toggle (S-VIEW-2: moved here from Browse to the Sample cluster band — it is
// a per-capture output-mode concern, not a choosing concern). A two-segment control right-anchored
// in `area` and vertically centered. Returns {mono-segment, stereo-segment}, each kChanSegW wide,
@@ -983,6 +1034,10 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
}
// S-VIEW-10: the velocity->amp transfer-curve editor beside the hero (amp-over-time beside
// amp-over-velocity). Independent of the decoded PCM — drawn even while (decoding...).
paintVelocityCurve(bmp, bands.velCurve, zone);
// --- Root + preview cluster (fenced root strip, preview button, velocity knob, channel) ---
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
int root = effectiveRoot();
@@ -1070,6 +1125,92 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA
}
}
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
const PerformanceZone& zone) {
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);
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
const VelocityCurve& curve = zone.velocityCurve;
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
// secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity
// and amp -> y mappings both go through the pure module so the trace, the node handles, and
// the hit-test all share one coordinate system.
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
int prevX = 0, prevY = 0;
for (int px = 0; px <= box.width; ++px) {
const int cx = box.left + px;
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
prevX = cx;
prevY = cy;
}
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
// to accent-hot when grabbed or hovered.
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
for (std::size_t i = 0; i < curve.points().size(); ++i) {
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
const bool grabbed = (drag_ == DragKind::kCurveNode &&
curvePointIndex_ == static_cast<int>(i));
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
const int nr = 3;
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, hot ? handleHot : handle,
1.0f, 0);
}
}
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) {
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
int idx = z.velocityCurve.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 (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
selectedZone_ = zoneIndex;
commitAndReload();
}
return;
}
// Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
dragStartMap_ = map_;
// Empty-space click: add a control point at the cursor via the pure inverse map, then grab
// it — the click flows straight into a placing drag.
if (idx < 0) {
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
idx = static_cast<int>(z.velocityCurve.addPoint(p.velocity, p.amp));
}
drag_ = DragKind::kCurveNode;
curvePointIndex_ = idx;
dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base
dragCurveRect_ = r;
dragCurveZone_ = zoneIndex;
dragStartX_ = x;
dragStartY_ = y;
selectedZone_ = zoneIndex;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
}
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
@@ -1362,8 +1503,11 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
// single-capture face when the map is empty but a capture is picked (S15-F2 lean: the
// single capture is already a one-zone map — one storage site serves both).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
paintControls(bmp, zonesControlPanel(content),
map_.zones[static_cast<std::size_t>(selectedZone_)]);
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
paintControls(bmp, zonesControlsArea(content), z);
// S-VIEW-10: the same velocity-curve editor, right-anchored in the per-zone param panel
// (one curve per zone). Suppressed (empty rect) when the panel is too narrow.
paintVelocityCurve(bmp, zonesCurveBox(content), z);
}
}
@@ -1499,12 +1643,21 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
h = {HoverKind::kDeleteZone, -1};
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect panel = zonesControlPanel(content);
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id >= 0) h = {HoverKind::kControl, id};
const Rect cb = zonesCurveBox(content);
if (cb.width() > 0 && contains(cb, x, y)) {
// S-VIEW-10: a curve node under the pointer lights accent-hot.
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
const int idx = z.velocityCurve.pointAtPixel(curveBoxFromRect(cb), x, y);
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
} else {
const ZonePlaySeconds& play =
map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect panel = zonesControlsArea(content);
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id >= 0) h = {HoverKind::kControl, id};
}
}
} else { // Sample view (home)
const SampleBands bands = computeSampleBands(w, hgt);
@@ -1519,7 +1672,13 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
if (contains(clusterPreviewButton(bands.cluster), x, y)) h = {HoverKind::kPreview, -1};
else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
else {
else if (bands.velCurve.width() > 0 && contains(bands.velCurve, x, y)) {
// S-VIEW-10: a curve node under the pointer lights accent-hot.
const PerformanceZone zone = effectiveSampleZone();
const int idx = zone.velocityCurve.pointAtPixel(
curveBoxFromRect(bands.velCurve), x, y);
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
} else {
const PerformanceZone zone = effectiveSampleZone();
const std::vector<ControlDesc> descs = controlDescs(zone.play);
const std::vector<ControlRow> rows = layoutControls(bands.control, descs);
@@ -1669,6 +1828,15 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
return;
}
// S-VIEW-10: the velocity-curve editor beside the hero. Every in-box click is an edit
// (grab / Alt-delete / add-at-cursor), so materialize the one-zone site first (the
// mirror of the control strip's ensureSampleZone path).
if (bands.velCurve.width() > 0 && contains(bands.velCurve, x, y)) {
const int zi = ensureSampleZone();
if (zi >= 0) handleCurveMouseDown(bands.velCurve, zi, x, y);
return;
}
// Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
@@ -1833,9 +2001,15 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
// The param panel: a toggle segment flips at once (commit); a slider grab starts a live drag.
// Only when a zone is selected (the Zone surface has no single-capture fallback — that lives
// on the Sample face now).
// on the Sample face now). The S-VIEW-10 curve box is checked first — it sits inside the
// panel's right column (zonesControlsArea is the panel minus that column).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
handleControlClick(selectedZone_, zonesControlPanel(content), x, y);
const Rect cb = zonesCurveBox(content);
if (cb.width() > 0 && contains(cb, x, y)) {
handleCurveMouseDown(cb, selectedZone_, x, y);
return;
}
handleControlClick(selectedZone_, zonesControlsArea(content), x, y);
}
}
@@ -1941,6 +2115,22 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
return;
}
if (drag_ == DragKind::kCurveNode) {
// S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure
// inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve +
// box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
// commit lands on WM_LBUTTONUP.
if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast<int>(map_.zones.size())) return;
if (curvePointIndex_ < 0) return;
const int dy = y - dragStartY_;
map_.zones[static_cast<std::size_t>(dragCurveZone_)].velocityCurve =
VelocityCurve::resolvePointDrag(dragStartCurve_,
static_cast<std::size_t>(curvePointIndex_),
curveBoxFromRect(dragCurveRect_), dx, dy);
invalidate();
return;
}
if (drag_ == DragKind::kWaveMarker) {
// S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap
// it against the decoded PCM, apply the inter-marker clamps, and write the override live.
@@ -2048,7 +2238,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
invalidate();
}
void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
void ReaSamplerEditor::onMouseUp(int x, int y) {
// Release a held preview note first (the preview button is a momentary key: note-off on up).
// This runs regardless of drag state — the preview press does not start a drag.
if (previewingNote_ >= 0) {
@@ -2059,9 +2249,14 @@ void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
if (drag_ == DragKind::kNone) return;
const DragKind kind = drag_;
const int paramId = dragParamId_;
const int curveIdx = curvePointIndex_;
const int curveZone = dragCurveZone_;
const Rect curveRect = dragCurveRect_;
drag_ = DragKind::kNone;
dragParamId_ = -1;
dragParamZone_ = -1;
curvePointIndex_ = -1;
dragCurveZone_ = -1;
// A scrollbar drag is transient UI (no map change), and the preview-velocity knob (id==-2) is a
// processor-side per-instance setting already applied live — neither reloads the instrument.
// Every other drag is a coherent map edit: publish the in-flight map + reload off-thread.
@@ -2069,6 +2264,20 @@ void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
invalidate();
return;
}
// S-VIEW-10 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).
if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 &&
curveZone < static_cast<int>(map_.zones.size())) {
const bool off = x < curveRect.left - kCurveDragOffMargin ||
x > curveRect.right + kCurveDragOffMargin ||
y < curveRect.top - kCurveDragOffMargin ||
y > curveRect.bottom + kCurveDragOffMargin;
if (off) {
map_.zones[static_cast<std::size_t>(curveZone)].velocityCurve.deletePoint(
static_cast<std::size_t>(curveIdx));
}
}
commitAndReload();
}
@@ -2237,6 +2446,8 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
self->drag_ = DragKind::kNone;
self->dragParamId_ = -1;
self->dragParamZone_ = -1;
self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset)
self->dragCurveZone_ = -1;
self->invalidate();
}
}