instrument: fix AHD DecayEnd overlay/grab defect, pin flaky curve test, close comment/doc findings

This commit is contained in:
2026-07-31 10:19:45 -04:00
parent 2fa1405b06
commit 03fb471c92
10 changed files with 117 additions and 28 deletions
+6
View File
@@ -95,6 +95,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
// baseRatio_ source frames per output frame — needs the span converted, or a transposed
// note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source
// rate, so its two domains already coincide.
// Divides by baseRatio_ alone, though the actual Varispeed read rate is baseRatio_ x
// envFactor — a deep pitch envelope makes this a first-order approximation, not exact.
// Strictly better than the un-converted source-frame span it replaced.
const double pitchSpan =
(pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0))
? static_cast<double>(postStart)
@@ -268,6 +271,9 @@ void Voice::retune(int note) {
// legato phrase is one gesture, one strike (classic mono-synth behavior).
if (!active_ || sample_ == nullptr) return;
note_ = note;
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the
// baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the
// first note's domain — consistent with "touch nothing else," but the drift lives here.
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
-5
View File
@@ -6,11 +6,6 @@
// configured an out-of-line render would put a call — and the envelope ticks behind it —
// across a TU boundary on the hottest path in the program. The per-NOTE half (start /
// retune / release / hardStop / presize) is cold enough to live in voice.cpp.
//
// DOCUMENTED ~600-line-ceiling EXCEPTION (root CLAUDE.md structural heuristic 1): this file
// is over the ceiling because of the constraint above, not silent overshoot. A responsibility
// seam here would move part of advanceFrame's inline body out of this header, reintroducing
// the cross-TU call the header-inlining exists to avoid — worse than the overshoot.
#include <cmath>
#include <cstdint>
+1 -1
View File
@@ -51,7 +51,7 @@ reasampler_test(knob_deck LINK knob_deck)
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
reasampler_pure_library(deck_groups
SOURCES deck_groups.cpp
LINK PUBLIC knob_deck velocity_curve peaks)
LINK PUBLIC knob_deck velocity_curve peaks curve_law)
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
# needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands)
+19
View File
@@ -108,11 +108,29 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab
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<EnvVertex>& 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<EnvVertex> 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.
@@ -120,6 +138,7 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
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;
+3 -1
View File
@@ -37,7 +37,9 @@ struct EnvClampBounds {
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node.
// Nearest node within the radius wins (Chebyshev distance); an exact tie goes to the earlier
// draw-order node, and since knots are appended last, a coincident endpoint handle wins over a
// knot rather than the drag silently becoming a curve edit.
// knot rather than the drag silently becoming a curve edit. An AHD's DecayEnd is excluded
// entirely when it coincides with HoldEnd AND holdFraction == 1.0 — there it cannot move
// (resolveNodeDrag's derivative is 0), so it is dropped rather than won by draw order.
struct NodeHit {
bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
+8 -13
View File
@@ -177,19 +177,14 @@ 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));
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);
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));