Files
reasampler/src/core/instrument/ui/envelope_edit.cpp
T

159 lines
6.9 KiB
C++

// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types.
#include "core/instrument/ui/envelope_edit.h"
#include <algorithm>
#include <cstdlib> // std::abs
namespace reasampler::instrument::ui {
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<double>(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<double>(h - 1);
}
// Origin + ReleaseStart are draw-only anchors, not grabbable.
bool isDraggable(EnvNode n) {
switch (n) {
case EnvNode::Origin:
case EnvNode::ReleaseStart:
return false;
default:
return true;
}
}
// Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
// Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
bool nodeInMode(EnvNode n, EnvMode m) {
switch (n) {
case EnvNode::AttackEnd:
case EnvNode::HoldEnd:
case EnvNode::DecayEnd:
case EnvNode::ReleaseEnd:
return m == EnvMode::Gate;
case EnvNode::FadeInEnd:
case EnvNode::FadeOutStart:
case EnvNode::LengthEnd:
return m == EnvMode::Trigger;
case EnvNode::Origin:
case EnvNode::ReleaseStart:
return false;
}
return false;
}
} // namespace
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance);
// ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
// coincidence (FadeOutStart overlaps LengthEnd and wins).
NodeHit best;
int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) {
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) 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;
}
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
AmpEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) 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<double>(dxPixels) * secPerPx;
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
switch (node) {
// Gate: 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 lvlPerPx = levelPerPixel(rect);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
break;
}
case EnvNode::ReleaseEnd:
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds + gateDSec, 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). fadeIn + fadeOut <= 1 keeps the two fade
// nodes from crossing (each clamps against the other).
case EnvNode::FadeInEnd: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
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 = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::FadeOutStart: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
// 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 = std::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 = std::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::instrument::ui