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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user