// 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 #include #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 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