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
+7
View File
@@ -56,6 +56,13 @@ reasampler_pure_library(deck_groups
# needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands)
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike
# envelope_overlay/envelope_edit, which stay engine-free — the staged envelopes touch no curve).
reasampler_pure_library(spline_edit
SOURCES spline_edit.cpp
LINK PUBLIC editor_geometry velocity_curve)
reasampler_test(spline_edit LINK spline_edit)
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
# proving that takes a curve of each domain mapped through the one curveBox.
+63 -14
View File
@@ -9,6 +9,11 @@ namespace reasampler::instrument::ui {
namespace {
int id(DeckParam p) { return static_cast<int>(p); }
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
// no wider than its knob row — the ceiling is PITCH ENV's, whose caption row lands exactly on
// its four-cell knob row at 23. Raising it reflows the deck's first row.
constexpr int kEnvModeSegW = 23;
} // namespace
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
@@ -31,6 +36,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
penv.captionWidth = 58;
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
// The mode toggle rides the caption slack rather than the knob row: every env group's
// knob row is wider than its caption row, so this costs no group width — and the deck
// has six pixels of headroom on its first row at the editor's floor width.
penv.captionToggle2 = {id(DeckParam::kPitchEnvMode), kEnvModeSegW};
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
id(DeckParam::kPitchEnvHold),
id(DeckParam::kPitchEnvDecay),
@@ -58,6 +67,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
fenv.id = kGroupFilterEnv;
fenv.captionWidth = 66;
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
fenv.captionToggle2 = {id(DeckParam::kFilterEnvMode), kEnvModeSegW};
if (trigger) {
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
id(DeckParam::kFilterTrigDecay), -1, -1};
@@ -76,6 +86,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
amp.captionWidth = 78;
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW};
if (trigger) {
// The play span first, then the AHD that shapes it, time-ordered left-to-right so
// the row reads like the drawn envelope. One blank keeps the group's width — and
@@ -204,6 +215,11 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect:
// A mode toggle names a different envelope, not a different setting of one — the same
// reason every other discrete toggle above is excluded.
case DeckParam::kAmpEnvMode:
case DeckParam::kPitchEnvMode:
case DeckParam::kFilterEnvMode:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
@@ -229,24 +245,65 @@ OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
return (current == picked) ? OverlayEnv::kNone : picked;
}
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled) {
OverlayEnv overlayEnvForModeToggle(int toggleId) {
switch (static_cast<DeckParam>(toggleId)) {
case DeckParam::kAmpEnvMode: return OverlayEnv::kAmp;
case DeckParam::kPitchEnvMode: return OverlayEnv::kPitch;
case DeckParam::kFilterEnvMode: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
}
}
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state) {
switch (env) {
case OverlayEnv::kPitch: return !pitchEnvEnabled;
case OverlayEnv::kFilter: return !filterEnabled;
case OverlayEnv::kPitch: return state.pitchEnvEnabled;
case OverlayEnv::kFilter: return state.filterEnabled;
case OverlayEnv::kAmp:
case OverlayEnv::kNone:
return true;
}
return true; // unreachable for a valid enumerator; silences a warning.
}
bool overlayEnvInert(OverlayEnv env, const DeckEnableState& state) {
if (env == OverlayEnv::kNone) return false;
if (!overlayEnvEnabled(env, state)) return true;
switch (env) {
case OverlayEnv::kPitch: return state.pitchSpline;
case OverlayEnv::kFilter: return state.filterSpline;
case OverlayEnv::kAmp: return state.ampSpline;
case OverlayEnv::kNone:
return false;
}
return false; // unreachable for a valid enumerator; silences a warning.
}
bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) {
bool deckKnobInert(DeckParam id, const DeckEnableState& state) {
switch (id) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kSustain:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigHold:
case DeckParam::kTrigDecay:
return state.ampSpline;
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvHold:
case DeckParam::kPitchEnvDecay:
return !state.pitchEnvEnabled || state.pitchSpline;
case DeckParam::kPitchEnvDepth:
return !pitchEnvEnabled;
return !state.pitchEnvEnabled;
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterTrigDecay:
return !state.filterEnabled || state.filterSpline;
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
@@ -257,15 +314,7 @@ bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) {
// The filter's velocity curve sits in the VELOCITY group but is a filter parameter:
// it goes inert with every other one, so no surface can reach a param the knobs can't.
case DeckParam::kFilterVelCurve:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterTrigDecay:
return !filterEnabled;
return !state.filterEnabled;
default:
return false;
}
+34 -8
View File
@@ -74,6 +74,11 @@ enum class DeckParam {
kAmpEnvSelect,
kPitchEnvSelect,
kFilterEnvSelect,
// Staged | Spline mode per envelope. Both states persist either way (play_params.h's
// SplineEnv); this only picks which one plays and which one the overlay edits.
kAmpEnvMode,
kPitchEnvMode,
kFilterEnvMode,
// Deck-only controls: processor-side per-instance params — routed to the processor
// setters, never through the parameter set.
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
@@ -163,17 +168,38 @@ OverlayEnv overlayEnvForRadio(int radioId);
// to, not an error. A non-radio id leaves the selection alone.
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
// Whether the overlay for `env` is INERT: its deck group's enable toggle is off, so its knobs
// are drawn-but-dead and a node drag on the same params must be too — otherwise a drag reaches
// a param a knob couldn't (envelope_edit.h). Amp has no enable toggle and is never inert.
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled);
// The group states the two inert predicates below read. One struct rather than a growing
// parameter list, so adding a gate is a change at the two predicates and nowhere else.
struct DeckEnableState {
bool pitchEnvEnabled = false;
bool filterEnabled = false;
bool ampSpline = false; // the amp EG is drawn rather than staged
bool pitchSpline = false;
bool filterSpline = false;
};
// Which envelope a Staged|Spline mode toggle belongs to; kNone for any other control id.
OverlayEnv overlayEnvForModeToggle(int toggleId);
// Whether `env`'s deck group is switched on at all. Amp has no enable toggle and is always on.
// The gate BOTH overlay modes share — a disabled group's contour is as dead as its knobs.
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state);
// Whether the STAGED overlay for `env` is INERT: its deck group is off, so its knobs are
// drawn-but-dead and a node drag on the same params must be too — otherwise a drag reaches a
// param a knob couldn't (envelope_edit.h). An envelope in SPLINE mode is inert here too: the
// staged nodes are not what the overlay is editing.
bool overlayEnvInert(OverlayEnv env, const DeckEnableState& state);
// Whether a deck knob cell is drawn-but-dead: the pitch envelope's four knobs while it is
// disabled, and the filter group's tone/modulation knobs (plus its VELOCITY cell, a filter
// parameter that just sits in that group) while the filter is disabled. Every other id is
// always live. Mirrors overlayEnvInert's group-toggle-gates-its-knobs shape for the deck's own
// disabled, the filter group's tone/modulation knobs (plus its VELOCITY cell, a filter
// parameter that just sits in that group) while the filter is disabled, and every STAGED
// SEGMENT knob of an envelope switched to Spline. The segment knobs' inner curve dials go with
// them — the dial is reached through its outer cell, so one predicate covers both. The DEPTH
// knobs (pitch peak, filter mod amount) stay live in either mode: they scale whichever shape is
// active rather than describing a stage. Mirrors overlayEnvInert's shape for the deck's own
// mouse-down/paint (the shell's deckKnobDisabled is a thin int-id wrapper over this).
bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled);
bool deckKnobInert(DeckParam id, const DeckEnableState& state);
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
+16 -12
View File
@@ -24,6 +24,7 @@ int knobRowWidth(const DeckGroupDesc& g) {
int captionRowWidth(const DeckGroupDesc& g) {
int w = g.captionWidth;
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
if (g.captionToggle2.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle2.segWidth;
if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize;
return w;
}
@@ -49,16 +50,20 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
captionRight = out.captionRadio.box.x - kDeckToggleGap;
out.caption.width = captionRight - out.caption.x;
}
if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const auto placeToggle = [&](const DeckToggleDesc& d, DeckToggleLayout& into) {
if (d.id < 0) return;
const int segW = d.segWidth;
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
togTop + kDeckToggleH);
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x;
}
into = DeckToggleLayout{d.id, seg0, seg1};
captionRight = seg0.x - kDeckToggleGap;
// Caption text stops at the leftmost toggle: pull the right edge in (XYWH: width).
out.caption.width = captionRight - out.caption.x;
};
placeToggle(g.captionToggle, out.captionToggle);
placeToggle(g.captionToggle2, out.captionToggle2);
// Knob row: fixed cells left-to-right, then the optional row toggle.
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
@@ -151,11 +156,10 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
}
if (g.captionToggle.id >= 0) {
if (contains(g.captionToggle.seg0, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
if (contains(g.captionToggle.seg1, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 1};
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
if (t->id < 0) continue;
if (contains(t->seg0, x, y)) return {DeckHitKind::CaptionToggle, t->id, 0};
if (contains(t->seg1, x, y)) return {DeckHitKind::CaptionToggle, t->id, 1};
}
if (g.rowToggle.id >= 0) {
if (contains(g.rowToggle.seg0, x, y))
+6
View File
@@ -64,6 +64,11 @@ struct DeckGroupDesc {
int captionWidth = 60;
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
// A second caption toggle, placed immediately left of the first (or in its place when the
// first is absent). Exists because a group whose knob row is wider than its caption row has
// caption slack a toggle can occupy for free — a rowToggle would widen the GROUP, and the
// deck has six pixels of headroom on its first row at the editor's floor width.
DeckToggleDesc captionToggle2;
std::vector<int> cellIds; // knob cells; -1 = blank reserve
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
};
@@ -95,6 +100,7 @@ struct DeckGroupLayout {
Rect caption; // caption text rect (left part of the caption row)
DeckRadioLayout captionRadio; // id -1 when absent (rect empty)
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
DeckToggleLayout captionToggle2;
std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent
};
+29
View File
@@ -0,0 +1,29 @@
// spline_edit.cpp — see spline_edit.h. Pure decision logic; no host types.
#include "core/instrument/ui/spline_edit.h"
namespace reasampler::instrument::ui {
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
SplineGesture gesture, int x, int y) {
if (box.width <= 0 || box.height <= 1) return {};
const int idx = curve.pointAtPixel(box, x, y);
switch (gesture) {
case SplineGesture::kRight:
return idx >= 0 ? SplineEdit{SplineEditKind::kDelete, idx} : SplineEdit{};
case SplineGesture::kControlLeft:
return idx >= 0 ? SplineEdit{SplineEditKind::kToggleHard, idx} : SplineEdit{};
case SplineGesture::kLeft:
break;
}
if (idx >= 0) return {SplineEditKind::kGrab, idx};
const bool inBox = (x >= box.left && x < box.left + box.width && y >= box.top &&
y < box.top + box.height);
return inBox ? SplineEdit{SplineEditKind::kAdd, -1} : SplineEdit{};
}
VelocityCurve::Box splineOverlayBox(const OverlayArea& area) {
return VelocityCurve::Box{area.rect.x, area.rect.y, area.rect.width, area.rect.height};
}
} // namespace reasampler::instrument::ui
+42
View File
@@ -0,0 +1,42 @@
// spline_edit.h — THE point-editing grammar, and the one place it is written down. Both spline
// consumers route their mouse-down through it — the velocity-curve popup and the spline EG
// overlay — so the two cannot drift into two grammars. Mirror of envelope_edit: decision logic
// only, no host types, no drawing.
#pragma once
#include "core/instrument/engine/velocity_curve.h"
#include "core/instrument/ui/editor_geometry.h" // Rect / OverlayArea
namespace reasampler::instrument::ui {
using engine::VelocityCurve;
// The gesture, in the pure module's own vocabulary (the shell maps its modifier state onto it).
enum class SplineGesture { kLeft, kRight, kControlLeft };
// What the gesture resolves to. Left-click adds a point in empty space and grabs an existing
// one; right-click deletes; control-click toggles hard/smooth. Points are smooth by default.
enum class SplineEditKind { kNone, kGrab, kAdd, kDelete, kToggleHard };
struct SplineEdit {
SplineEditKind kind = SplineEditKind::kNone;
int index = -1; // the point the action targets; -1 for kAdd (it has none yet) and kNone
};
// Resolves a click at (x, y) over `box` into an edit. The endpoint and point-count rules are
// NOT re-stated here — kDelete on an endpoint and kAdd at the ceiling are refused by
// VelocityCurve::deletePoint / addPoint, which the caller applies, so there is exactly one home
// for each. A click outside the mapping box resolves to kNone unless it lands on a node's pick
// radius: the drawn inset ring must not ADD (the new point would clamp onto an endpoint's x and
// stack an undeletable duplicate) but must still be able to grab.
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
SplineGesture gesture, int x, int y);
// The contour's mapping box inside the waveform overlay: the FULL area, so the drawn contour
// spans the whole sample width 1:1 with its time axis. No inset — unlike the popup's box, which
// insets to keep endpoint handles clear of the sheet border, this one must stay 1:1 with the
// waveform beneath it. Takes the overlay (not a lane) — see waveform_view.h's overlay contract.
VelocityCurve::Box splineOverlayBox(const OverlayArea& area);
} // namespace reasampler::instrument::ui