// Standalone tests for reasampler::instrument::engine::StretchCursor — the Preserve read's // source-feed schedule. No VST3, no REAPER, no vendor, no test framework. // // Covers: // 1. rate 1.0 is EXACTLY one source frame per output frame, forever and with no residue — // the mechanism behind the "unity is bit-identical to the shipped Preserve read" gate. // 2. the schedule tracks the rate: over N output frames the cursor consumes N*rate source // frames to within one, at rates either side of unity and at irrational ones. // 3. the per-output-frame feed count never exceeds kMaxFeedPerFrame — the bound that makes // a variable-length feed loop RT-safe. // 4. the clamp: out-of-range folds to the bounds, unusable input folds to unity (never to a // silent stall or a quarter-speed surprise). // 5. the sustain loop wraps the cursor and never lets it leave [start, end) — "loop the // source" holds at every rate, including one that steps over the loop end. #include "../src/core/instrument/engine/time_stretch.h" #include #include #include using namespace reasampler::instrument::engine; using reasampler::instrument::engine::loop::ResolvedLoop; 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 ResolvedLoop noLoop() { return ResolvedLoop{}; } static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) { ResolvedLoop lp; lp.active = true; lp.start = start; lp.end = end; lp.length = end - start; return lp; } // --- 1. Unity is exactly one frame per output frame, with no drifting residue. --- static void testUnityRateFeedsExactlyOneFramePerOutputFrame() { StretchCursor c; c.start(100); const ResolvedLoop lp = noLoop(); for (std::int64_t i = 0; i < 200000; ++i) { CHECK(c.due(1.0) == 1); CHECK(c.next(lp) == 100 + i); } // No accumulated debt after 200k frames: the source frame the cursor is about to feed is // exactly the one an un-stretched integer walk would be at. A residue of even one frame // over a long note would move the shipped Preserve output. CHECK(c.frame() == 100 + 200000); } // --- 2. The schedule tracks the rate. --- static void testTotalConsumedTracksTheRate() { const ResolvedLoop lp = noLoop(); // Includes a rate with no exact binary representation, where a naive per-frame rounding // would drift without bound rather than carrying the residue. for (double rate : {0.5, 0.75, 1.0, 1.3333333333333333, 2.0, 1.0 / 3.0 + 1.0}) { StretchCursor c; c.start(0); const std::int64_t outFrames = 100000; for (std::int64_t i = 0; i < outFrames; ++i) { const std::int64_t due = c.due(rate); for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp); } const double expected = static_cast(outFrames) * clampStretchRate(rate); CHECK(std::fabs(static_cast(c.frame()) - expected) <= 1.0); } } // --- 3. The feed count is bounded — the RT-safety argument for a variable-length loop. --- static void testFeedPerOutputFrameIsBounded() { const ResolvedLoop lp = noLoop(); // Drive at, above and around the ceiling; an unclamped rate would run the caller's loop // for as many iterations as the rate names. for (double rate : {kStretchRateMax, kStretchRateMax * 100.0, 3.99, 2.5}) { StretchCursor c; c.start(0); std::int64_t worst = 0; for (std::int64_t i = 0; i < 20000; ++i) { const std::int64_t due = c.due(rate); if (due > worst) worst = due; for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp); } CHECK(worst <= kMaxFeedPerFrame); CHECK(worst >= 1); // and the bound is not vacuous — frames genuinely fell due } } // --- 4. The clamp. --- static void testRateClamp() { CHECK(clampStretchRate(1.0) == 1.0); // exact: the unity read depends on it CHECK(clampStretchRate(0.5) == 0.5); CHECK(clampStretchRate(2.0) == 2.0); CHECK(clampStretchRate(0.001) == kStretchRateMin); CHECK(clampStretchRate(1000.0) == kStretchRateMax); // Unusable input plays at speed rather than stalling or quarter-speeding. CHECK(clampStretchRate(0.0) == 1.0); CHECK(clampStretchRate(-2.0) == 1.0); CHECK(clampStretchRate(std::nan("")) == 1.0); // ...and the cursor honours it rather than looping on the raw value. StretchCursor c; c.start(0); CHECK(c.due(-5.0) == 1); // folded to unity StretchCursor d; d.start(0); CHECK(d.due(50.0) <= kMaxFeedPerFrame); } // --- 5. The loop wraps the SOURCE cursor, at every rate. --- static void testCursorStaysInsideTheLoopSpan() { const ResolvedLoop lp = loopSpan(1000, 1040); // a 40-frame loop: rate 4 steps 10% of it for (double rate : {0.5, 1.0, 2.0, 4.0}) { StretchCursor c; c.start(1000); std::int64_t lowest = 1 << 30, highest = -1; for (std::int64_t i = 0; i < 50000; ++i) { const std::int64_t due = c.due(rate); for (std::int64_t k = 0; k < due; ++k) { const std::int64_t q = c.next(lp); if (q < lowest) lowest = q; if (q > highest) highest = q; } } // Never reads outside the span — the "loop the source, shift the output" contract does // not weaken under a stretch, because the span is a source-frame fact. CHECK(lowest >= lp.start); CHECK(highest < lp.end); CHECK(highest == lp.end - 1); // and it genuinely covered the span CHECK(lowest == lp.start); } // A cursor started BEYOND the loop end (the start-point-past-the-loop case) is pulled in on // its first take rather than reading off the end. StretchCursor c; c.start(5000); const std::int64_t q = c.next(lp); CHECK(q >= lp.start && q < lp.end); } int main() { testUnityRateFeedsExactlyOneFramePerOutputFrame(); testTotalConsumedTracksTheRate(); testFeedPerOutputFrameIsBounded(); testRateClamp(); testCursorStaysInsideTheLoopSpan(); if (g_fail == 0) { std::printf("all time_stretch tests passed\n"); return 0; } std::printf("%d time_stretch check(s) failed\n", g_fail); return 1; }