Γ-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:
2026-08-01 19:06:06 -04:00
parent e589addc54
commit 589a8e078b
10 changed files with 873 additions and 71 deletions
+169
View File
@@ -23,6 +23,10 @@
// 6. unity + latency contract — asserted bit-exactly: a warm()ed shifter at ratio 1.0 IS a
// clean window delay; a prime()d one has ZERO added latency (out[i] == src[i] to the
// bit) — the GA2 immediate-onset claim.
// 9. time-stretch — the write rate (duration) and the tap rate (pitch) are independent: a
// source fed faster/slower than the output runs moves along the output timeline with its
// pitch untouched, composes with the full transposition range, and never resamples to do
// it. A resampled read is the explicit non-tautology witness in the duration test.
// 8. stereo linked lag (Q-W0 T1-01) — a follower channel driven via processLinked() mirrors
// the master's splice decision (jump/lag/frac/fadeLen AND firing frame) exactly, on
// decorrelated stereo content where an independent per-channel search provably diverges.
@@ -581,6 +585,168 @@ static void testStereoLinkedLagSharedSchedule() {
CHECK(!mirrorDiverged); // applySplice() reproduces splice() bit-identically
}
// --- 9. Time-stretch: the WRITE rate is duration, the TAP rate is pitch, and they are
// independent. Feeding faster/slower than the output runs moves the content along the
// output timeline WITHOUT moving its pitch — no resampling anywhere, which is what
// "WDL_Resampler is not a Preserve engine" asks for. ---
// Drives the shifter with a fractional feed rate the way the Voice does: all but the last
// source frame due on an output frame go through writeFrame (no output), the last through
// process(); an output frame with none due takes processNoInput(). Returns the output plus,
// via `consumed`, how much source it ate.
static std::vector<double> runStretch(const std::vector<AudioSample>& src, std::int64_t w,
double feedRate, double shift, std::size_t outFrames,
std::size_t* consumed) {
PitchShifter ps;
ps.configure(w);
ps.prime(src.data(), w);
ps.setShiftRatio(shift);
ps.setFeedRate(feedRate);
std::size_t pos = static_cast<std::size_t>(w);
double debt = 0.0;
std::vector<double> out(outFrames);
for (std::size_t i = 0; i < outFrames; ++i) {
debt += feedRate;
const int due = static_cast<int>(debt);
debt -= static_cast<double>(due);
AudioSample last = 0.0f;
bool fed = false;
for (int k = 0; k < due; ++k) {
if (fed) ps.writeFrame(last);
last = pos < src.size() ? src[pos] : 0.0f;
++pos;
fed = true;
}
out[i] = static_cast<double>(fed ? ps.process(last) : ps.processNoInput());
}
if (consumed != nullptr) *consumed = pos - static_cast<std::size_t>(w);
return out;
}
// Mean spacing between positive-going zero crossings over [from, to).
static double periodIn(const std::vector<double>& v, std::size_t from, std::size_t to) {
double sum = 0.0;
std::size_t prev = 0, count = 0;
for (std::size_t i = from + 1; i < to; ++i) {
if (v[i - 1] <= 0.0 && v[i] > 0.0) {
if (count > 0) sum += static_cast<double>(i - prev);
prev = i;
++count;
}
}
return count > 1 ? sum / static_cast<double>(count - 1) : 0.0;
}
static void testStretchMovesDurationNotPitch() {
// A source that changes pitch ONCE, at a known source frame: period 200 before it, period
// 100 after. Where that change lands in the OUTPUT is duration; what the two periods
// measure is pitch. A stretcher moves the first and not the second; a resampled read moves
// both, which is exactly the distinction under test.
const std::int64_t w = 2205;
const std::size_t change = 40000; // source frame where the period halves
const std::size_t srcLen = 160000;
std::vector<AudioSample> src(srcLen);
double phase = 0.0;
for (std::size_t i = 0; i < srcLen; ++i) {
phase += 2.0 * kPi / (i < change ? 200.0 : 100.0);
src[i] = static_cast<AudioSample>(std::sin(phase));
}
for (double rate : {0.5, 1.0, 2.0}) {
// Duration: the source is consumed at the feed rate, so the change lands at
// change/rate in the output — the run is sized to reach past it at every rate.
const std::size_t changeOut = static_cast<std::size_t>(change / rate);
const std::size_t outFrames = changeOut + 12000;
std::size_t consumed = 0;
const std::vector<double> out =
runStretch(src, w, rate, /*shift=*/1.0, outFrames, &consumed);
// Pitch: measured well clear of the transition on both sides, and UNCHANGED by the
// rate — 200 before, 100 after, at 0.5x, 1x and 2x alike.
const double before = periodIn(out, changeOut / 4, changeOut / 4 + 6000);
const double after = periodIn(out, changeOut + 2000, changeOut + 8000);
CHECK(approx(before, 200.0, 10.0));
CHECK(approx(after, 100.0, 5.0));
// Non-tautology witness: a RESAMPLED read of the same source at the same rate would
// have produced 200/rate and 100/rate here. At rate != 1 those differ from the
// measurements above by far more than the tolerances, so the assertions genuinely
// separate a stretch from a resample.
if (rate != 1.0) {
CHECK(std::fabs(before - 200.0 / rate) > 20.0);
CHECK(std::fabs(after - 100.0 / rate) > 20.0);
}
// ...and the source really was consumed at the rate (the duration half of the claim).
CHECK(approx(static_cast<double>(consumed),
static_cast<double>(outFrames) * rate, 2.0));
// No dead stretches anywhere, frame 0 included: the stretch path must not reintroduce
// the onset gap prime() exists to close.
std::size_t worstGap = 0, run = 0;
for (std::size_t i = 0; i < outFrames; ++i) {
if (std::fabs(out[i]) < 1e-3) {
++run;
if (run > worstGap) worstGap = run;
} else {
run = 0;
}
}
CHECK(worstGap < 32);
}
}
// The stretch and the transposition compose over the SAME ring, and a feed rate the shift does
// not match is where the splice fade's headroom is tightest (the outgoing tap closes on the
// writer at ratio - feedRate, which setFeedRate exists to tell it). Bounded, finite, gap-free
// across the corners of the engine's rate range crossed with the full transposition range.
static void testStretchAndShiftComposeSafely() {
const std::int64_t w = 2205;
const double f0 = 1.0 / 196.37; // the adversarial non-integer period
const std::size_t srcLen = 400000;
std::vector<AudioSample> src(srcLen);
for (std::size_t i = 0; i < srcLen; ++i) {
src[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * f0 * static_cast<double>(i)));
}
for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) {
for (double semis : {-24.0, -12.0, -5.0, 0.0, 7.0, 12.0, 24.0}) {
const double shift = std::pow(2.0, semis / 12.0);
const std::size_t outFrames = 60000;
const std::vector<double> out =
runStretch(src, w, rate, shift, outFrames, nullptr);
std::size_t worstGap = 0, run = 0;
double peak = 0.0;
for (std::size_t i = 0; i < outFrames; ++i) {
CHECK(std::isfinite(out[i]));
const double a = std::fabs(out[i]);
if (a > peak) peak = a;
if (a < 1e-3) {
++run;
if (run > worstGap) worstGap = run;
} else {
run = 0;
}
}
CHECK(worstGap < 32); // continuous: every splice landed in real, aligned history
CHECK(peak < 1.2); // complementary fades: no cancellation, no bulge
CHECK(peak > 0.8); // ...and it played at full level
// Pitch is the TAP's, not the feed's: the observed period is the source period
// divided by the shift, whatever the rate.
const double p = periodIn(out, 20000, 50000);
if (!approx(p, 196.37 / shift, 196.37 / shift * 0.12)) {
std::printf(" rate %.2f semis %.0f: period %.2f want %.2f\n", rate, semis, p,
196.37 / shift);
}
CHECK(approx(p, 196.37 / shift, 196.37 / shift * 0.12));
}
}
}
// The two new entry points on a shifter that was never configured (a Varispeed voice's) —
// neither may touch the empty ring.
static void testStretchEntryPointsOnPassThrough() {
PitchShifter ps;
CHECK(!ps.configured());
ps.writeFrame(0.5f); // no ring to write into
CHECK(ps.processNoInput() == 0.0f); // no input to pass through
CHECK(ps.process(0.25f) == 0.25f); // and the 1:1 path still passes through
}
int main() {
testDurationInvariance();
testUnityRoughlyReproduces();
@@ -590,6 +756,9 @@ int main() {
testUnityBitExactAndLatency();
testFreezeTailContinuousTone();
testStereoLinkedLagSharedSchedule();
testStretchMovesDurationNotPitch();
testStretchAndShiftComposeSafely();
testStretchEntryPointsOnPassThrough();
if (g_fail == 0) {
std::printf("all pitch_shift tests passed\n");
+307
View File
@@ -21,7 +21,10 @@
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <vector>
using namespace reasampler;
@@ -2880,6 +2883,303 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() {
CHECK(blockPeak(out, 0, frames) > 0.5); // and it genuinely played at full level
}
// ---------------------------------------------------------------------------
// The Preserve read path's stretch generalization: the source is consumed at the playback
// rate while the shifter's read tap runs at the transposition, over ONE delay ring. Only
// their DIFFERENCE reaches the splice machinery.
// ---------------------------------------------------------------------------
// FNV-1a over the raw float bits — an exact-stream witness, not a tolerance.
static std::uint64_t hashStream(const std::vector<AudioSample>& v) {
std::uint64_t h = 1469598103934665603ull;
for (const AudioSample s : v) {
std::uint32_t bits = 0;
std::memcpy(&bits, &s, sizeof(bits));
for (int b = 0; b < 4; ++b) {
h ^= static_cast<std::uint64_t>((bits >> (8 * b)) & 0xffu);
h *= 1099511628211ull;
}
}
return h;
}
// A source with no symmetry a shifter could accidentally satisfy: a sine at a non-integer
// period plus a deterministic pseudo-random dither, so any change in the splice schedule,
// the fed frame sequence or the tap position moves the hash.
static SampleData stretchProbeSample(std::size_t frames, bool stereo) {
SampleData s;
s.frames.resize(frames);
if (stereo) s.framesR.resize(frames);
std::uint32_t lcg = 12345u;
for (std::size_t i = 0; i < frames; ++i) {
lcg = lcg * 1664525u + 1013904223u;
const double n = static_cast<double>(lcg >> 8) / 8388608.0 - 1.0; // [-1,1)
const double t = static_cast<double>(i);
s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * t / 196.37) + 0.1 * n);
if (stereo) {
s.framesR[i] =
static_cast<float>(0.8 * std::sin(2.0 * kPi * t / 123.13) - 0.1 * n);
}
}
s.rootNote = 60;
s.sampleRate = 44100;
s.play.adsr = flatAdsr();
s.play.pitchEngine = PitchEngine::Preserve;
return s;
}
// Renders one raw Voice (not through VoiceEngine, which publishes no rate) for `outFrames`.
static void renderVoice(const SampleData& s, int note, double rate, std::int64_t window,
bool stereo, std::vector<AudioSample>& l, std::vector<AudioSample>& r) {
Voice v;
v.presizePreserveShifters(window);
v.start(note, 127, s, /*declickTakeover=*/false, rate);
for (std::size_t i = 0; i < l.size(); ++i) {
if (stereo) {
AudioSample a = 0.0f, b = 0.0f;
v.renderFrameStereo(a, b);
l[i] = a;
r[i] = b;
} else {
l[i] = v.renderFrame();
}
}
}
// --- The null case, asserted against a baseline the SHIPPED engine produced. ---
// The four constants below were captured by running this same function against the
// pre-stretch build (phase-g, before the rate seam existed) and printing the hashes; they are
// therefore a witness that the generalized read path reproduces the shipped Preserve output
// bit for bit at rate 1.0, not a self-consistency check. A change here is a change to what
// every already-saved project sounds like — re-derive the cause before re-baselining.
static void testPreserveUnityRateIsBitIdenticalToTheShippedRead() {
const std::int64_t w = 2205; // the product window at 44.1k
const std::size_t n = 6000;
struct Case {
int note;
bool stereo;
bool loop;
std::uint64_t hashL;
std::uint64_t hashR;
};
const Case cases[] = {
{60, false, false, 16118581538698271917ull, 0ull}, // on root: unity shift
{67, false, false, 17268489061432447375ull, 0ull}, // +7 st: real splices
{55, false, false, 17626155132441637249ull, 0ull}, // -5 st: down-shift
{67, true, true, 116487689553455907ull, 9528575457480122654ull}, // stereo linked + loop
};
for (const Case& c : cases) {
SampleData s = stretchProbeSample(4000, c.stereo);
if (c.loop) {
s.loop.hasLoop = true;
s.loop.start = 1200;
s.loop.end = 3600;
s.loopCrossfadeFrames = 256;
}
std::vector<AudioSample> l(n), r(c.stereo ? n : 0);
renderVoice(s, c.note, /*rate=*/1.0, w, c.stereo, l, r);
const std::uint64_t hl = hashStream(l);
CHECK(hl == c.hashL);
if (hl != c.hashL) std::printf(" note %d L hash %lluull\n", c.note, hl);
if (c.stereo) {
const std::uint64_t hr = hashStream(r);
CHECK(hr == c.hashR);
if (hr != c.hashR) std::printf(" note %d R hash %lluull\n", c.note, hr);
}
}
}
// --- Rate changes DURATION only; the transposition alone sets pitch. ---
static void testPreserveStretchChangesDurationNotPitch() {
// Gate, no loop: the voice's life is exactly how long the source lasts, so the frame at
// which it goes idle IS the note's duration.
const std::int64_t w = 1024;
const std::size_t frames = 24000;
const double srcPeriod = 160.0;
SampleData s;
s.frames.resize(frames);
for (std::size_t i = 0; i < frames; ++i) {
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / srcPeriod));
}
s.rootNote = 60;
s.play.adsr = flatAdsr();
s.play.pitchEngine = PitchEngine::Preserve;
auto run = [&](double rate, PitchEngine engine, std::size_t& lifeFrames) {
SampleData local = s;
local.play.pitchEngine = engine;
Voice v;
v.presizePreserveShifters(w);
v.start(60, 127, local, /*declickTakeover=*/false, rate);
std::vector<AudioSample> out;
out.reserve(frames * 3);
lifeFrames = 0;
for (std::size_t i = 0; i < frames * 3 && v.active(); ++i) {
out.push_back(v.renderFrame());
++lifeFrames;
}
return out;
};
std::size_t lifeUnity = 0, lifeSlow = 0, lifeFast = 0;
const std::vector<AudioSample> unity = run(1.0, PitchEngine::Preserve, lifeUnity);
const std::vector<AudioSample> slow = run(0.5, PitchEngine::Preserve, lifeSlow);
const std::vector<AudioSample> fast = run(2.0, PitchEngine::Preserve, lifeFast);
// Duration scales by 1/rate (the small excess over the source length is the terminal
// declick ring-out Preserve ends on).
CHECK(approx(static_cast<double>(lifeUnity), 24000.0, 200.0));
CHECK(approx(static_cast<double>(lifeSlow), 48000.0, 400.0));
CHECK(approx(static_cast<double>(lifeFast), 12000.0, 200.0));
// ...and the pitch does not move with it. Measured away from the onset and the tail.
auto period = [](const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
double sum = 0.0;
std::size_t prev = 0, count = 0;
for (std::size_t i = from + 1; i < to && i < v.size(); ++i) {
if (v[i - 1] <= 0.0f && v[i] > 0.0f) {
if (count > 0) sum += static_cast<double>(i - prev);
prev = i;
++count;
}
}
return count > 1 ? sum / static_cast<double>(count - 1) : 0.0;
};
CHECK(approx(period(unity, 2000, 9000), srcPeriod, 8.0));
CHECK(approx(period(slow, 2000, 9000), srcPeriod, 8.0));
CHECK(approx(period(fast, 2000, 9000), srcPeriod, 8.0));
// The non-tautology witness: VARISPEED is the engine that couples them. Reaching the same
// durations there costs exactly the pitch change Preserve refuses to make — so the three
// equal periods above are a property of the stretcher, not of the measurement.
std::size_t lifeVari = 0;
const std::vector<AudioSample> vari = run(0.5, PitchEngine::Varispeed, lifeVari);
CHECK(approx(static_cast<double>(lifeVari), 24000.0, 200.0)); // rate ignored under Varispeed
SampleData down = s;
down.play.pitchEngine = PitchEngine::Varispeed;
Voice vv;
vv.presizePreserveShifters(w);
vv.start(48, 127, down); // -12 st under Varispeed: duration doubles AND pitch halves
std::vector<AudioSample> variDown;
std::size_t variLife = 0;
for (std::size_t i = 0; i < frames * 3 && vv.active(); ++i) {
variDown.push_back(vv.renderFrame());
++variLife;
}
CHECK(approx(static_cast<double>(variLife), 48000.0, 200.0)); // same duration...
CHECK(approx(period(variDown, 2000, 9000), srcPeriod * 2.0, 16.0)); // ...at half pitch
}
// --- The onset is a regression surface: no added latency at ANY rate. ---
static void testPreserveStretchSpeaksOnFrameZeroAtEveryRate() {
const std::int64_t w = 2048;
SampleData s = stretchProbeSample(12000, false);
s.startFrame = 500; // and the first output frame is the START frame, not frame 0
for (double rate : {0.5, 1.0, 2.0}) {
for (int note : {48, 60, 67}) {
Voice v;
v.presizePreserveShifters(w);
v.start(note, 127, s, /*declickTakeover=*/false, rate);
const AudioSample first = v.renderFrame();
// The primed ring parks the tap ON the start frame, so output frame 0 is source
// frame `startFrame` exactly — at every rate and every transposition. A stretcher
// that buffered a window before speaking would fail here, which is the whole point.
CHECK(first == s.frames[500]);
// ...and it keeps speaking: no first-window dip while the schedule settles. The
// 256-frame measuring window spans most of a period even at the lowest note tested
// (-12 st stretches the probe's 196-frame period to 393), so a continuous tone
// peaks well above the floor in every one of them and only a real gap can sink it.
double lo = 1e9;
for (int i = 0; i < 20; ++i) {
double peak = 0.0;
for (int k = 0; k < 256; ++k) {
peak = (std::max)(peak, std::fabs(static_cast<double>(v.renderFrame())));
}
lo = (std::min)(lo, peak);
}
if (!(lo > 0.5)) std::printf(" rate %.2f note %d: lo %.3f\n", rate, note, lo);
CHECK(lo > 0.5);
}
}
}
// --- "Loop the source, shift the output" is unweakened by a stretch. ---
static void testPreserveStretchLoopsTheSourceSpan() {
for (double rate : {0.5, 1.0, 2.0}) {
for (int note : {48, 60, 72}) {
SampleData s;
s.frames.resize(200, 0.0f);
for (int i = 60; i < 120; ++i) s.frames[i] = 0.5f;
s.rootNote = 60;
s.sampleRate = 48000;
s.loop.hasLoop = true;
s.loop.start = 80;
s.loop.end = 120;
s.play.adsr = flatAdsr();
s.play.pitchEngine = PitchEngine::Preserve;
Voice v;
v.presizePreserveShifters(64);
v.start(note, 127, s, /*declickTakeover=*/false, rate);
std::vector<AudioSample> out(4000);
for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame();
// The loop is a SOURCE-frame fact, so it keeps the voice alive and at level for as
// long as it is held, whatever the rate consumes it at.
CHECK(v.active());
double sum = 0.0;
for (std::size_t i = out.size() - 200; i < out.size(); ++i) sum += out[i];
CHECK(approx(sum / 200.0, 0.5, 0.05));
}
}
}
// --- The 32-voice measurement gate. Asserts correctness; PRINTS the cost, which is the
// number reported for the algorithm decision (meaningful only in a Release build). ---
static void testPreserveStretchThirtyTwoVoicesHoldUp() {
const std::int64_t w = 2205; // the product window at 44.1k
const std::size_t blockFrames = 44100; // one second of audio
const std::size_t voiceCount = 32;
SampleData s = stretchProbeSample(200000, true);
s.loop.hasLoop = true; // held notes: all 32 sound for the whole run
s.loop.start = 40000;
s.loop.end = 160000;
s.loopCrossfadeFrames = 1024;
// 1.0 is the reference: it is the cost the shipped Preserve read already carries, so the
// two stretched rows are read as a delta against it rather than in isolation.
for (double rate : {1.0, 0.5, 2.0}) {
std::vector<Voice> voices(voiceCount);
for (std::size_t i = 0; i < voiceCount; ++i) {
voices[i].presizePreserveShifters(w);
voices[i].start(48 + static_cast<int>(i), 100, s, /*declickTakeover=*/false, rate);
}
const std::clock_t t0 = std::clock();
double guard = 0.0;
std::size_t sounding = 0;
for (std::size_t f = 0; f < blockFrames; ++f) {
AudioSample l = 0.0f, r = 0.0f;
for (std::size_t i = 0; i < voiceCount; ++i) {
AudioSample a = 0.0f, b = 0.0f;
voices[i].renderFrameStereo(a, b);
l += a;
r += b;
}
guard += static_cast<double>(l) + static_cast<double>(r);
CHECK(std::isfinite(l) && std::isfinite(r));
}
const double secs = static_cast<double>(std::clock() - t0) / CLOCKS_PER_SEC;
for (std::size_t i = 0; i < voiceCount; ++i) {
if (voices[i].active()) ++sounding;
}
CHECK(sounding == voiceCount); // all 32 held the whole second (the loop kept them up)
CHECK(std::fabs(guard) > 0.0); // ...and genuinely produced audio
std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: %.3f s wall for 1.0 s "
"audio (%.1f%% of one core, %.1f ns/voice/frame)\n",
rate, secs, 100.0 * secs,
secs * 1e9 / (static_cast<double>(blockFrames) *
static_cast<double>(voiceCount)));
}
}
int main() {
testEveryKeyPlaysTheLoadedCapture();
testUnplayableCaptureRefusesEveryNote();
@@ -3006,6 +3306,13 @@ int main() {
testPreservePrimeStopsAtTriggerPlayEnd();
testPreserveSubWindowSampleNoZeroPadInRing();
// The Preserve read path's stretch generalization.
testPreserveUnityRateIsBitIdenticalToTheShippedRead();
testPreserveStretchChangesDurationNotPitch();
testPreserveStretchSpeaksOnFrameZeroAtEveryRate();
testPreserveStretchLoopsTheSourceSpan();
testPreserveStretchThirtyTwoVoicesHoldUp();
if (g_fail == 0) {
std::printf("all sampler_core tests passed\n");
return 0;
+155
View File
@@ -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;
}