FA2: bounded-schematic Gate envelope (15% sustain reserve, in-bounds release) + zero-fade-out grabbable via nearest-node hit-test; all nodes clamp in-canvas

This commit is contained in:
2026-07-27 18:25:03 -04:00
parent e2bd4f4351
commit d5d1902ea4
6 changed files with 392 additions and 121 deletions
+33 -8
View File
@@ -23,6 +23,15 @@ double secondsPerPixel(const Rect& area, double totalSeconds) {
return totalSeconds / static_cast<double>(w);
}
// Seconds per pixel in the GATE timed region (FA2): the Gate schematic maps A/H/D/R onto
// gateTimedWidth(area) px, not the full canvas, so a Gate time-node drag must use this scale for
// the handle to track the cursor. Matches envelope_overlay::gatePolyline.
double gateSecondsPerPixel(const Rect& area, double totalSeconds) {
const int w = gateTimedWidth(area);
if (w <= 0 || totalSeconds <= 0.0) return 0.0;
return totalSeconds / static_cast<double>(w);
}
// Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
double levelPerPixel(const Rect& area) {
@@ -46,14 +55,23 @@ bool isDraggable(EnvNode n) {
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// First-match in draw order (deterministic tie-break), skipping non-draggable anchors.
// NEAREST draggable node within the pick radius wins (Chebyshev distance — the square grab
// box); ties break to the earlier draw-order node (FA2). Nearest-wins keeps every handle
// grabbable when nodes sit close (e.g. a short hold), while the draw-order tie-break makes
// exactly-coincident nodes deterministic: at zero fade-out, FadeOutStart overlays LengthEnd
// and WINS the tie, so the fade-out handle is grabbable at the right edge and can be dragged
// inward from zero.
NodeHit best;
int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) {
if (!isDraggable(v.node)) continue;
if (std::abs(x - v.x) <= kNodeGrabRadius && std::abs(y - v.y) <= kNodeGrabRadius) {
return NodeHit{true, v.node};
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
if (dist < bestDist) { // strictly closer only: earlier draw order keeps ties
bestDist = dist;
best = NodeHit{true, v.node};
}
}
return NodeHit{false, EnvNode::Origin};
return best;
}
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
@@ -65,21 +83,28 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
const double secPerPx = secondsPerPixel(area, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx;
// Gate time nodes live in the TIMED region of the Gate schematic (FA2), which is narrower
// than the canvas by the sustain-plateau reserve — their px->seconds scale differs so the
// dragged handle tracks the cursor 1:1. gateTimedWidth >= 1 whenever the area is non-empty,
// so gateDSec is well-defined past the degenerate guard above.
const double gateDSec =
static_cast<double>(dxPixels) * gateSecondsPerPixel(area, totalSeconds);
switch (node) {
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds = clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
out.attackSeconds =
clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds = clamp(grabEnv.holdSeconds + dSec, 0.0, bounds.maxHoldSeconds);
out.holdSeconds = clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower
// level, so subtract the level delta).
out.decaySeconds = clamp(grabEnv.decaySeconds + dSec, 0.0, bounds.maxDecaySeconds);
out.decaySeconds = clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(area);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
out.sustainLevel = clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
@@ -87,7 +112,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
}
case EnvNode::ReleaseEnd:
out.releaseSeconds =
clamp(grabEnv.releaseSeconds + dSec, 0.0, bounds.maxReleaseSeconds);
clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
break;
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
+12 -5
View File
@@ -20,11 +20,16 @@
// CALLER-SUPPLIED here (EnvClampBounds): the shell passes the same maxima it feeds the slider,
// so the two surfaces share one clamp by construction.
//
// WHICH AXES. Time-only nodes (attack-end, hold-end, release-end; fade-in-end, length-end,
// fade-out-end) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the
// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart,
// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the
// drawing-only ReleaseStart vertex are NOT draggable.
//
// GATE DRAG SCALE (FA2). Gate time nodes live in the Gate schematic's TIMED region
// (gateTimedWidth(area) px — the canvas minus the sustain-plateau reserve), so their px->seconds
// conversion uses that width, not the full canvas; Trigger nodes keep the full-canvas scale.
// Both match the forward map in envelope_overlay, so a dragged handle tracks the cursor 1:1.
//
// Reuses editor_geometry's Rect + the EnvNode / AmpEnvelope / EnvMode types from
// envelope_overlay (one shared node vocabulary across draw + edit), and the shared timeToX /
// levelToY maps so the handle the overlay drew and the grab region here agree pixel-for-pixel.
@@ -64,9 +69,11 @@ struct EnvClampBounds {
// Which node a grab at (x, y) lands on, given the CURRENT envelope + overlay rect + sample
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles).
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable). On a
// tie (two handles within the radius) the earlier draw-order node wins (deterministic, mirroring
// waveform_view::markerAtPoint's first-match). Pure.
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable).
// The NEAREST node within the radius wins (Chebyshev distance); an exact tie goes to the earlier
// draw-order node (FA2 — deterministic, and it makes coincident nodes grabbable: at zero
// fade-out, FadeOutStart overlays LengthEnd, wins the tie, and can be dragged inward from the
// right edge; at zero hold, AttackEnd wins over HoldEnd). Pure.
struct NodeHit {
bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
+46 -17
View File
@@ -10,13 +10,22 @@ int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width());
if (w <= 0 || totalSeconds <= 0.0) return area.left;
if (t < 0.0) t = 0.0;
// Linear map, NOT clamped on the high side: t past totalSeconds maps past area.right (the Gate
// release tail, drawn after the sample end by design). Round to the nearest pixel.
// Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the
// last in-bounds column area.right-1. Round to the nearest pixel.
const double frac = t / totalSeconds;
const long xi = static_cast<long>(frac * static_cast<double>(w) + 0.5);
long xi = static_cast<long>(frac * static_cast<double>(w) + 0.5);
if (xi > w - 1) xi = w - 1;
return area.left + static_cast<int>(xi);
}
int gateTimedWidth(const Rect& area) {
const int w = std::max(0, area.width());
if (w <= 0) return 0;
const int sustainPx =
static_cast<int>(kGateSustainDisplayFraction * static_cast<double>(w) + 0.5);
return std::max(1, w - sustainPx);
}
int levelToY(const Rect& area, double level) {
const int h = std::max(0, area.height());
if (h <= 0) return area.top;
@@ -46,6 +55,22 @@ EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, dou
return v;
}
// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space —
// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x to
// the last in-bounds column (FA2 bounds invariant).
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
const int w = std::max(1, area.width());
long xi = static_cast<long>(px + 0.5);
if (xi < 0) xi = 0;
if (xi > w - 1) xi = w - 1;
EnvVertex v;
v.node = node;
v.x = area.left + static_cast<int>(xi);
v.y = levelToY(area, level);
v.level = level;
return v;
}
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area, double totalSeconds) {
// Non-negative segment durations (a stored negative would be an upstream bug; clamp defensively).
const double a = std::max(0.0, env.attackSeconds);
@@ -54,23 +79,27 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area, do
const double r = std::max(0.0, env.releaseSeconds);
const double sus = clamp01(env.sustainLevel);
// Cumulative wall-clock times of each breakpoint from t=0.
const double tAttack = a;
const double tHold = tAttack + h;
const double tDecay = tHold + d;
// The sustain plateau runs to the sample end; if the pre-sustain stages already overrun the
// sample, the plateau collapses to zero width (its end clamps up to tDecay).
const double tSustainEnd = std::max(tDecay, totalSeconds);
const double tRelease = tSustainEnd + r; // release trails PAST the sample end, by design
// BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved
// sustain-plateau width) at the sample's time scale; the sustain plateau is the fixed reserve
// between DecayEnd and ReleaseStart. Cumulative px, clamped in gateVtx, stay monotonic.
const int timedW = gateTimedWidth(area);
const int sustainPx = std::max(0, area.width()) - timedW;
const double pxPerSec = static_cast<double>(timedW) / totalSeconds;
const double pxAttack = a * pxPerSec;
const double pxHold = (a + h) * pxPerSec;
const double pxDecay = (a + h + d) * pxPerSec;
const double pxPlateau = pxDecay + static_cast<double>(sustainPx); // schematic note-off
const double pxRelease = pxPlateau + r * pxPerSec;
std::vector<EnvVertex> pts;
pts.reserve(6);
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
pts.push_back(vtx(EnvNode::AttackEnd, area, totalSeconds, tAttack, 1.0));
pts.push_back(vtx(EnvNode::HoldEnd, area, totalSeconds, tHold, 1.0));
pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, tDecay, sus)); // sustain node
pts.push_back(vtx(EnvNode::ReleaseStart, area, totalSeconds, tSustainEnd, sus)); // plateau end
pts.push_back(vtx(EnvNode::ReleaseEnd, area, totalSeconds, tRelease, 0.0));
pts.push_back(gateVtx(EnvNode::Origin, area, 0.0, 0.0));
pts.push_back(gateVtx(EnvNode::AttackEnd, area, pxAttack, 1.0));
pts.push_back(gateVtx(EnvNode::HoldEnd, area, pxHold, 1.0));
pts.push_back(gateVtx(EnvNode::DecayEnd, area, pxDecay, sus)); // sustain node
pts.push_back(gateVtx(EnvNode::ReleaseStart, area, pxPlateau, sus)); // plateau end
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, pxRelease, 0.0));
return pts;
}
+66 -33
View File
@@ -4,16 +4,37 @@
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue
// and draws the node handles (via envelope_edit's hit-test).
//
// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform at accurate wall-clock
// time (Simpler / Phase-Plant grammar):
// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform (Simpler / Phase-Plant
// grammar):
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
// sustain plateau, release sustain->0.
// sustain plateau, release sustain->0. Since there is no held note-off to draw
// against, Gate is a BOUNDED SCHEMATIC (FA2): a fixed fraction of the canvas
// width (kGateSustainDisplayFraction) is RESERVED for the sustain plateau, and
// the remaining "timed" width carries A/H/D AND the release at the sample's time
// scale — so A -> (H) -> D -> S-plateau -> R all render INSIDE the canvas and the
// release is a visible, draggable segment (it no longer trails past area.right).
// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored
// to playEnd (= lengthFraction of the post-start span).
// The horizontal axis is wall-clock TIME across the waveform rect; the vertical axis is LEVEL
// (0 at rect bottom, 1 at rect top). The overlay shares the waveform's time base so the drawn
// shape lines up with the PCM under it: the same [0, frameCount] span waveform_view maps, so the
// envelope's own duration is placed at the SAME frames the voice plays it over.
// to playEnd (= lengthFraction of the post-start span). Trigger keeps the
// waveform's exact time base so the shape lines up with the PCM under it.
// The horizontal axis is TIME (Gate: schematic, see above; Trigger: wall-clock across the rect);
// the vertical axis is LEVEL (0 at rect bottom, 1 at rect top).
//
// BOUNDS INVARIANT (FA2). EVERY vertex of EVERY polyline is clamped inside the canvas:
// x in [area.left, area.right-1], y in [area.top, area.bottom-1] (half-open rect convention).
// No node and no drawn segment ever exceeds the canvas — paint-time clipping of handles is no
// longer needed (and never fires) in the shell.
//
// FA2 CONTRACT CHANGE — WAVE B SHELL AUTHOR, READ THIS:
// * The EnvNode enum is UNCHANGED (same node set, same draggable set — Origin + ReleaseStart
// remain the only non-draggable anchors).
// * ALL vertices are now in-bounds (see above). The shell's previous "skip handle when
// v.x >= waveArea.right" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd
// (Trigger, at full length / zero fade-out) now land at area.right-1 and MUST get handles.
// * Gate's x-axis is schematic, not PCM-aligned (the sustain reserve compresses the timed
// region); Trigger's x-axis is still PCM-aligned.
// * nodeAtPoint (envelope_edit) now resolves to the NEAREST node within the grab radius,
// draw-order tie-break — coincident nodes (zero fade-out at the right edge, zero hold) are
// deterministically grabbable.
//
// DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on
// sample_map / sampler_core (which would drag bank_book / wav_trim in). The shell reads the
@@ -44,7 +65,8 @@ enum class EnvMode { Gate, Trigger };
//
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(=sustain corner) -> ReleaseStart
// -> ReleaseEnd. The sustain node is DecayEnd (its Y is the sustain level);
// ReleaseStart is a drawing-only plateau-end vertex.
// ReleaseStart is a drawing-only plateau-end vertex (the schematic note-off);
// release is edited by dragging ReleaseEnd.
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd, level 0). The fade-out
// ramp is the FadeOutStart->LengthEnd segment; LengthEnd is the playEnd terminal.
enum class EnvNode {
@@ -55,7 +77,8 @@ enum class EnvNode {
// Y sets sustainLevel)
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) —
// a DRAWING vertex only, not a draggable handle (release is edited at
// ReleaseEnd; this vertex tracks its X = sample end, Y = sustain level)
// ReleaseEnd; this vertex sits a fixed sustain-plateau width right of
// DecayEnd — the schematic note-off — Y = sustain level)
ReleaseEnd, // Gate: end of the release tail (level 0) — X sets releaseSeconds
FadeInEnd, // Trigger: top of the fade-in (level 1) — X sets fadeInFraction
FadeOutStart, // Trigger: end of the unity plateau / start of the fade-out (level 1) —
@@ -121,36 +144,46 @@ struct EnvVertex {
}
};
// The fraction of the canvas width RESERVED for the Gate sustain-plateau display (FA2). The
// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining
// width is the "timed" region A/H/D/R map onto at the sample's time scale. One constant shared
// by the forward map (here) and the inverse map (envelope_edit) so a drag tracks the cursor.
inline constexpr double kGateSustainDisplayFraction = 0.15;
// The pixel width of the Gate timed region: area.width() minus the sustain-plateau reserve,
// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0
// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale.
int gateTimedWidth(const Rect& area);
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds`
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive);
// x maps time 0..totalSeconds across [area.left, area.right], y maps level 0..1 across
// [area.bottom-1 .. area.top] (level 1 at the TOP). The polyline reads left-to-right in draw
// order, Origin first.
// y maps level 0..1 across [area.bottom-1 .. area.top] (level 1 at the TOP). The polyline reads
// left-to-right in draw order, Origin first.
//
// TIME BASE. The envelope's own segment durations are placed on the SAME time axis the waveform
// occupies, so the curve lines up with the PCM:
// * Gate: attack/hold/decay run from t=0; the sustain plateau runs to the note-off. Since the
// overlay has no held note-off to draw against, the sustain plateau is drawn to the END of
// the sample (totalSeconds) and the release tail is drawn AFTER that boundary — i.e. the
// release is appended past the sample end (the standard "release after key-up at end of
// view" convention). When attack+hold+decay already exceed totalSeconds the plateau collapses
// to zero width (nodes clamp to the sample end) and release still trails past it.
// * Trigger: the played span is lengthFraction * totalSeconds; fade-in/out are fractions OF
// that played span. Nodes past the played span never appear (LengthEnd/FadeOutEnd sit at the
// played span's right edge).
// TIME BASE (FA2).
// * Gate: a bounded schematic. The canvas splits into a TIMED region of gateTimedWidth(area)
// px — where attack/hold/decay run from t=0 and the release ramp runs after the plateau, all
// at totalSeconds-over-timed-width scale — plus a FIXED sustain plateau of
// (width - timedWidth) px between DecayEnd and ReleaseStart (the schematic note-off). Every
// vertex x clamps to area.right-1, so when the stages overrun the visible span the trailing
// nodes pile up (still monotonic, still in-bounds, still draggable back left).
// * Trigger: the waveform's exact time base (PCM-aligned). The played span is
// lengthFraction * totalSeconds; fade-in/out are fractions OF that played span. Nodes past
// the played span never appear (FadeOutStart/LengthEnd sit at the played span's right edge).
//
// A time beyond totalSeconds (the Gate release tail) maps past area.right — the shell clips at
// paint time (the same way waveform_view lets a frame past the count pin the marker). A degenerate
// area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline [Origin, end at
// level 0] so the shell always has a drawable line. Pure — same inputs, same polyline.
// BOUNDS: every vertex is inside the canvas — x in [area.left, area.right-1], y in
// [area.top, area.bottom-1]. Nothing maps past area.right (the pre-FA2 release tail is gone). A
// degenerate area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline
// [Origin, end at level 0] so the shell always has a drawable line. Pure — same inputs, same
// polyline.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds);
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.left, t=totalSeconds -> area.right,
// linear. t is NOT clamped on the high side (a Gate release past the sample end maps past
// area.right, by design — see buildEnvelopePolyline); t < 0 pins to area.left. A zero-width area
// or totalSeconds <= 0 yields area.left. Pure — the shared time->x map both the polyline and the
// node hit-test (envelope_edit) use, so the drawn handle and its grab region agree.
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.left, t=totalSeconds ->
// area.right-1, linear, CLAMPED on both sides (t < 0 pins to area.left; t past totalSeconds pins
// to area.right-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields
// area.left. Pure — the shared time->x map the Trigger polyline and the node hit-test
// (envelope_edit) use, so the drawn handle and its grab region agree.
int timeToX(const Rect& area, double totalSeconds, double t);
// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.top, level 0 -> area.bottom-1