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
+2 -2
View File
@@ -20,7 +20,7 @@ add_subdirectory(filter)
# engine — the block is a plain value the voice observes, not a thing the engine owns.
reasampler_pure_library(live_params
SOURCES live_params.cpp
LINK PUBLIC peaks velocity_curve filter)
LINK PUBLIC peaks velocity_curve filter curve_law)
reasampler_test(live_params LINK live_params)
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
@@ -28,7 +28,7 @@ reasampler_test(live_params LINK live_params)
# boundary costs the hot path nothing.
reasampler_pure_library(sampler_core
SOURCES voice.cpp voice_engine.cpp
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params)
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law)
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
# or REAPER type reaching the core would fail to compile or link here.
reasampler_test(sampler_core LINK sampler_core)
+12 -5
View File
@@ -346,14 +346,14 @@ public:
// Trigger amp and filter envelopes. A zero/negative span finishes immediately.
void configure(std::int64_t spanFrames, const AhdParams& params) {
span_ = spanFrames > 0 ? spanFrames : 0;
fit(params);
fit(params, /*latchFinished=*/false); // a fresh note starts from a clean read
smooth_.clear();
}
// Peer of AdsrEnvelope::snapLive: a voice that has rendered nothing takes the new shape
// outright, with no step to absorb.
void snapLive(const AhdParams& params) {
fit(params);
fit(params, /*latchFinished=*/false);
smooth_.clear();
}
@@ -361,7 +361,10 @@ public:
// why this smooths rather than holding a normalized position.
void applyLive(double sourceOffset, const AhdParams& params) {
const double before = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
fit(params);
// LATCHED: a voice already read past its fitted total must never resurge because a
// later live move reopened the total. Reachable on any active() voice, including one
// ringing out past its own end (voice.h) where tickAmplitude() still runs.
fit(params, /*latchFinished=*/true);
const double after = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
if (after != before) smooth_.absorb(before - after);
}
@@ -381,11 +384,15 @@ public:
const AhdSpan& stages() const { return fit_; }
private:
void fit(const AhdParams& p) {
// `latchFinished`: once true, a re-fit can only ever KEEP finished_ true, never clear it —
// see applyLive above for why. configure()/snapLive() pass false: those are a fresh read
// (new note or a not-yet-rendered voice), which must compute finished_ from scratch.
void fit(const AhdParams& p, bool latchFinished) {
fit_ = fitAhd(span_, p);
attackCurve_ = p.attackCurve;
decayCurve_ = p.decayCurve;
finished_ = (fit_.total <= 0);
const bool empty = (fit_.total <= 0);
finished_ = latchFinished ? (finished_ || empty) : empty;
}
std::int64_t span_ = 0;
+2 -2
View File
@@ -55,8 +55,8 @@ struct AdsrParams {
// Attack -> Hold -> Decay over a bounded span: the shape every SUSTAIN-LESS envelope takes
// (the Trigger amp, the Trigger filter envelope, the pitch envelope). Hold is a FRACTION of
// the span left after attack and decay, never a time of its own — that is what makes
// A + H + D <= span structural rather than clamped (see fitAhd in envelopes.h).
// the span left after attack and decay, never a time of its own — fitAhd (envelopes.h) owns
// why a fraction, not a time.
struct AhdParams {
std::int64_t attackFrames = 0;
std::int64_t decayFrames = 0;
+5
View File
@@ -6,6 +6,11 @@
// 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>