Γ-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");
|
||||
|
||||
Reference in New Issue
Block a user