instrument: fix AHD node-tracking/tie-break/live-latch defects and close staged-envelope-curve test gaps

This commit is contained in:
2026-07-31 09:52:17 -04:00
parent d60ab1524a
commit 2fa1405b06
27 changed files with 360 additions and 86 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ reasampler_test(browser_scroll LINK browser_scroll)
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
reasampler_test(param_slider LINK param_slider)
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry)
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law)
reasampler_test(envelope_overlay LINK envelope_overlay)
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
+24 -7
View File
@@ -5,6 +5,7 @@
#include "core/util/clamp01.h"
#include <algorithm>
#include <cmath> // std::fabs
#include <cstdlib> // std::abs
namespace reasampler::instrument::ui {
@@ -91,15 +92,20 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
}
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
// curve_law's inverse. Both directions go through the ONE law, which is why the knot and the
// inner dial cannot express different exponents.
// 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 grabLevel = seg.start + (seg.end - seg.start) * curveMidLevel(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<double>(dyPixels) * levelPerPixel(area);
return curveFromMidLevel((newLevel - seg.start) / (seg.end - seg.start));
return curveFromMidLevel((newLevel - seg.start) / span);
}
} // namespace
@@ -196,10 +202,21 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
out.holdFraction = clamp01((s.hold + dSec) / rem);
break;
}
case EnvNode::DecayEnd:
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + dSec, 0.0, bounds.maxDecaySeconds);
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);
+7 -15
View File
@@ -1,21 +1,13 @@
// envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
// envelope nodes and their mid-segment curve knots. Mirror of card_drag/waveform_view: drag
// arithmetic lives here, unit-tested outside the DAW; the shell draws handles, captures the
// grab, and feeds pixel deltas back in.
//
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
// (edit) map + hit-test. Both read/write the same StageEnvelope fields (the shell re-reads the
// one parameter set every paint), so a node drag, a knot drag, and a knob edit are three views
// on one source of truth — structurally, not through a listener chain.
//
// A drag can never produce a param a knob couldn't: time nodes are range-clamped to the same
// per-param [min,max] the knobs enforce (EnvClampBounds, caller-supplied since those maxima
// live shell-side), and a knot resolves through curve_law's own exponent domain.
// envelope nodes and their mid-segment curve knots. The inverse of envelope_overlay's forward
// (draw) map; both read/write the same StageEnvelope fields, so node drag / knot drag / knob
// edit are one model — see core/instrument/CLAUDE.md's "envelope overlay" section for why.
// Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested outside the DAW.
//
// Time-only nodes drag on X; DecayEnd in an AHDSR drags on both axes (X = decay time, Y =
// sustain level); a curve knot drags on Y alone. Origin is never draggable, and neither is an
// AHDSR's ReleaseEnd — it is anchored to the right edge, and release is dragged from
// ReleaseStart instead. A node is only editable in its own kind.
// sustain level); a curve knot drags on Y alone (curve_law.h owns the exponent domain). Origin
// is never draggable, and neither is an AHDSR's ReleaseEnd — anchored to the right edge, with
// release dragged from ReleaseStart instead. A node is only editable in its own kind.
#pragma once
+14 -4
View File
@@ -90,8 +90,7 @@ 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 is the curve's own value at the segment midpoint, which is what
// makes the knot's height and the inner dial two readings of one exponent.
// 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);
@@ -178,8 +177,19 @@ std::vector<EnvVertex> ahdPolyline(const StageEnvelope& env, const Rect& area,
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));
pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0));
EnvVertex holdVtx = vtx(EnvNode::HoldEnd, area, totalSeconds, t0 + s.attack + s.hold, 1.0);
EnvVertex decayVtx = vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0);
// A hold that consumes the WHOLE post-attack/decay remainder (holdFraction == 1.0, the
// Trigger AHD default) puts HoldEnd and DecayEnd on the same wall-clock instant, and
// nodeAtPoint's earlier-draw-order tie-break then hides DecayEnd behind HoldEnd forever.
// Nudge apart, clamped to the canvas — the same minimum-separation rationale
// kGateNodeSepPx exists for on the AHDSR schematic, applied to this coincidence instead.
if (decayVtx.x - holdVtx.x < kGateNodeSepPx) {
const int right = area.x + std::max(1, area.width) - 1;
decayVtx.x = std::min(holdVtx.x + kGateNodeSepPx, right);
}
pts.push_back(holdVtx);
pts.push_back(decayVtx);
if (s.attack > 0.0) {
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
env.attackCurve));
+3 -4
View File
@@ -13,10 +13,9 @@
namespace reasampler::instrument::ui {
// Which LAYOUT POLICY an envelope takes, decided by whether it has a sustain stage rather
// than by which processor it modulates. A gated (AHDSR) envelope right-anchors its release so
// the sustain plateau reads full-width; a sustain-less (AHD) one maps 1:1 onto the waveform's
// own time axis, which only means anything for a trigger shape. The two policies coexist.
// Which LAYOUT POLICY an envelope takes — see core/instrument/CLAUDE.md's "envelope overlay"
// section for why (decided by sustain-stage presence, not by which processor it modulates).
// Ahdsr right-anchors its release; Ahd maps 1:1 onto the waveform's own time axis.
enum class EnvKind { Ahdsr, Ahd };
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.