Files
reasampler/tests/test_time_stretch.cpp
T
daniel 589a8e078b Γ-W1-T5: a real Preserve time-stretcher — write rate is duration, tap rate is pitch
Generalizes the correlation-aligned SOLA delay line so the feed and the shift are
independent rates over one ring. Unity is bit-identical to the shipped read, asserted
against a hash baseline captured pre-change.
2026-08-02 13:47:19 -04:00

156 lines
6.3 KiB
C++

// 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 <cmath>
#include <cstdio>
#include <vector>
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<double>(outFrames) * clampStretchRate(rate);
CHECK(std::fabs(static_cast<double>(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;
}