// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types. #include "core/instrument/ui/envelope_edit.h" #include "core/util/clamp01.h" #include #include // std::fabs #include // std::abs namespace reasampler::instrument::ui { using util::clamp01; using util::curveFromMidLevel; using util::curveMidLevel; namespace { // Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion). 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(w); } // Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a // dragged handle tracks the cursor 1:1. double gateSecondsPerPixel(const Rect& area) { const double pps = gatePxPerSecond(area); return pps > 0.0 ? 1.0 / pps : 0.0; } // Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]). double levelPerPixel(const Rect& area) { const int h = std::max(0, area.height); if (h <= 1) return 0.0; return 1.0 / static_cast(h - 1); } // Origin is a draw-only anchor; so is an AHDSR's ReleaseEnd, which is pinned to the right edge // (release is dragged from ReleaseStart instead). bool isDraggable(EnvNode n) { switch (n) { case EnvNode::Origin: case EnvNode::ReleaseEnd: return false; default: return true; } } // Guards the degenerate baseline's cross-kind vertices, and keeps the sustain-only nodes off an // AHD. Applied by both the hit-test and the drag resolver. bool nodeInKind(EnvNode n, EnvKind k) { switch (n) { case EnvNode::AttackEnd: case EnvNode::HoldEnd: case EnvNode::DecayEnd: case EnvNode::AttackCurve: case EnvNode::DecayCurve: return true; case EnvNode::ReleaseStart: case EnvNode::ReleaseCurve: return k == EnvKind::Ahdsr; case EnvNode::Origin: case EnvNode::ReleaseEnd: return false; } return false; } // The two endpoint levels of the segment a curve knot shapes. `ok` is false when the segment // is level (nothing a curve could express), so the drag is a no-op rather than a division. struct SegmentLevels { double start = 0.0; double end = 0.0; bool ok = false; }; SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) { const double sus = clamp01(env.sustainLevel); SegmentLevels s; switch (knot) { case EnvNode::AttackCurve: s = {0.0, 1.0, true}; break; case EnvNode::DecayCurve: s = {1.0, env.kind == EnvKind::Ahdsr ? sus : 0.0, true}; break; case EnvNode::ReleaseCurve: s = {sus, 0.0, true}; break; default: return s; } if (s.start == s.end) s.ok = false; return s; } // A knot drag: the grab-time mid-level shifted by the pixel delta, read back through // curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law). double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent, const Rect& area, int dyPixels) { const SegmentLevels seg = segmentLevels(grabEnv, knot); if (!seg.ok) return grabExponent; const double span = seg.end - seg.start; // segmentLevels only rejects an EXACTLY level segment; a near-level one (e.g. sustain // 0.99) still passes with a tiny divisor here, so one pixel of drag can swing `u` by // ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of // level travel — a segment thinner than that is visually a no-op drag anyway. if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent; const double grabLevel = seg.start + span * curveMidLevel(grabExponent); const double newLevel = grabLevel - static_cast(dyPixels) * levelPerPixel(area); return curveFromMidLevel((newLevel - seg.start) / span); } // An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see // resolveNodeDrag's DecayEnd case. At holdFraction == 1.0 that derivative is exactly 0, so a // drag there can never change anything; when it ALSO coincides with HoldEnd (decay ~ 0) it is a // dead handle sitting on top of a live one. Excluded from the grabbable set in that exact case // only — a functional DecayEnd (holdFraction < 1) stays grabbable even when it coincides. bool ahdDecayEndIsDead(const StageEnvelope& env, const std::vector& poly) { if (env.kind != EnvKind::Ahd) return false; if (1.0 - clamp01(env.holdFraction) > 1e-9) return false; EnvVertex hold, decay; bool haveHold = false, haveDecay = false; for (const EnvVertex& v : poly) { if (v.node == EnvNode::HoldEnd) { hold = v; haveHold = true; } else if (v.node == EnvNode::DecayEnd) { decay = v; haveDecay = true; } } return haveHold && haveDecay && hold.x == decay.x; } } // namespace NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double totalSeconds, int x, int y) { const std::vector poly = buildEnvelopePolyline(env, area, totalSeconds); const bool dropDeadDecayEnd = ahdDecayEndIsDead(env, poly); // Nearest draggable, kind-matching node within the pick radius wins (Chebyshev distance); // ties go to the earlier draw-order node. Knots are appended last, so a knot coincident // with an endpoint handle loses — a drag there stays a time edit. NodeHit best; int bestDist = kNodeGrabRadius + 1; for (const EnvVertex& v : poly) { if (!isDraggable(v.node) || !nodeInKind(v.node, env.kind)) continue; if (dropDeadDecayEnd && v.node == EnvNode::DecayEnd) continue; const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y)); if (dist < bestDist) { // strict-less-than keeps ties at the earlier draw order bestDist = dist; best = NodeHit{true, v.node}; } } return best; } StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area, double totalSeconds, const EnvClampBounds& bounds, int dxPixels, int dyPixels) { StageEnvelope out = grabEnv; if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out; const Rect& rect = area.rect; const double secPerPx = secondsPerPixel(rect, totalSeconds); if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion const double dSec = static_cast(dxPixels) * secPerPx; const double gateDSec = static_cast(dxPixels) * gateSecondsPerPixel(rect); if (grabEnv.kind == EnvKind::Ahdsr) { switch (node) { // Each cumulative-time node edits its own segment duration. Non-negative durations // ARE the monotonic-in-time guarantee (a segment can never go negative, so a node // can never cross a neighbour) — the [0, max] clamp is the whole constraint. case EnvNode::AttackEnd: out.attackSeconds = std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); break; case EnvNode::HoldEnd: out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); break; case EnvNode::DecayEnd: { // X sets decay time, Y sets sustain level (drag down = higher y = lower level). out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); const double dLevel = -static_cast(dyPixels) * levelPerPixel(rect); out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0); break; } case EnvNode::ReleaseStart: // The release runs from this node to the anchored right edge, so dragging LEFT // (negative dx) lengthens it — the delta enters with the opposite sign. out.releaseSeconds = std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds); break; case EnvNode::AttackCurve: out.attackCurve = curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels); break; case EnvNode::DecayCurve: out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels); break; case EnvNode::ReleaseCurve: out.releaseCurve = curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels); break; default: break; } return out; } // AHD: the x-axis is the waveform's own, so a stage node moves at 1:1 wall-clock scale. const AhdSplit s = splitAhdSeconds(grabEnv); switch (node) { case EnvNode::AttackEnd: out.attackSeconds = std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds); break; case EnvNode::HoldEnd: { // Hold is a fraction of what attack and decay left, so the node's pixel motion // converts through that remainder. A zero remainder leaves nothing to divide by and // nothing the drag could express. const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay; if (rem <= 0.0) break; out.holdFraction = clamp01((s.hold + dSec) / rem); break; } case EnvNode::DecayEnd: { // DecayEnd is DRAWN at t0 + total, and total = attack + decay + (span-attack-decay) // * holdFraction, so d(total)/d(decay) = 1 - holdFraction: Hold eats a holdFraction // share of whatever decay gives up. Scaling by 1/(1-frac) makes the drawn endpoint // track the cursor 1:1, matching every other node. At frac == 1.0 (the Trigger // default) Hold consumes the WHOLE remainder regardless of decay's value, so the // derivative is exactly 0 — no scale recovers motion there, and decaySeconds is left // unchanged rather than divided by zero. const double denom = 1.0 - clamp01(grabEnv.holdFraction); if (denom > 1e-9) { out.decaySeconds = std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds); } break; } case EnvNode::AttackCurve: out.attackCurve = curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels); break; case EnvNode::DecayCurve: out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels); break; default: break; } return out; } } // namespace reasampler::instrument::ui