Γ-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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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