instrument: one staged-envelope system — per-segment curves, the sustain-less AHD, and a shared overlay for all three envelopes

Trigger's fade pair folds into the AHD (and goes live); the release anchors right;
Preserve rings its synthetic tail out instead of cutting it. Payload v10.
This commit is contained in:
2026-07-31 08:37:57 -04:00
parent 87d7ceb066
commit 13e8c5c4d9
51 changed files with 3406 additions and 1812 deletions
+68 -51
View File
@@ -1,8 +1,7 @@
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
// envelope_overlay.h — staged-envelope -> polyline geometry for the Sample-view overlay.
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
// param_slider. The shell packs the one parameter set's AdsrSeconds/TriggerParams into
// AmpEnvelope and draws the polyline plus a handle at each node (envelope_edit does the
// hit-test).
// param_slider. The shell packs whichever envelope is overlay-active into StageEnvelope and
// draws the polyline plus a handle at each node (envelope_edit does the hit-test).
#pragma once
@@ -10,92 +9,98 @@
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
#include "core/util/curve_law.h" // the ONE per-segment curve law
namespace reasampler::instrument::ui {
// Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
enum class EnvMode { Gate, Trigger };
// Which LAYOUT POLICY an envelope takes, decided by whether it has a sustain stage rather
// than by which processor it modulates. A gated (AHDSR) envelope right-anchors its release so
// the sustain plateau reads full-width; a sustain-less (AHD) one maps 1:1 onto the waveform's
// own time axis, which only means anything for a trigger shape. The two policies coexist.
enum class EnvKind { Ahdsr, Ahd };
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
// Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
// AHD nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd.
// The three *Curve nodes are the round mid-segment knots whose vertical drag sets that
// segment's curve exponent. Shared by envelope_overlay (forward/draw) and envelope_edit
// (inverse/edit).
enum class EnvNode {
Origin, // t=0, level 0 — not draggable
AttackEnd, // Gate: attack ramp top — sets attackSeconds
HoldEnd, // Gate: hold plateau end — sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
ReleaseEnd, // Gate: release tail end — sets releaseSeconds
FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
AttackEnd, // attack ramp top — sets attackSeconds
HoldEnd, // hold plateau end — AHDSR: holdSeconds; AHD: holdFraction
DecayEnd, // AHDSR: decay settles to sustain (X = decay, Y = sustain); AHD: decay end
ReleaseStart, // AHDSR: sustain plateau end — sets releaseSeconds (drags on X, inverted)
ReleaseEnd, // AHDSR: the envelope's end point — ANCHORED to the right edge, not draggable
AttackCurve, // mid-attack knot — sets attackCurve
DecayCurve, // mid-decay knot — sets decayCurve
ReleaseCurve, // mid-release knot — sets releaseCurve (AHDSR only)
};
// Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
// TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
// core/instrument/CLAUDE.md.
struct AmpEnvelope {
EnvMode mode = EnvMode::Gate;
// The envelope the overlay draws. One struct for both policies: `kind` selects which fields
// are read, so a single pack/unpack pair serves the amp, pitch, and filter envelopes.
struct StageEnvelope {
EnvKind kind = EnvKind::Ahdsr;
// Gate (AHDSR): seconds, plus a dimensionless sustain level.
// AHDSR: seconds at the schematic param-domain scale, plus a dimensionless sustain level.
double attackSeconds = 0.003;
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060;
// Trigger: fractions of the played span.
double lengthFraction = 1.0;
double fadeInFraction = 0.0;
double fadeOutFraction = 0.0;
// AHD: attack/decay seconds plus the Hold FRACTION of the span left after them, laid over
// [originSeconds, originSeconds + spanSeconds) of the waveform's own time axis.
double holdFraction = 1.0;
double originSeconds = 0.0;
double spanSeconds = 0.0;
// Per-segment curve exponents (release is AHDSR-only). curve_law.h owns the domain.
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
double releaseCurve = util::kCurveNeutral;
};
// One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
// inspection.
// One polyline vertex: pixel point plus which node it is. `level` is redundant with y, carried
// for inspection. `knot` marks the round mid-segment curve handles, which draw differently and
// are not part of the traced line.
struct EnvVertex {
EnvNode node = EnvNode::Origin;
int x = 0;
int y = 0;
double level = 0.0;
bool knot = false;
bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level;
return node == o.node && x == o.x && y == o.y && level == o.level && knot == o.knot;
}
};
// Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
// carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
inline constexpr double kGateSustainDisplayFraction = 0.15;
// Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
// Minimum pixel separation between consecutive AHDSR nodes, so zero-duration stages (tier-0
// defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
// radius (6) so a click can never tie between neighbours.
inline constexpr int kGateNodeSepPx = 8;
// Gate schematic's per-stage time domain (seconds) — the timed region represents four stages
// end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
// lands exactly at the canvas edge.
// The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each
// span at most this. Must match the shell's stage-knob ceiling so a maxed knob lands exactly at
// the canvas edge (at which point the sustain plateau has shrunk to nothing).
inline constexpr double kGateStageMaxSeconds = 2.0;
// Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
// a non-empty area; 0 for a zero/negative-width area.
int gateTimedWidth(const Rect& area);
// Pixels per second of the Gate timed region, independent of the sample's actual duration.
// Pixels per second of the AHDSR schematic, independent of the sample's actual duration.
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
// the cursor 1:1.
double gatePxPerSecond(const Rect& area);
// Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices
// are in draw order, Origin first.
// Maps a staged envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); the
// traced vertices come first in draw order (Origin first), then the curve knots.
//
// Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
// waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
// the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
// The AHDSR x-axis is a bounded schematic independent of totalSeconds (it does NOT line up with
// the waveform under it) with its ReleaseEnd anchored to the right edge; the AHD x-axis is
// wall-clock, 1:1 with the waveform. Every vertex is clamped inside the canvas: x in
// [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area or
// totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
// waveform overlay (not a lane) — see waveform_view.h's overlay contract.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
std::vector<EnvVertex> buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area,
double totalSeconds);
// Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
@@ -106,4 +111,16 @@ int timeToX(const Rect& area, double totalSeconds, double t);
// clamped. Shared with envelope_edit's node hit-test.
int levelToY(const Rect& area, double level);
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
// the sum past the span; there is no clamp on the sum because none is possible.
struct AhdSplit {
double attack = 0.0;
double hold = 0.0;
double decay = 0.0;
double total = 0.0;
};
AhdSplit splitAhdSeconds(const StageEnvelope& env);
} // namespace reasampler::instrument::ui