Γ-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.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user