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
+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