S-VIEW-3: add envelope_overlay + envelope_edit pure modules

AHDSR/Trigger params -> polyline forward map (overlay) and node hit-test +
clamped/monotonic pixel->param inverse map (edit) for the draggable Sample-view
envelope. Engine-free, unit-tested, wired into ctest. No shell changes.
This commit is contained in:
2026-07-27 13:24:48 -04:00
parent 3b9b78b82c
commit 12a7d072ac
7 changed files with 988 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types.
#include "envelope_edit.h"
#include <algorithm>
#include <cstdlib> // std::abs
namespace reasampler::vst {
namespace {
double clamp(double v, double lo, double hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the
// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX.
double secondsPerPixel(const Rect& area, double totalSeconds) {
const int w = std::max(0, area.width());
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) {
const int h = std::max(0, area.height());
if (h <= 1) return 0.0;
return 1.0 / static_cast<double>(h - 1);
}
// True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors).
bool isDraggable(EnvNode n) {
switch (n) {
case EnvNode::Origin:
case EnvNode::ReleaseStart:
return false;
default:
return true;
}
}
} // namespace
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.
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};
}
}
return NodeHit{false, EnvNode::Origin};
}
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
AmpEnvelope out = grabEnv;
if (!isDraggable(node)) return out;
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;
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);
break;
case EnvNode::HoldEnd:
out.holdSeconds = clamp(grabEnv.holdSeconds + dSec, 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);
const double lvlPerPx = levelPerPixel(area);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
out.sustainLevel = clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
break;
}
case EnvNode::ReleaseEnd:
out.releaseSeconds =
clamp(grabEnv.releaseSeconds + dSec, 0.0, bounds.maxReleaseSeconds);
break;
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
// span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the
// two fade nodes never cross (each clamps against the other), and length in [0, max].
case EnvNode::FadeInEnd: {
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeInFraction,
1.0 - std::max(0.0, grabEnv.fadeOutFraction));
out.fadeInFraction = clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::FadeOutStart: {
// FadeOutStart sits at (1 - fadeOut) of the played span; dragging it LEFT (negative dx)
// lengthens the fade-out. So the fade-out fraction moves OPPOSITE the pixel delta.
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeOutFraction,
1.0 - std::max(0.0, grabEnv.fadeInFraction));
out.fadeOutFraction = clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::LengthEnd: {
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
out.lengthFraction = clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
break;
}
case EnvNode::Origin:
case EnvNode::ReleaseStart:
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
}
return out;
}
} // namespace reasampler::vst
+94
View File
@@ -0,0 +1,94 @@
// envelope_edit.h — PURE node hit-test + pixel-delta→clamped-param inverse map for the S-VIEW-3
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here,
// unit-tested at the boundaries outside the DAW, while the editor shell (reasampler_editor.cpp)
// draws the handles, captures the grab on WM_LBUTTONDOWN, feeds each move's pixel delta back
// through here, and commits the resulting params to the zone through the same off-audio-thread
// path a slider edit uses.
//
// TWO SURFACES, ONE MODEL. envelope_overlay owns the params→polyline FORWARD map (draw); this
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node
// drag and a slider edit are two views on one source of truth and can never diverge.
//
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't:
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0.
// * RANGE-CLAMPED — times clamp to the SAME per-param [min,max] the slider enforces; levels
// clamp to [0,1]. Because the concrete second/fraction maxima live SHELL-SIDE (param_slider
// is deliberately engine-free — the shell owns the 0..1↔domain mapping), the clamp bounds are
// 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
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the
// drawing-only ReleaseStart vertex are NOT draggable.
//
// 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.
#pragma once
#include <cstdint>
#include <vector>
#include "editor_geometry.h" // Rect
#include "envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
namespace reasampler::vst {
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
inline constexpr int kNodeGrabRadius = 6;
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
struct EnvClampBounds {
double maxAttackSeconds = 4.0; // upper bound of the attack slider
double maxHoldSeconds = 4.0;
double maxDecaySeconds = 4.0;
double maxReleaseSeconds = 4.0;
// Trigger fades + length are fractions; their natural upper bound is 1.0. Exposed so a shell
// that caps a fade below the full span (e.g. 0.5) shares that cap with its slider.
double maxFadeInFraction = 1.0;
double maxFadeOutFraction = 1.0;
double maxLengthFraction = 1.0;
// sustainLevel is always [0,1] — no shell knob needed, kept implicit.
};
// 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.
struct NodeHit {
bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
};
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y);
// Resolve a drag of `node` to a new AmpEnvelope. Given the envelope AS OF GRAB TIME (`grabEnv` —
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe:
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX),
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time,
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack).
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1].
// dyPixels is IGNORED for every time-only node.
// * Non-draggable node (Origin / ReleaseStart) or a zero-width/zero-height area or
// totalSeconds <= 0 -> `grabEnv` returned unchanged (no motion).
// Only the dragged node's param(s) change; every other field carries through from `grabEnv`. Pure
// — rounding is to the param's continuous value (no snapping, matching the sliders' resolution).
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
} // namespace reasampler::vst
+113
View File
@@ -0,0 +1,113 @@
// envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types.
#include "envelope_overlay.h"
#include <algorithm>
namespace reasampler::vst {
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.
const double frac = t / totalSeconds;
const long xi = static_cast<long>(frac * static_cast<double>(w) + 0.5);
return area.left + static_cast<int>(xi);
}
int levelToY(const Rect& area, double level) {
const int h = std::max(0, area.height());
if (h <= 0) return area.top;
if (level < 0.0) level = 0.0;
if (level > 1.0) level = 1.0;
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The
// range spans (h-1) pixels so both endpoints land ON a drawable row.
const int span = h - 1;
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
return area.top + static_cast<int>(dy);
}
namespace {
double clamp01(double v) {
if (v < 0.0) return 0.0;
if (v > 1.0) return 1.0;
return v;
}
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
EnvVertex v;
v.node = node;
v.x = timeToX(area, totalSeconds, t);
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);
const double h = std::max(0.0, env.holdSeconds);
const double d = std::max(0.0, env.decaySeconds);
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
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));
return pts;
}
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
// The played span is lengthFraction of the whole sample; fades are fractions OF that span.
const double len = clamp01(env.lengthFraction);
double fadeIn = clamp01(env.fadeInFraction);
double fadeOut = clamp01(env.fadeOutFraction);
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
const double playSeconds = len * totalSeconds;
const double tFadeInEnd = fadeIn * playSeconds;
const double tFadeOutStart = playSeconds - fadeOut * playSeconds; // where fade-out begins
std::vector<EnvVertex> pts;
pts.reserve(4);
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
pts.push_back(vtx(EnvNode::FadeInEnd, area, totalSeconds, tFadeInEnd, 1.0));
pts.push_back(vtx(EnvNode::FadeOutStart, area, totalSeconds, tFadeOutStart, 1.0)); // unity end
pts.push_back(vtx(EnvNode::LengthEnd, area, totalSeconds, playSeconds, 0.0)); // playEnd
return pts;
}
} // namespace
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
if (area.width() <= 0 || area.height() <= 0 || totalSeconds <= 0.0) {
// Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line.
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)};
}
return env.mode == EnvMode::Gate ? gatePolyline(env, area, totalSeconds)
: triggerPolyline(env, area, totalSeconds);
}
} // namespace reasampler::vst
+142
View File
@@ -0,0 +1,142 @@
// 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 at accurate wall-clock
// time (Simpler / Phase-Plant grammar):
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
// sustain plateau, release sustain->0.
// * 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.
//
// 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.
// 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 tracks its X = sample end, 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.
// 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).
// 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.
double lengthFraction = 1.0; // (0,1] of the post-start span that plays
double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span
double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span
};
// 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;
}
};
// 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.
//
// 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).
//
// 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.
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.
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