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:
@@ -25,14 +25,17 @@ The consequence is a hard clamp: **`crossfade <= start`**. A loop starting at fr
|
||||
material ahead of it and therefore gets no crossfade, whatever the user dialled — honest
|
||||
rather than silently reading before the buffer.
|
||||
|
||||
### The seam does not close to zero, it closes as 1/crossfade
|
||||
### The weight is normalized over `crossfade - 1`, so the last rendered frame lands AT 1
|
||||
|
||||
The weight reaches 1 only AT `end` — a position no rendered frame lands on — so the last
|
||||
frame before the wrap carries `(xf-1)/xf` and a residual step of `(seam step)/xf` survives.
|
||||
Measured on a source-frame ramp with a 19-frame seam: 4.0 at `xf = 4`, 1.5 at 8, 0.25 at 16
|
||||
(`sampler_core_tests`). It is proportional and therefore inaudible at any musically useful
|
||||
setting; a claim that the crossfade makes the seam *exactly* continuous is wrong in the
|
||||
discrete domain and a test asserting it will fail.
|
||||
`crossfadeWeight` normalizes by `1/(crossfade-1)`, not `1/crossfade`: `d` at the last rendered
|
||||
frame (`end - 1`) is always exactly `crossfade - 1` — the ceiling's own threshold — for every
|
||||
REAL crossfade length (`crossfade >= 2`), so that frame is the incoming tap outright rather than
|
||||
a blend approaching it. The seam across the wrap is therefore the material's OWN one-frame step
|
||||
(`sampler_core_tests`, `testSeamStepMatchesTheNaturalStepForAnyCrossfade`), not a residual that
|
||||
merely shrinks with a longer fade — the earlier `1/crossfade` normalization left `(xf-1)/xf` at
|
||||
that frame, which is what the `crossfade - 1` fix closes. `crossfade == 1` degenerates to the
|
||||
hard seam instead: its one frame sits at `d == 0`, caught by the `d <= 0` floor before the
|
||||
multiply/ceiling ever runs, so `fadeInv` is guarded to 0 rather than dividing by zero.
|
||||
|
||||
### Linear, not equal-power
|
||||
|
||||
@@ -41,6 +44,12 @@ where an equal-power pair bulges. Linear also costs a subtract and a multiply on
|
||||
forbids a transcendental. The filter's morph crossfade is equal-power for a reason specific
|
||||
to quadrature taps (`engine/filter/CLAUDE.md`) — that reasoning does not transfer here.
|
||||
|
||||
**Known exception:** a full-mix or stem bounce (in this tool's own stated material scope) is
|
||||
not quasi-periodic, so its two taps are effectively decorrelated — a linear pair then dips
|
||||
~3 dB at the fade midpoint the way it wouldn't on a correlated tonal/one-shot loop. Accepted
|
||||
rather than fixed: an equal-power pair would cost the transcendental this path forbids, and the
|
||||
dip is a fade-region loudness wobble, not the seam click the crossfade exists to kill.
|
||||
|
||||
### An invalid span is refused, never repaired
|
||||
|
||||
An inverted span, a span reaching past the PCM, a negative start, a Trigger voice: all yield
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# The loop's validity rule and crossfade geometry. Links peaks only (via play_params' own
|
||||
# SampleLoop) — deliberately not the voice engine: the resolve is a fold over plain values,
|
||||
# which is what lets the editor share it without pulling the engine in.
|
||||
# The loop's validity rule and crossfade geometry. Links peaks/filter/velocity_curve/curve_law
|
||||
# (play_params' own dependency set) — deliberately not the voice engine: the resolve is a fold
|
||||
# over plain values, which is what lets the editor share it without pulling the engine in.
|
||||
reasampler_pure_library(loop_span
|
||||
SOURCES loop_span.cpp
|
||||
LINK PUBLIC peaks filter velocity_curve curve_law)
|
||||
|
||||
@@ -21,11 +21,13 @@ ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
|
||||
// fade cannot outrun the material ahead of the loop, nor the loop itself.
|
||||
std::int64_t xf = crossfadeFrames;
|
||||
if (xf < 0) xf = 0;
|
||||
if (xf > out.start) xf = out.start;
|
||||
if (xf > out.length) xf = out.length;
|
||||
const std::int64_t bound = maxCrossfade(out.start, out.length);
|
||||
if (xf > bound) xf = bound;
|
||||
out.crossfade = xf;
|
||||
out.fadeBegin = static_cast<double>(out.end - xf);
|
||||
out.fadeInv = xf > 0 ? 1.0 / static_cast<double>(xf) : 0.0;
|
||||
// xf == 1 has no fractional region to normalize (crossfadeWeight's d <= 0 check already
|
||||
// catches its only frame) — guard rather than divide by zero.
|
||||
out.fadeInv = xf > 1 ? 1.0 / static_cast<double>(xf - 1) : 0.0;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,22 @@
|
||||
// it sits on the per-voice-per-sample read.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // AudioSample
|
||||
#include "core/instrument/engine/play_params.h" // SampleLoop
|
||||
|
||||
namespace reasampler::instrument::engine::loop {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The crossfade's own bound: it cannot outrun the material ahead of the loop (`start` source
|
||||
// frames precede it) nor the loop's own length (the incoming tap is one loop length behind the
|
||||
// head). Shared by resolveLoop's clamp and the editor's drag clamp so the two cannot diverge.
|
||||
inline std::int64_t maxCrossfade(std::int64_t start, std::int64_t length) {
|
||||
return start < length ? start : length;
|
||||
}
|
||||
|
||||
// A sustain loop folded against one capture: validity, geometry, and the clamped crossfade.
|
||||
// `active == false` leaves every other field zero, so a caller can wrap on the flag alone.
|
||||
//
|
||||
@@ -22,7 +33,7 @@ struct ResolvedLoop {
|
||||
std::int64_t length = 0; // end - start
|
||||
std::int64_t crossfade = 0; // source frames; 0 = hard seam
|
||||
double fadeBegin = 0.0; // end - crossfade
|
||||
double fadeInv = 0.0; // 1 / crossfade; 0 when crossfade == 0
|
||||
double fadeInv = 0.0; // 1 / (crossfade - 1); 0 when crossfade <= 1
|
||||
};
|
||||
|
||||
// Folds a stored loop + crossfade against the decoded sample. Refuses anything the read path
|
||||
@@ -33,13 +44,46 @@ ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
|
||||
std::int64_t frameCount, bool gateMode);
|
||||
|
||||
// Weight of the INCOMING (pre-loop-start) tap at source position `pos`: 0 before the fade
|
||||
// region, rising linearly to 1 at `end`. `pos` must already be wrapped into [start, end) —
|
||||
// the ceiling is a belt for a caller that has not wrapped yet, not a licence to skip it.
|
||||
// region, reaching exactly 1 at the LAST rendered frame (`end - 1`), not merely approaching it —
|
||||
// normalizing over `crossfade - 1` rather than `crossfade` is what buys that: d at `end - 1` is
|
||||
// always exactly `crossfade - 1`, the ceiling's own threshold, for any REAL crossfade
|
||||
// (`crossfade >= 2`). That last frame is therefore the incoming tap outright, which is exactly
|
||||
// the value the wrap hands over, so the step across the seam is the material's own natural step
|
||||
// — not a residual that merely shrinks with a longer fade. `crossfade == 1` degenerates to the
|
||||
// hard seam instead: its one frame sits at `d == 0`, caught by the `d <= 0` floor below before
|
||||
// the ceiling ever runs. `pos` must already be wrapped into [start, end) — the ceiling is a belt
|
||||
// for a caller that has not wrapped yet, not a licence to skip it.
|
||||
inline double crossfadeWeight(const ResolvedLoop& lp, double pos) {
|
||||
if (lp.crossfade <= 0) return 0.0;
|
||||
const double d = pos - lp.fadeBegin;
|
||||
if (d <= 0.0) return 0.0;
|
||||
return d < static_cast<double>(lp.crossfade) ? d * lp.fadeInv : 1.0;
|
||||
return d < static_cast<double>(lp.crossfade - 1) ? d * lp.fadeInv : 1.0;
|
||||
}
|
||||
|
||||
// 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.
|
||||
inline 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).
|
||||
inline AudioSample crossfadedSource(const std::vector<AudioSample>& pcm, const ResolvedLoop& lp,
|
||||
std::int64_t pos, double xw) {
|
||||
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 - lp.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));
|
||||
}
|
||||
|
||||
// Where the editor parks the loop handles for a capture that has none — the last quarter,
|
||||
|
||||
@@ -199,9 +199,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
// seam would put the click back one window into the note.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount)
|
||||
? crossfadedSource(pcmCh, q,
|
||||
instrument::engine::loop::crossfadeWeight(
|
||||
loop_, static_cast<double>(q)))
|
||||
? crossfadedSource(pcmCh, loop_, q, crossfadeWeight(loop_,
|
||||
static_cast<double>(q)))
|
||||
: 0.0f;
|
||||
++q;
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user