// 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; using util::curveMap; using util::curveMidLevel; 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; // Clamp in double space before the int cast — a huge t would overflow a 32-bit long // (Windows) and wrap to the wrong edge. 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); } double gatePxPerSecond(const Rect& area) { const int w = std::max(0, area.width); if (w <= 0) return 0.0; // The four timed stages share the canvas minus their four separation bases and the last // in-bounds column; whatever they leave IS the sustain plateau, which is why a zero release // puts the plateau's end one separation short of the right edge rather than a fixed // fraction of the way across. const double usable = std::max(1.0, static_cast(w - 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; spans (h-1) px 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); } AhdSplit splitAhdSeconds(const StageEnvelope& env) { AhdSplit out; const double span = std::max(0.0, env.spanSeconds); double a = std::max(0.0, env.attackSeconds); if (a > span) a = span; double d = std::max(0.0, env.decaySeconds); if (d > span - a) d = span - a; const double remaining = span - a - d; out.attack = a; out.decay = d; out.hold = remaining * clamp01(env.holdFraction); out.total = out.attack + out.hold + out.decay; return out; } namespace { EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level, bool knot = false) { EnvVertex v; v.node = node; v.x = timeToX(area, totalSeconds, t); v.y = levelToY(area, level); v.level = level; v.knot = knot; return v; } // The AHDSR schematic works in px space rather than the plain timeToX map; clamps in double // space before the int cast for the same overflow reason as timeToX. EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool knot = false) { 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; v.knot = knot; return v; } // The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's // pixel midpoint, its level read through curve_law.h's own law (the knot/dial pairing's home). EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel, double endLevel, double exponent) { const double u = curveMidLevel(exponent); const double level = startLevel + (endLevel - startLevel) * u; EnvVertex v; v.node = node; v.x = (x0 + x1) / 2; v.y = levelToY(area, level); v.level = level; v.knot = true; return v; } std::vector gatePolyline(const StageEnvelope& env, const Rect& area) { // Clamp defensively — a stored negative duration would be an upstream bug. 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); const int W = std::max(1, area.width); const double sep = static_cast(kGateNodeSepPx); const double pps = gatePxPerSecond(area); const double xMax = static_cast(W - 1); // The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart — // the sustain->release join, and the node the user drags — sits a release-length to its // left. Everything the release does not take is the sustain plateau, so a zero release // leaves the plateau running to within one separation of the edge. double xAttack = sep + a * pps; double xHold = xAttack + sep + h * pps; double xDecay = xHold + sep + d * pps; double xPlateau = xMax - sep - r * pps; const double xRelease = xMax; // Keep every node separated when the four stages together would overrun the canvas: the // plateau holds its minimum gap from the edge, then the A/H/D chain compresses from the // right and re-floors from the left. This only bites at the domain's extremes; gateVtx's // own clamp wins on a canvas too narrow to hold the gaps at all. if (xPlateau < xDecay + sep) { if (xPlateau < 4.0 * sep) xPlateau = 4.0 * sep; xDecay = std::min(xDecay, xPlateau - sep); 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 + sep); } std::vector pts; pts.reserve(9); 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)); // anchored // Knots ride only SLOPED stages that actually have a duration — a zero-length stage has no // interior to place a handle in, and one there would collide with its own endpoints. if (a > 0.0) { pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0, env.attackCurve)); } if (d > 0.0) { pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, sus, env.decayCurve)); } if (r > 0.0) { pts.push_back(knotVtx(EnvNode::ReleaseCurve, area, pts[4].x, pts[5].x, sus, 0.0, env.releaseCurve)); } return pts; } std::vector ahdPolyline(const StageEnvelope& env, const Rect& area, double totalSeconds) { const AhdSplit s = splitAhdSeconds(env); const double t0 = std::max(0.0, env.originSeconds); std::vector pts; pts.reserve(6); pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, t0, 0.0)); pts.push_back(vtx(EnvNode::AttackEnd, area, totalSeconds, t0 + s.attack, 1.0)); pts.push_back(vtx(EnvNode::HoldEnd, area, totalSeconds, t0 + s.attack + s.hold, 1.0)); // DecayEnd is drawn at t0 + total, which coincides with HoldEnd exactly when decay ~ 0 — // independent of holdFraction (total = attack + hold + decay always). Left at its true // instant rather than nudged: the 1:1 axis this policy exists to keep honest must hold even // at a shared instant, including the Trigger default's abrupt (zero-decay) cutoff. // envelope_edit's nodeAtPoint handles the coincidence instead, by dropping DecayEnd from the // grabbable set when it also cannot move (holdFraction == 1.0). pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0)); if (s.attack > 0.0) { pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0, env.attackCurve)); } if (s.decay > 0.0) { pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, 0.0, env.decayCurve)); } return pts; } } // namespace std::vector buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area, double totalSeconds) { const Rect& rect = area.rect; if (rect.width <= 0 || rect.height <= 0 || totalSeconds <= 0.0) { // Degenerate surface: flat two-point baseline so the shell always has a line. return {vtx(EnvNode::Origin, rect, 1.0, 0.0, 0.0), vtx(EnvNode::ReleaseEnd, rect, 1.0, 1.0, 0.0)}; } return env.kind == EnvKind::Ahdsr ? gatePolyline(env, rect) : ahdPolyline(env, rect, totalSeconds); } } // namespace reasampler::instrument::ui