Fix envelope-overlay knot/trace disagreement on odd pixel spans

Generalize curveMidLevel/curveFromMidLevel to curveLevelAt/curveFromLevelAt at
arbitrary phi; knotVtx and its drag inverse now read the phi a knot's truncated
x actually implies, not always 0.5.
This commit is contained in:
2026-08-01 21:31:40 -04:00
parent ee8a956fbd
commit a1b42ed1a8
7 changed files with 252 additions and 32 deletions
+47 -12
View File
@@ -11,8 +11,8 @@
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveFromMidLevel;
using util::curveMidLevel;
using util::curveFromLevelAt;
using util::curveLevelAt;
namespace {
@@ -105,10 +105,35 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
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).
// The pixel bounds of the segment a curve knot rides, by node — read off the SAME polyline the
// draw built (never re-derived), so the drag's phi can never disagree with knotVtx's.
struct SegmentPixels {
int x0 = 0;
int x1 = 0;
bool ok = false;
};
SegmentPixels segmentPixels(const std::vector<EnvVertex>& poly, EnvNode knot) {
EnvNode startNode, endNode;
switch (knot) {
case EnvNode::AttackCurve: startNode = EnvNode::Origin; endNode = EnvNode::AttackEnd; break;
case EnvNode::DecayCurve: startNode = EnvNode::HoldEnd; endNode = EnvNode::DecayEnd; break;
case EnvNode::ReleaseCurve: startNode = EnvNode::ReleaseStart; endNode = EnvNode::ReleaseEnd; break;
default: return {};
}
SegmentPixels s;
bool haveStart = false, haveEnd = false;
for (const EnvVertex& v : poly) {
if (v.node == startNode) { s.x0 = v.x; haveStart = true; }
else if (v.node == endNode) { s.x1 = v.x; haveEnd = true; }
}
s.ok = haveStart && haveEnd;
return s;
}
// A knot drag: the grab-time level at `phi` (the phi the knot's own drawn x implies — see
// knotPhi) shifted by the pixel delta, read back through curve_law's inverse at that same phi.
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
const Rect& area, double dyPixels) {
double phi, const Rect& area, double dyPixels) {
const SegmentLevels seg = segmentLevels(grabEnv, knot);
if (!seg.ok) return grabExponent;
const double span = seg.end - seg.start;
@@ -117,9 +142,9 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab
// ~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 grabLevel = seg.start + span * curveLevelAt(phi, grabExponent);
const double newLevel = grabLevel - dyPixels * levelPerPixel(area);
return curveFromMidLevel((newLevel - seg.start) / span);
return curveFromLevelAt(phi, (newLevel - seg.start) / span);
}
// An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see
@@ -178,6 +203,16 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
const double dy = static_cast<double>(dyPixels) * scale;
const double dSec = dx * secPerPx;
// A curve knot's phi is read off the same polyline knotVtx drew, so the drag inverts the
// exact phi the knot is sitting at rather than assuming the segment midpoint.
double curvePhi = 0.5;
if (node == EnvNode::AttackCurve || node == EnvNode::DecayCurve ||
node == EnvNode::ReleaseCurve) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(grabEnv, area, totalSeconds);
const SegmentPixels sp = segmentPixels(poly, node);
if (sp.ok) curvePhi = knotPhi(sp.x0, sp.x1);
}
if (grabEnv.kind == EnvKind::Ahdsr) {
switch (node) {
// Each cumulative-time node edits its own segment duration. Non-negative durations
@@ -212,15 +247,15 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
break;
case EnvNode::AttackCurve:
out.attackCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dy), mods);
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dy), mods);
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::ReleaseCurve:
out.releaseCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dy), mods);
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, curvePhi, rect, dy), mods);
break;
default:
break;
@@ -261,11 +296,11 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
}
case EnvNode::AttackCurve:
out.attackCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dy), mods);
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dy), mods);
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
break;
default:
break;
+12 -5
View File
@@ -9,8 +9,7 @@
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveMap;
using util::curveMidLevel;
using util::curveLevelAt;
int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width);
@@ -46,6 +45,12 @@ int levelToY(const Rect& area, double level) {
return area.y + static_cast<int>(dy);
}
double knotPhi(int x0, int x1) {
if (x1 == x0) return 0.5;
const int mid = (x0 + x1) / 2;
return static_cast<double>(mid - x0) / static_cast<double>(x1 - x0);
}
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
AhdSplit out;
const double span = std::max(0.0, env.spanSeconds);
@@ -90,14 +95,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool
}
// 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).
// pixel midpoint. Its level is read at the phi that midpoint's TRUNCATED x actually implies
// (knotPhi), not always phi = 0.5 — an odd-pixel span would otherwise draw the knot a half
// pixel off the curve its own vertices trace. curve_law.h owns the knot/dial pairing.
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;
const double u = curveLevelAt(knotPhi(x0, x1), exponent);
const double level = startLevel + (endLevel - startLevel) * u;
v.y = levelToY(area, level);
v.level = level;
v.knot = true;
@@ -115,6 +115,13 @@ int timeToX(const Rect& area, double totalSeconds, double t);
// clamped. Shared with envelope_edit's node hit-test.
int levelToY(const Rect& area, double level);
// The normalized phi a curve knot's TRUNCATED integer x actually lands at within its bounding
// segment [x0, x1] — exactly 0.5 only when the span is even. Shared with envelope_edit's knot
// drag so the draw and its inverse read the same phi off the same formula rather than two
// copies that could drift apart. x0 == x1 (no interior) returns 0.5; callers never place a knot
// there.
double knotPhi(int x0, int x1);
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push