loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone

This commit is contained in:
2026-07-31 17:36:36 -04:00
parent a90ccd9a00
commit 0fe4166d7d
25 changed files with 991 additions and 64 deletions
+253
View File
@@ -712,6 +712,251 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
}
}
// ---------------------------------------------------------------------------
// Gate loop sustain: sample-exact wrapping, the crossfaded seam, and release out.
// ---------------------------------------------------------------------------
// A sample whose every frame carries its own index scaled down, so an observed output value
// names the exact source frame it came from — which is what makes "sample-exact" assertable
// rather than merely plausible.
static SampleData indexSample(std::size_t frames, int rootNote = 60) {
SampleData s;
s.frames.resize(frames);
for (std::size_t i = 0; i < frames; ++i) {
s.frames[i] = static_cast<float>(i) * 0.001f;
}
s.rootNote = rootNote;
return s;
}
// The source frame an output value names, inverted from indexSample's encoding.
static double sourceFrameOf(double out) { return out * 1000.0; }
static void testLoopReadWrapsSampleExactOverManyCycles() {
// Loop [40, 60) over a 100-frame index sample: the head must walk 40..59 and jump back to
// exactly 40, cycle after cycle, with nothing skipped or repeated at the seam.
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 200); // 8 full cycles past the loop entry
CHECK(eng.activeVoiceCount() == 1);
for (std::size_t i = 0; i < out.size(); ++i) {
// Output frame i reads source frame i while i < 60, then wraps by 20 each cycle.
const std::int64_t expected = i < 60 ? static_cast<std::int64_t>(i)
: 40 + ((static_cast<std::int64_t>(i) - 60) % 20);
CHECK(approx(sourceFrameOf(out[i]), static_cast<double>(expected), 1e-3));
}
}
static void testZeroCrossfadeLeavesTheSeamHard() {
// With no fade dialled, the frame at the loop end and the frame after it are the raw
// source frames — the step is the whole point of the default, and the whole thing a
// nonzero fade has to smooth.
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.loopCrossfadeFrames = 0;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 80);
CHECK(approx(sourceFrameOf(out[59]), 59.0, 1e-3));
CHECK(approx(sourceFrameOf(out[60]), 40.0, 1e-3)); // hard jump, no blend
}
// The output at output-frame n, for loop [40,60) over the index sample under fade length xf.
static double loopedFrameValue(std::int64_t xf, std::size_t n) {
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.loopCrossfadeFrames = xf;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 80);
return sourceFrameOf(out[n]);
}
static void testCrossfadeBlendsMonotonelyAcrossTheRegion() {
// Loop [40, 60) with an 8-frame pre-seam fade: over output frames 52..59 the read blends
// from source frame n toward source frame n-20 (the same head one loop length back). The
// region ENTRY is exactly continuous — frame 52 carries weight 0 — and every frame in it
// is the exact linear blend, falling monotonically.
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.loopCrossfadeFrames = 8;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 80);
CHECK(approx(sourceFrameOf(out[51]), 51.0, 1e-3));
CHECK(approx(sourceFrameOf(out[52]), 52.0, 1e-3)); // weight 0 at the region edge
for (int n = 52; n < 60; ++n) {
const double w = static_cast<double>(n - 52) / 8.0;
const double want = static_cast<double>(n) * (1.0 - w) + static_cast<double>(n - 20) * w;
CHECK(approx(sourceFrameOf(out[static_cast<std::size_t>(n)]), want, 1e-3));
}
for (int n = 53; n < 60; ++n) {
CHECK(out[static_cast<std::size_t>(n)] < out[static_cast<std::size_t>(n - 1)]);
}
}
// What a longer fade actually buys, measured rather than asserted by adjective. The last
// rendered frame before the wrap carries weight (xf-1)/xf, not 1 — the weight only reaches 1
// AT `end`, a position no frame lands on — so a residual step of (seam step)/xf survives.
// It shrinks in exact proportion to the fade length, which is the property that makes the
// parameter meaningful and the seam inaudible at any musically useful setting.
static void testSeamStepShrinksInProportionToTheFadeLength() {
auto seamStep = [](std::int64_t xf) {
return std::fabs(loopedFrameValue(xf, 60) - loopedFrameValue(xf, 59));
};
const double hard = seamStep(0);
CHECK(approx(hard, 19.0, 1e-3)); // 59 -> 40, the whole loop length less one frame
CHECK(approx(seamStep(4), 4.0, 1e-3));
CHECK(approx(seamStep(8), 1.5, 1e-3));
CHECK(approx(seamStep(16), 0.25, 1e-3));
// Each doubling roughly halves it, and even the shortest fade tested is a quarter of the
// hard seam.
CHECK(seamStep(4) < hard * 0.25);
CHECK(seamStep(8) < seamStep(4) * 0.5);
CHECK(seamStep(16) < seamStep(8) * 0.5);
}
static void testCrossfadeLengthFollowsItsParameter() {
// A longer fade starts earlier and nowhere else: the region begin is end - crossfade, so
// doubling the parameter doubles the number of blended frames.
auto firstFadedFrame = [](std::int64_t xf) {
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.loopCrossfadeFrames = xf;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 70);
for (int n = 40; n < 60; ++n) {
const double got = sourceFrameOf(out[static_cast<std::size_t>(n)]);
if (!approx(got, static_cast<double>(n), 1e-3)) return n;
}
return 60;
};
CHECK(firstFadedFrame(0) == 60); // never diverges from the raw read
CHECK(firstFadedFrame(4) == 57); // region [56,60); frame 56 carries weight 0
CHECK(firstFadedFrame(8) == 53); // region [52,60)
CHECK(firstFadedFrame(16) == 45); // region [44,60)
}
// The fade cannot read before frame 0, so a loop starting at 0 gets none — silently clamped
// rather than reading out of bounds or refusing the loop outright.
static void testCrossfadeIsSuppressedForALoopAtFrameZero() {
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 0;
s.loop.end = 20;
s.loopCrossfadeFrames = 16;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 60);
CHECK(eng.activeVoiceCount() == 1);
for (int n = 0; n < 20; ++n) {
CHECK(approx(sourceFrameOf(out[static_cast<std::size_t>(n)]),
static_cast<double>(n), 1e-3));
}
}
static void testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice() {
// The loop is the SUSTAIN: held, the voice never ends; released, it must leave the
// sustain level down the release ramp and free itself — not keep cycling forever.
SampleData s = dcSample(60, 60); // flat 1.0 so output IS the envelope
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 40;
s.loopCrossfadeFrames = 4;
AdsrParams a = flatAdsr();
a.releaseFrames = 32;
s.play.adsr = a;
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> held;
eng.render(held, 500); // far past the sample end
CHECK(eng.activeVoiceCount() == 1);
CHECK(approx(held.back(), 1.0, 1e-4)); // still at sustain, still looping
eng.noteOff(60);
std::vector<AudioSample> tail;
eng.render(tail, 16);
// Mid-release: audibly decaying, not yet done.
CHECK(tail.back() < 0.75 && tail.back() > 0.0);
CHECK(eng.activeVoiceCount() == 1);
std::vector<AudioSample> rest;
eng.render(rest, 64);
CHECK(eng.activeVoiceCount() == 0);
CHECK(approx(rest.back(), 0.0, 1e-6));
}
// Preserve's contract is LOOP THE SOURCE, SHIFT THE OUTPUT: the loop points name source
// frames, so a transposed note loops the same source span and keeps sounding at level. If the
// span were treated as an output-domain fact, an up-shifted voice would outrun it, freeze its
// shifter tail and decay. Run with and without a crossfade, since the fade is applied to the
// SOURCE feed ahead of the shifter and the ring prime walks the same blend.
static void testPreserveLoopsTheSourceSpanAtEveryTransposition() {
for (std::int64_t xf : {std::int64_t{0}, std::int64_t{16}}) {
for (int note : {48, 60, 72}) {
SampleData s;
s.frames.resize(200, 0.0f);
for (int i = 60; i < 120; ++i) s.frames[i] = 0.5f; // the loop body + its run-in
s.rootNote = 60;
s.sampleRate = 48000;
s.loop.hasLoop = true;
s.loop.start = 80;
s.loop.end = 120;
s.loopCrossfadeFrames = xf;
s.play.adsr = flatAdsr();
s.play.pitchEngine = PitchEngine::Preserve;
VoiceEngine eng(1, s);
eng.noteOn(note, 127);
std::vector<AudioSample> out;
eng.render(out, 4000); // 20x the sample length
// The source span is finite; only the loop can keep a voice alive this long, and
// it does so at every transposition because the span is a source-frame fact.
CHECK(eng.activeVoiceCount() == 1);
// And it is still delivering the loop body, not a decaying frozen tail. The body
// and its run-in are one constant, so the shifter's splices reproduce it whatever
// the shift ratio and whatever the fade blends.
double sum = 0.0;
for (std::size_t i = out.size() - 200; i < out.size(); ++i) sum += out[i];
CHECK(approx(sum / 200.0, 0.5, 0.05));
}
}
}
// ---------------------------------------------------------------------------
// velocity -> volume.
// ---------------------------------------------------------------------------
@@ -2562,6 +2807,14 @@ int main() {
testStartFrameOutOfRangeClampsToZero();
testStartFrameWithLoop();
testStartAfterLoopEndWrapsIntoLoop();
testLoopReadWrapsSampleExactOverManyCycles();
testZeroCrossfadeLeavesTheSeamHard();
testCrossfadeBlendsMonotonelyAcrossTheRegion();
testSeamStepShrinksInProportionToTheFadeLength();
testCrossfadeLengthFollowsItsParameter();
testCrossfadeIsSuppressedForALoopAtFrameZero();
testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice();
testPreserveLoopsTheSourceSpanAtEveryTransposition();
testVelocityDefaultCurveIsFlatUnity();
testVelocityLinearCurveReproducesRamp();
testVelocityShapedCurveDrivesGain();