Files
reasampler/src/vst/envelope_overlay.h
T

230 lines
15 KiB
C++

// envelope_overlay.h — PURE amp-envelope → polyline geometry for the S-VIEW-3 Sample-view
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside
// 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 (Simpler / Phase-Plant
// grammar):
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
// 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 PARAM-DOMAIN
// scale — the timed width represents 4 x kGateStageMaxSeconds (the four stage
// sliders end-to-end at their maxima), NOT the sample's duration, so the layout
// is identical for a 0.3s and a 10s capture. Each segment additionally gets a
// kGateNodeSepPx pixel base, so consecutive nodes NEVER coincide: every Gate
// node is individually grabbable at ANY param values, including the tier-0
// defaults (hold 0 / decay 0). A -> (H) -> D -> S-plateau -> R all render INSIDE
// the canvas and the release is a visible, draggable segment.
// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored
// 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 timed region is scaled to the param
// domain (4 x kGateStageMaxSeconds), the sustain reserve is a fixed width, and every
// segment carries a kGateNodeSepPx pixel base. The Gate curve does NOT line up with the
// waveform under it — do not label it as if it did. Trigger's x-axis IS still PCM-aligned.
// * Gate nodes never coincide (min-separation, above), so every Gate handle is individually
// grabbable in every state. nodeAtPoint (envelope_edit) resolves to the NEAREST node within
// the grab radius with a draw-order tie-break; the tie-break only matters for the one
// remaining coincidence, Trigger's zero-fade-out (FadeOutStart overlays LengthEnd at the
// right edge and wins the tie, so the fade can be dragged open from zero).
//
// 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
// zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here.
// AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no-
// hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input
// is the total sample duration in seconds, which the shell resolves once from the live rate and
// the frame count and passes in — this module never sees a sample rate.
//
// It reuses editor_geometry's Rect + contains(), the one shared geometry idiom.
#pragma once
#include <cstdint>
#include <vector>
#include "editor_geometry.h" // Rect — the shared geometry idiom
namespace reasampler::vst {
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
enum class EnvMode { Gate, Trigger };
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the
// forward map (draw) and inverse map (edit) name the same nodes.
//
// 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 (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 {
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor)
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds,
// 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 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) —
// X sets fadeOutFraction
LengthEnd, // Trigger: the playEnd terminal / %-length (level 0) — X sets lengthFraction
};
// The amp-envelope params the overlay draws — the small view struct the shell packs from the
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include).
//
// Gate fields (SECONDS, wall-clock): attack / hold / decay / release; sustain is a LEVEL 0..1.
// These map 1-to-1 with the stored AdsrSeconds fields — no conversion required.
//
// Trigger fields (FRACTIONS of play): fadeIn / fadeOut as a fraction of the played span;
// lengthFraction is the played span as a fraction of the
// post-start sample length (matching TriggerParams).
//
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
// TriggerParams (sampler_core.h) stores Trigger fades as SOURCE FRAMES:
// fadeInFrames (int64_t) — 0->1 ramp length in source frames
// fadeOutFrames (int64_t) — 1->0 ramp length in source frames
// AmpEnvelope stores them as FRACTIONS of the played span:
// fadeInFraction = fadeInFrames / playLengthFrames
// fadeOutFraction = fadeOutFrames / playLengthFrames
// where playLengthFrames = round(lengthFraction * (frameCount - startFrame)).
// This is a NON-TRIVIAL derived view — NOT a direct field copy. The shell owes a
// converter on BOTH directions:
// PACK (draw): frames -> fraction (TriggerParams -> AmpEnvelope, needs frameCount + rate)
// UNPACK (commit): fraction -> frames (AmpEnvelope -> TriggerParams, same inputs)
// lengthFraction maps 1-to-1 with TriggerParams::lengthFraction and needs no conversion.
//
// Unused fields for the active mode are ignored.
struct AmpEnvelope {
EnvMode mode = EnvMode::Gate;
// Gate (AHDSR), seconds + 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 play span (fadeIn/fadeOut) and of the post-start length.
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy.
double lengthFraction = 1.0; // (0,1] of the post-start span that plays (1-to-1 with TriggerParams)
double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span (DERIVED — see above)
double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span (DERIVED — see above)
};
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin.
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
struct EnvVertex {
EnvNode node = EnvNode::Origin;
int x = 0; // pixel x inside the overlay rect
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0)
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection)
bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level;
}
};
// 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 schematic param-domain scale. One
// constant shared by the forward map (here) and the inverse map (envelope_edit).
inline constexpr double kGateSustainDisplayFraction = 0.15;
// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets
// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0
// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen
// larger than envelope_edit's kNodeGrabRadius (6) so a click dead-on a node can never tie with
// its neighbour. Shared by the forward map and the drag inverse.
inline constexpr int kGateNodeSepPx = 8;
// The Gate schematic's per-stage time domain (seconds): the timed region represents the four
// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider
// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its
// slider max lands exactly at the canvas edge. Drag safety does NOT depend on this constant
// (param clamps are caller-supplied in envelope_edit); only layout does.
inline constexpr double kGateStageMaxSeconds = 2.0;
// 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);
// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width,
// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over
// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a
// zero/negative-width area; otherwise > 0 (the usable width floors at 1 px). The ONE px<->sec
// scale shared by the forward map (gatePolyline) and the drag inverse (envelope_edit), so a
// dragged handle tracks the cursor 1:1.
double gatePxPerSecond(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);
// 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 (FA2).
// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. 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, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment
// carrying a kGateNodeSepPx base so consecutive nodes never coincide — plus a FIXED sustain
// plateau of (width - timedWidth) px between DecayEnd and ReleaseStart (the schematic
// note-off). Stages beyond the schematic domain (a stored stage > kGateStageMaxSeconds)
// compress from the RIGHT preserving the minimum gaps, so trailing nodes stay individually
// separated instead of piling on the last column; only a canvas too narrow to hold the
// minimum gaps at all sacrifices separation (in-bounds wins).
// * 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).
//
// 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-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
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is
// clamped to [0,1]. A zero-height area yields area.top. Pure — the shared level->y map the polyline
// and the node hit-test share.
int levelToY(const Rect& area, double level);
} // namespace reasampler::vst