// 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 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 LAST rendered // frame (end - 1, d == crossfade - 1) — normalizing over crossfade - 1 rather than crossfade is // what lands the ceiling exactly there instead of merely approaching it, which is what makes // the seam continuous: at that frame the incoming tap has fully replaced the raw read, and the // wrap hands over exactly that value. static void testWeightRunsZeroToOneAcrossTheFadeRegion() { // xf = 101 so xf - 1 = 100, a clean denominator (loopStart 200 keeps the clamp out of the // way: max is min(200, 200)). const ResolvedLoop lp = resolveLoop(span(200, 400), 101, 1000, true); CHECK(lp.crossfade == 101); CHECK(lp.fadeBegin == 299.0); CHECK(crossfadeWeight(lp, 298.0) == 0.0); CHECK(crossfadeWeight(lp, 299.0) == 0.0); CHECK(crossfadeWeight(lp, 349.0) == 0.5); // d = 50 CHECK(crossfadeWeight(lp, 374.0) == 0.75); // d = 75 CHECK(crossfadeWeight(lp, 399.0) == 1.0); // d = 100 == xf - 1, the ceiling's own threshold CHECK(crossfadeWeight(lp, 400.0) == 1.0); // past it too (the unwrapped-caller belt) } // xf - 1 == 0 would divide by zero; the guard parks fadeInv at 0 instead. Unreachable via the // multiply branch anyway (the region's only frame has d == 0, caught by the d <= 0 check // first), but fadeInv must still be a sane value rather than +inf. static void testCrossfadeOfOneNeedsNoDivisionGuard() { const ResolvedLoop lp = resolveLoop(span(100, 400), 1, 1000, true); CHECK(lp.crossfade == 1); CHECK(lp.fadeInv == 0.0); CHECK(crossfadeWeight(lp, 399.0) == 0.0); // d == 0, the region's one frame CHECK(crossfadeWeight(lp, 400.0) == 1.0); // past it, the ceiling belt still holds } 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(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(); testCrossfadeOfOneNeedsNoDivisionGuard(); testWeightIsMonotoneAndBoundedAcrossTheRegion(); testWeightSaturatesPastTheLoopEnd(); testDefaultBoundsSitInTheLastQuarterAndClearFrameZero(); testDefaultBoundsResolveActive(); if (g_fail == 0) std::printf("loop_span_tests: all passed\n"); return g_fail == 0 ? 0 : 1; }