loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// Standalone tests for reasampler::instrument::engine::loop — no VST3, no REAPER, no test
|
||||
// framework. Covers the loop's validity/clamp rule, the pre-seam crossfade geometry, and the
|
||||
// editor's default handle placement.
|
||||
|
||||
#include "../src/core/instrument/engine/loop/loop_span.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::engine::loop;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static SampleLoop span(std::int64_t start, std::int64_t end, bool has = true) {
|
||||
SampleLoop l;
|
||||
l.hasLoop = has;
|
||||
l.start = start;
|
||||
l.end = end;
|
||||
return l;
|
||||
}
|
||||
|
||||
// --- Validity -----------------------------------------------------------------
|
||||
|
||||
static void testValidGateLoopResolvesToItsOwnSpan() {
|
||||
const ResolvedLoop lp = resolveLoop(span(100, 400), 0, 1000, /*gateMode=*/true);
|
||||
CHECK(lp.active);
|
||||
CHECK(lp.start == 100);
|
||||
CHECK(lp.end == 400);
|
||||
CHECK(lp.length == 300);
|
||||
CHECK(lp.crossfade == 0);
|
||||
}
|
||||
|
||||
static void testTriggerModeNeverLoops() {
|
||||
const ResolvedLoop lp = resolveLoop(span(100, 400), 32, 1000, /*gateMode=*/false);
|
||||
CHECK(!lp.active);
|
||||
CHECK(lp.length == 0);
|
||||
CHECK(lp.crossfade == 0);
|
||||
}
|
||||
|
||||
static void testUnsetLoopIsInactive() {
|
||||
const ResolvedLoop lp = resolveLoop(span(100, 400, /*has=*/false), 32, 1000, true);
|
||||
CHECK(!lp.active);
|
||||
}
|
||||
|
||||
// An inverted, empty, negative, or out-of-range span is REFUSED rather than repaired: a
|
||||
// wrong loop the user can hear beats a wrong loop the engine invented, and refusing is what
|
||||
// keeps the read path from indexing outside the PCM.
|
||||
static void testMalformedSpansAreRefusedNotRepaired() {
|
||||
CHECK(!resolveLoop(span(400, 100), 0, 1000, true).active); // inverted
|
||||
CHECK(!resolveLoop(span(200, 200), 0, 1000, true).active); // empty
|
||||
CHECK(!resolveLoop(span(-5, 400), 0, 1000, true).active); // negative start
|
||||
CHECK(!resolveLoop(span(100, 1001), 0, 1000, true).active); // end past the PCM
|
||||
CHECK(resolveLoop(span(100, 1000), 0, 1000, true).active); // end AT the PCM is fine
|
||||
}
|
||||
|
||||
// --- Crossfade clamping -------------------------------------------------------
|
||||
|
||||
static void testCrossfadeClampsToTheMaterialAheadOfTheLoop() {
|
||||
// The incoming tap reads [start - xf, start), so the fade cannot outrun `start`.
|
||||
const ResolvedLoop lp = resolveLoop(span(50, 400), 500, 1000, true);
|
||||
CHECK(lp.active);
|
||||
CHECK(lp.crossfade == 50);
|
||||
CHECK(lp.fadeBegin == 350.0);
|
||||
}
|
||||
|
||||
static void testCrossfadeClampsToTheLoopLength() {
|
||||
const ResolvedLoop lp = resolveLoop(span(500, 600), 400, 1000, true);
|
||||
CHECK(lp.active);
|
||||
CHECK(lp.crossfade == 100); // loop length, not the 400 asked for or the 500 before it
|
||||
}
|
||||
|
||||
static void testLoopAtFrameZeroGetsNoCrossfade() {
|
||||
const ResolvedLoop lp = resolveLoop(span(0, 400), 64, 1000, true);
|
||||
CHECK(lp.active);
|
||||
CHECK(lp.crossfade == 0); // nothing precedes the loop to fade in from
|
||||
CHECK(lp.fadeInv == 0.0);
|
||||
}
|
||||
|
||||
static void testNegativeCrossfadeIsZero() {
|
||||
const ResolvedLoop lp = resolveLoop(span(100, 400), -20, 1000, true);
|
||||
CHECK(lp.active);
|
||||
CHECK(lp.crossfade == 0);
|
||||
}
|
||||
|
||||
// --- Crossfade weight ---------------------------------------------------------
|
||||
|
||||
static void testZeroCrossfadeWeighsNothingAnywhere() {
|
||||
const ResolvedLoop lp = resolveLoop(span(100, 400), 0, 1000, true);
|
||||
CHECK(crossfadeWeight(lp, 100.0) == 0.0);
|
||||
CHECK(crossfadeWeight(lp, 399.0) == 0.0);
|
||||
CHECK(crossfadeWeight(lp, 399.999) == 0.0);
|
||||
}
|
||||
|
||||
// The weight rises from exactly 0 at the region's start to exactly 1 at the loop end, which
|
||||
// is what makes the seam continuous: at `end` the incoming tap has reached `start`, and the
|
||||
// wrap puts the head there.
|
||||
static void testWeightRunsZeroToOneAcrossTheFadeRegion() {
|
||||
const ResolvedLoop lp = resolveLoop(span(100, 400), 100, 1000, true);
|
||||
CHECK(lp.crossfade == 100);
|
||||
CHECK(lp.fadeBegin == 300.0);
|
||||
CHECK(crossfadeWeight(lp, 299.0) == 0.0);
|
||||
CHECK(crossfadeWeight(lp, 300.0) == 0.0);
|
||||
CHECK(crossfadeWeight(lp, 350.0) == 0.5);
|
||||
CHECK(crossfadeWeight(lp, 375.0) == 0.75);
|
||||
CHECK(crossfadeWeight(lp, 400.0) == 1.0);
|
||||
}
|
||||
|
||||
static void testWeightIsMonotoneAndBoundedAcrossTheRegion() {
|
||||
const ResolvedLoop lp = resolveLoop(span(100, 400), 60, 1000, true);
|
||||
double prev = -1.0;
|
||||
for (int i = 0; i <= 400; ++i) {
|
||||
const double pos = 300.0 + static_cast<double>(i) * 0.25; // sweeps 300..400
|
||||
const double w = crossfadeWeight(lp, pos);
|
||||
CHECK(w >= prev);
|
||||
CHECK(w >= 0.0 && w <= 1.0);
|
||||
prev = w;
|
||||
}
|
||||
CHECK(prev == 1.0);
|
||||
}
|
||||
|
||||
// The ceiling is a belt for an unwrapped caller: without it the linear ramp would extrapolate
|
||||
// past the incoming tap and amplify it.
|
||||
static void testWeightSaturatesPastTheLoopEnd() {
|
||||
const ResolvedLoop lp = resolveLoop(span(100, 400), 50, 1000, true);
|
||||
CHECK(crossfadeWeight(lp, 500.0) == 1.0);
|
||||
}
|
||||
|
||||
// --- Default handle placement -------------------------------------------------
|
||||
|
||||
static void testDefaultBoundsSitInTheLastQuarterAndClearFrameZero() {
|
||||
const LoopBounds d = defaultLoopBounds(1000);
|
||||
CHECK(d.start == 750);
|
||||
CHECK(d.end == 1000);
|
||||
CHECK(d.start > 0); // the whole point: it does not land under the start marker
|
||||
CHECK(defaultLoopBounds(0).start == 0 && defaultLoopBounds(0).end == 0);
|
||||
CHECK(defaultLoopBounds(-5).end == 0);
|
||||
}
|
||||
|
||||
// A default span is itself a valid loop, so the handles a user is offered describe a loop the
|
||||
// engine will actually accept.
|
||||
static void testDefaultBoundsResolveActive() {
|
||||
const LoopBounds d = defaultLoopBounds(888);
|
||||
const ResolvedLoop lp = resolveLoop(span(d.start, d.end), 0, 888, true);
|
||||
CHECK(lp.active);
|
||||
CHECK(lp.start == d.start && lp.end == d.end);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testValidGateLoopResolvesToItsOwnSpan();
|
||||
testTriggerModeNeverLoops();
|
||||
testUnsetLoopIsInactive();
|
||||
testMalformedSpansAreRefusedNotRepaired();
|
||||
testCrossfadeClampsToTheMaterialAheadOfTheLoop();
|
||||
testCrossfadeClampsToTheLoopLength();
|
||||
testLoopAtFrameZeroGetsNoCrossfade();
|
||||
testNegativeCrossfadeIsZero();
|
||||
testZeroCrossfadeWeighsNothingAnywhere();
|
||||
testWeightRunsZeroToOneAcrossTheFadeRegion();
|
||||
testWeightIsMonotoneAndBoundedAcrossTheRegion();
|
||||
testWeightSaturatesPastTheLoopEnd();
|
||||
testDefaultBoundsSitInTheLastQuarterAndClearFrameZero();
|
||||
testDefaultBoundsResolveActive();
|
||||
if (g_fail == 0) std::printf("loop_span_tests: all passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user