loop: fix the crossfade seam's residual discontinuity, plus six review minors

Normalizes crossfadeWeight over crossfade-1 so the last rendered frame lands at exactly the incoming tap instead of a residual step; corrects the CLAUDE.md invariant and seam test to match. Shares lerpSource/crossfadedSource/maxCrossfade, fixes stale docs/constants, and clears crossfade on the loop-OFF gesture.
This commit is contained in:
2026-07-31 17:59:58 -04:00
parent 0fe4166d7d
commit 3cb22e984d
12 changed files with 172 additions and 83 deletions
+4 -28
View File
@@ -29,6 +29,8 @@ using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
using instrument::engine::loop::ResolvedLoop;
using instrument::engine::loop::crossfadeWeight;
using instrument::engine::loop::crossfadedSource;
using instrument::engine::loop::lerpSource;
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
// reference-frequency needed.
@@ -154,32 +156,6 @@ public:
}
private:
// Linear-interpolated read at a plain (non-wrapping) fractional source position. The
// crossfade tap sits one loop length behind the head, i.e. BEFORE the loop start, so it
// never needs the wrap partner the main read uses.
static double lerpSource(const std::vector<AudioSample>& pcm, std::int64_t frameCount,
double pos) {
const std::int64_t i0 = static_cast<std::int64_t>(pos);
const std::int64_t i1 = i0 + 1;
const double frac = pos - static_cast<double>(i0);
const double a = (i0 >= 0 && i0 < frameCount) ? static_cast<double>(pcm[i0]) : 0.0;
const double b = (i1 >= 0 && i1 < frameCount) ? static_cast<double>(pcm[i1]) : 0.0;
return a + (b - a) * frac;
}
// One integer source frame with the loop crossfade already blended in — the Preserve
// path's read, and the start()-time ring prime's. `pos` must be a valid index; `xw` is
// crossfadeWeight at that position (0 blends nothing).
AudioSample crossfadedSource(const std::vector<AudioSample>& pcm, std::int64_t pos,
double xw) const {
const double v = static_cast<double>(pcm[static_cast<std::size_t>(pos)]);
if (xw <= 0.0) return static_cast<AudioSample>(v);
const std::int64_t tap = pos - loop_.length;
if (tap < 0) return static_cast<AudioSample>(v);
const double in = static_cast<double>(pcm[static_cast<std::size_t>(tap)]);
return static_cast<AudioSample>(v + xw * (in - v));
}
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
// output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
@@ -449,7 +425,7 @@ private:
// shift the output.
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
const AudioSample feedL =
feedOk ? crossfadedSource(pcm, feedPos_, feedXw) : 0.0f;
feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
@@ -468,7 +444,7 @@ private:
// not leak a previous note.
if (exhausted) shiftR_.freezeTail();
const AudioSample feedR =
feedOk ? crossfadedSource(pcmR, feedPos_, feedXw) : 0.0f;
feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));