// envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types. #include "core/instrument/ui/envelope_overlay.h" #include "core/util/clamp01.h" #include namespace reasampler::instrument::ui { using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) 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.x; if (t < 0.0) t = 0.0; // Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the // last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge // t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round. double px = (t / totalSeconds) * static_cast(w); if (px > static_cast(w - 1)) px = static_cast(w - 1); return area.x + static_cast(px + 0.5); } int gateTimedWidth(const Rect& area) { const int w = std::max(0, area.width); if (w <= 0) return 0; const int sustainPx = static_cast(kGateSustainDisplayFraction * static_cast(w) + 0.5); return std::max(1, w - sustainPx); } double gatePxPerSecond(const Rect& area) { const int timedW = gateTimedWidth(area); if (timedW <= 0) return 0.0; // Usable width = timed region minus the four per-segment separation bases and the last // in-bounds column, floored at 1 px so the scale never degenerates; the domain is the four // stages end-to-end at their schematic maxima (param-domain scale — sample-length-free). const double usable = std::max(1.0, static_cast(timedW - 1 - 4 * kGateNodeSepPx)); return usable / (4.0 * kGateStageMaxSeconds); } int levelToY(const Rect& area, double level) { const int h = std::max(0, area.height); if (h <= 0) return area.y; 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((1.0 - level) * static_cast(span) + 0.5); return area.y + static_cast(dy); } namespace { 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; } // One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space — // timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in // DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a // huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge). EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) { const int w = std::max(1, area.width); if (px < 0.0) px = 0.0; if (px > static_cast(w - 1)) px = static_cast(w - 1); EnvVertex v; v.node = node; v.x = area.x + static_cast(px + 0.5); v.y = levelToY(area, level); v.level = level; return v; } std::vector gatePolyline(const AmpEnvelope& env, const Rect& area) { // 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); // BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved // sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment // gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually // grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain // plateau is the fixed reserve between DecayEnd and ReleaseStart. const int W = std::max(1, area.width); const double sustainPx = static_cast(W - gateTimedWidth(area)); const double sep = static_cast(kGateNodeSepPx); const double pps = gatePxPerSecond(area); double xAttack = sep + a * pps; // AttackEnd double xHold = xAttack + sep + h * pps; // HoldEnd double xDecay = xHold + sep + d * pps; // DecayEnd (sustain node) double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off) double xRelease = xPlateau + sep + r * pps; // ReleaseEnd // Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT // preserving the minimum gaps, so trailing nodes stay individually separated instead of // piling on the last column. The re-floor pass only bites when the canvas is too narrow to // hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation). const double xMax = static_cast(W - 1); if (xRelease > xMax) { xRelease = xMax; xPlateau = std::min(xPlateau, xRelease - sep); xDecay = std::min(xDecay, xPlateau - sustainPx); xHold = std::min(xHold, xDecay - sep); xAttack = std::min(xAttack, xHold - sep); xAttack = std::max(xAttack, sep); xHold = std::max(xHold, xAttack + sep); xDecay = std::max(xDecay, xHold + sep); xPlateau = std::max(xPlateau, xDecay + sustainPx); xRelease = std::max(xRelease, xPlateau + sep); } std::vector pts; pts.reserve(6); pts.push_back(gateVtx(EnvNode::Origin, area, 0.0, 0.0)); pts.push_back(gateVtx(EnvNode::AttackEnd, area, xAttack, 1.0)); pts.push_back(gateVtx(EnvNode::HoldEnd, area, xHold, 1.0)); pts.push_back(gateVtx(EnvNode::DecayEnd, area, xDecay, sus)); // sustain node pts.push_back(gateVtx(EnvNode::ReleaseStart, area, xPlateau, sus)); // plateau end pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0)); return pts; } std::vector 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 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 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)}; } // Gate is a param-domain schematic — totalSeconds only gates the degenerate branch above // (no loaded duration -> baseline); Trigger is PCM-aligned and consumes it. return env.mode == EnvMode::Gate ? gatePolyline(env, area) : triggerPolyline(env, area, totalSeconds); } } // namespace reasampler::instrument::ui