Merge Γ-W1-T5: a real Preserve time-stretcher — write rate is duration, tap rate is pitch

This commit is contained in:
2026-08-01 21:37:48 -04:00
12 changed files with 1837 additions and 74 deletions
+50
View File
@@ -0,0 +1,50 @@
#pragma once
// Out-of-band spectral energy metric: the same period-grid, Hann-windowed direct-evaluation
// approach as test_preserve_low_frequency.cpp's reportSpectrum. Chosen over zero-crossing
// counting because splice debris adds spurious crossings that make that estimator
// anti-correlated with severity (a render can read a badly wrong PERIOD while this metric
// shows it is mostly clean, or vice versa). Grid/segment sizes are smaller than the hand-run
// harness's — this one runs inside the gated suite.
#include <cmath>
#include <cstddef>
#include <vector>
namespace reasampler::test_support {
// Percentage (0..100) of the segment [from, from+len)'s spectral energy that falls outside
// +/- 6% of `wantPeriod` (frames). 0 = a clean single tone at that period; higher values mean
// harmonics, splice-cadence sidebands, or crossfade cancellation debris are present.
inline double energyOutsideFundamentalPercent(const std::vector<double>& v, std::size_t from,
std::size_t len, double wantPeriod) {
constexpr double kPi = 3.14159265358979323846;
constexpr int kGrid = 400;
const double pLo = 30.0, pHi = 8000.0;
std::vector<double> mag(static_cast<std::size_t>(kGrid));
std::vector<double> per(static_cast<std::size_t>(kGrid));
for (int g = 0; g < kGrid; ++g) {
// Geometric grid: constant relative resolution across the swept period range.
const double p = pLo * std::pow(pHi / pLo, static_cast<double>(g) / (kGrid - 1));
per[static_cast<std::size_t>(g)] = p;
double re = 0.0, im = 0.0;
const double w = 2.0 * kPi / p;
for (std::size_t k = 0; k < len && from + k < v.size(); ++k) {
const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast<double>(k) /
static_cast<double>(len)));
const double x = v[from + k] * hann;
re += x * std::cos(w * static_cast<double>(k));
im += x * std::sin(w * static_cast<double>(k));
}
mag[static_cast<std::size_t>(g)] = std::sqrt(re * re + im * im);
}
double eTotal = 0.0, eFund = 0.0;
for (int g = 0; g < kGrid; ++g) {
const std::size_t i = static_cast<std::size_t>(g);
const double e = mag[i] * mag[i];
eTotal += e;
if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e;
}
return eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0;
}
} // namespace reasampler::test_support
+237
View File
@@ -23,11 +23,16 @@
// 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.
#include "../src/core/instrument/engine/pitch_shift.h"
#include "energy_outside_fundamental.h"
#include <cmath>
#include <cstdio>
@@ -581,6 +586,234 @@ 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 [0.5, 2.0] rate bound (time_stretch.h) narrows the splice-cadence failure onto the
// source fundamental rather than eliminating it. At rate 2.0, shift 0.25 (-24 st) — both
// inside the shipped range — the header's own derivation puts the safe-source floor at a
// period of 315 frames (~140 Hz @ 44.1k): P=500/600/700 sit above that floor, on purpose,
// asserting the corner rather than assuming it. Zero-crossing period is NOT the right
// observable here: an investigation (test_preserve_low_frequency.cpp) found the P=500
// render's FUNDAMENTAL within 0.03% of target by autocorrelation and spectral peak alike,
// while the zero-crossing estimator read 23% flat — splice debris adds spurious crossings
// the count cannot tell from a real detune. Energy outside the fundamental tracks the actual
// damage instead: measured here (same rate/shift/source, this file's own metric parameters)
// at 7.31% / 14.41% / 21.22% for P=500/600/700, against 0.10% on an alignable control (P=200,
// below the safe floor) at the same rate and shift — so that is what this asserts: a known,
// characterised property of the range, not a pass/fail on a period estimate. A failure on
// either bound below is a finding — report it, don't retune the thresholds to hide it.
static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() {
using reasampler::test_support::energyOutsideFundamentalPercent;
const std::int64_t w = 2205;
const double rate = 2.0;
const double shift = std::pow(2.0, -24.0 / 12.0); // 0.25
const std::size_t outFrames = 60000;
const std::size_t from = 20000, len = 32768;
// Below the safe floor (P > 315 frames): the cadence inequality predicts real damage,
// measured at 7-21% (see above). The threshold (5%) sits above the alignable control's
// near-zero floor and under the observed range, so it discriminates a genuine cadence hit
// from a clean render; the ceiling (30%) is a generous margin above the highest measured
// value, there to catch a much worse regression rather than to chase today's exact number.
for (double period : {500.0, 600.0, 700.0}) {
const double f0 = 1.0 / 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)));
}
const std::vector<double> out = runStretch(src, w, rate, shift, outFrames, nullptr);
for (double v : out) CHECK(std::isfinite(v));
const double want = period / shift;
const double energyPct = energyOutsideFundamentalPercent(out, from, len, want);
std::printf(" [cadence corner] period %.0f (rate 2.0, -24 st): energy outside "
"fundamental %.2f%% (want period %.1f fr)\n", period, energyPct, want);
CHECK(energyPct > 5.0);
CHECK(energyPct < 30.0);
}
// The alignable control: same rate/shift, a source period (200 < 315) the cadence
// inequality does not reach. Without this, a future change that raised the noise floor
// EVERYWHERE (not just at this corner) would still read "under 30%" above and slide
// through — this is what catches that case.
{
const double period = 200.0;
const double f0 = 1.0 / 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)));
}
const std::vector<double> out = runStretch(src, w, rate, shift, outFrames, nullptr);
for (double v : out) CHECK(std::isfinite(v));
const double want = period / shift;
const double energyPct = energyOutsideFundamentalPercent(out, from, len, want);
std::printf(" [alignable control] period %.0f (rate 2.0, -24 st): energy outside "
"fundamental %.2f%% (want period %.1f fr)\n", period, energyPct, want);
CHECK(energyPct < 5.0);
}
}
// 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 +823,10 @@ int main() {
testUnityBitExactAndLatency();
testFreezeTailContinuousTone();
testStereoLinkedLagSharedSchedule();
testStretchMovesDurationNotPitch();
testStretchAndShiftComposeSafely();
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
testStretchEntryPointsOnPassThrough();
if (g_fail == 0) {
std::printf("all pitch_shift tests passed\n");
+700
View File
@@ -0,0 +1,700 @@
// Measurement harness for the Preserve engine's behaviour on LOW-FREQUENCY material.
// Reports numbers; it renders no perceptual verdict and changes no DSP.
//
// The question it answers: a splice relocates the read tap by a nominal `window` refined by a
// correlation search over +/- maxLag, so the reachable relocation distances form ONE bounded
// interval. Phase-aligning a splice needs a WHOLE NUMBER OF SOURCE PERIODS inside that
// interval, and for some periods none exists — a geometric limit, separate from the
// splice-CADENCE inequality. Both now sit in time_stretch.h; this is what measured them.
//
// Two findings shaped the sections below and are worth knowing before reading the output:
// whether an unreachable multiple accumulates into a DETUNE or only wobbles the phase depends
// on whether the nearest multiple misses on one side or straddles (a straddle cancels in the
// mean); and PITCH IS THE WRONG THING TO MEASURE HERE — the fundamental usually survives, so
// the load-bearing metric is energy outside it. Zero-crossing counting in particular reports a
// wrong period on renders whose fundamental is provably correct, which is section C.
//
// Measures, at both 44.1k and 48k geometry:
// A. the reachable relocation interval, observed rather than derived (jump/lag/frac off
// every SpliceEvent), and the alignment-reachability predicate over frequency.
// B. Voice-level renders at 30 Hz: the shipped default path, then transposition at rate 1.0
// and rate 0.5/2.0 with none, then a 20-200 Hz sweep to locate the turnover.
// C. the P=500-frame (~88 Hz) case the pitch_shift floor probe fails on, measured with three
// independent pitch estimators to separate the two mechanisms from a measurement artifact.
// D. a window sweep at 30 Hz — what a larger window would buy, and what it would cost.
// E. alignable frequencies under identical conditions, without which D and B have no scale.
#include "../src/core/instrument/engine/pitch_shift.h"
#include "../src/core/instrument/engine/time_stretch.h"
#include "../src/core/instrument/engine/voice.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <utility>
#include <vector>
using namespace reasampler;
using namespace reasampler::instrument::engine;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
constexpr double kPi = 3.14159265358979323846;
// ---------------------------------------------------------------------------------------
// Source + render helpers
// ---------------------------------------------------------------------------------------
// A pure sine at `freqHz`, phase-continuous, long enough that a rate-2.0 render never
// exhausts it (the caller sizes `frames`).
static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames,
PitchEngine engine, double phase = 0.0) {
SampleData s;
s.frames.resize(frames);
const double w = 2.0 * kPi * freqHz / static_cast<double>(sampleRate);
for (std::size_t i = 0; i < frames; ++i) {
s.frames[i] = static_cast<AudioSample>(std::sin(w * static_cast<double>(i) + phase));
}
s.sampleRate = sampleRate;
s.rootNote = 60;
s.play.pitchEngine = engine; // Gate, no loop, default (fully open) AHDSR
return s;
}
// One note through the REAL Voice: presize (off-thread step), start with the stretch rate,
// then pull `outFrames` mono frames. `note - 60` is the transposition in semitones.
static std::vector<double> renderVoice(const SampleData& s, int note, double stretchRate,
std::int64_t window, std::size_t outFrames) {
Voice v;
v.presizePreserveShifters(window);
v.start(note, 127, s, /*declickTakeover=*/false, stretchRate);
std::vector<double> out(outFrames, 0.0);
for (std::size_t i = 0; i < outFrames; ++i) {
out[i] = static_cast<double>(v.renderFrame());
}
return out;
}
// ---------------------------------------------------------------------------------------
// Metrics
// ---------------------------------------------------------------------------------------
// Mean spacing between positive-going zero crossings over [from, to) — the same estimator
// test_pitch_shift.cpp uses, kept identical so the two files' numbers are comparable.
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 < v.size(); ++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;
}
// Least-squares fit of a single tone at `cyclesPerFrame` over [from, from+len): returns the
// fitted phase and writes the residual energy fraction (1 - explained), which is the
// single-tone-purity metric — 0 = a perfect sine at that frequency, 1 = none of the energy
// is there. Robust to amplitude but NOT to phase drift within the block, which is why the
// caller keeps blocks near one period.
static double toneFit(const std::vector<double>& v, std::size_t from, std::size_t len,
double cyclesPerFrame, double* residFraction) {
double sc = 0.0, ss = 0.0, cc = 0.0, s2 = 0.0, cs = 0.0, e = 0.0;
for (std::size_t k = 0; k < len && from + k < v.size(); ++k) {
const double t = 2.0 * kPi * cyclesPerFrame * static_cast<double>(k);
const double c = std::cos(t), s = std::sin(t);
const double x = v[from + k];
sc += x * c; ss += x * s; cc += c * c; s2 += s * s; cs += c * s; e += x * x;
}
const double det = cc * s2 - cs * cs;
double a = 0.0, b = 0.0;
if (std::fabs(det) > 1e-12) {
a = (sc * s2 - ss * cs) / det;
b = (ss * cc - sc * cs) / det;
}
const double explained = a * sc + b * ss; // energy captured by the fit
if (residFraction != nullptr) *residFraction = e > 0.0 ? 1.0 - explained / e : 0.0;
return std::atan2(b, a);
}
// Total unwrapped phase drift (in CYCLES) of the render relative to an ideal tone at
// `cyclesPerFrame`, measured across [from, to) in one-period blocks. This is the direct
// observable behind "the rendered pitch is wrong": a nonzero drift IS a frequency error.
static double phaseDriftCycles(const std::vector<double>& v, std::size_t from, std::size_t to,
double cyclesPerFrame, double* worstStepCycles) {
const std::size_t blk = static_cast<std::size_t>(1.0 / cyclesPerFrame);
double total = 0.0, prev = 0.0, worst = 0.0;
bool first = true;
for (std::size_t p = from; p + blk <= to && p + blk < v.size(); p += blk) {
const double ph = toneFit(v, p, blk, cyclesPerFrame, nullptr);
if (!first) {
double d = ph - prev;
while (d > kPi) d -= 2.0 * kPi;
while (d < -kPi) d += 2.0 * kPi;
total += d / (2.0 * kPi);
if (std::fabs(d) / (2.0 * kPi) > worst) worst = std::fabs(d) / (2.0 * kPi);
}
prev = ph;
first = false;
}
if (worstStepCycles != nullptr) *worstStepCycles = worst;
return total;
}
// Median single-tone residual fraction over the render, in one-period blocks.
static double medianResidual(const std::vector<double>& v, std::size_t from, std::size_t to,
double cyclesPerFrame) {
const std::size_t blk = static_cast<std::size_t>(1.0 / cyclesPerFrame);
std::vector<double> r;
for (std::size_t p = from; p + blk <= to && p + blk < v.size(); p += blk) {
double resid = 0.0;
toneFit(v, p, blk, cyclesPerFrame, &resid);
r.push_back(resid);
}
if (r.empty()) return 0.0;
std::size_t mid = r.size() / 2;
std::nth_element(r.begin(), r.begin() + static_cast<std::ptrdiff_t>(mid), r.end());
return r[mid];
}
// Period of the highest normalized-autocorrelation peak over [minLag, maxLag] — a pitch
// estimator that, unlike zero-crossing counting, is not fooled by a low-level fast component
// adding spurious crossings. The two disagreeing is itself the diagnosis.
static double autocorrPeriod(const std::vector<double>& v, std::size_t from, std::size_t len,
std::int64_t minLag, std::int64_t maxLag) {
double e0 = 0.0;
for (std::size_t k = 0; k < len && from + k < v.size(); ++k) e0 += v[from + k] * v[from + k];
if (e0 <= 0.0) return 0.0;
double best = -1e18; std::int64_t bestLag = 0;
std::vector<double> score(static_cast<std::size_t>(maxLag - minLag + 1), 0.0);
for (std::int64_t lag = minLag; lag <= maxLag; ++lag) {
double s = 0.0, e = 0.0;
for (std::size_t k = 0; k < len && from + k + static_cast<std::size_t>(lag) < v.size();
++k) {
const double b = v[from + k + static_cast<std::size_t>(lag)];
s += v[from + k] * b;
e += b * b;
}
const double r = e > 0.0 ? s / std::sqrt(e0 * e) : 0.0;
score[static_cast<std::size_t>(lag - minLag)] = r;
if (r > best) { best = r; bestLag = lag; }
}
// Parabolic refinement so the estimate isn't quantized to whole frames.
const std::size_t i = static_cast<std::size_t>(bestLag - minLag);
double frac = 0.0;
if (i > 0 && i + 1 < score.size()) {
const double den = score[i - 1] - 2.0 * score[i] + score[i + 1];
if (den < 0.0) frac = 0.5 * (score[i - 1] - score[i + 1]) / den;
}
return static_cast<double>(bestLag) + frac;
}
// The five strongest spectral peaks over a Hann-windowed segment, scanned on a fine period
// grid (Goertzel-style direct evaluation, no FFT-bin quantization). Prints period in frames
// and magnitude relative to the strongest — the decisive "is the rendered pitch wrong, or is
// there a second component fooling the zero-crossing count" measurement.
static void reportSpectrum(const char* label, const std::vector<double>& v, std::size_t from,
std::size_t len, double wantPeriod) {
const int kGrid = 2000;
const double pLo = 30.0, pHi = 8000.0;
std::vector<double> mag(static_cast<std::size_t>(kGrid), 0.0);
std::vector<double> per(static_cast<std::size_t>(kGrid), 0.0);
for (int g = 0; g < kGrid; ++g) {
// Geometric grid: constant relative resolution across three octaves of period.
const double p = pLo * std::pow(pHi / pLo, static_cast<double>(g) / (kGrid - 1));
per[static_cast<std::size_t>(g)] = p;
double re = 0.0, im = 0.0;
const double w = 2.0 * kPi / p;
for (std::size_t k = 0; k < len && from + k < v.size(); ++k) {
const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast<double>(k) /
static_cast<double>(len)));
const double x = v[from + k] * hann;
re += x * std::cos(w * static_cast<double>(k));
im += x * std::sin(w * static_cast<double>(k));
}
mag[static_cast<std::size_t>(g)] = std::sqrt(re * re + im * im);
}
double top = 0.0;
for (double m : mag) top = std::max(top, m);
// Local maxima, ranked by MAGNITUDE (not by grid order) so the fundamental cannot be
// pushed off the list by low-level debris at a shorter period.
std::vector<std::pair<double, double>> peaks; // (magnitude, period)
for (int g = 1; g + 1 < kGrid; ++g) {
const std::size_t i = static_cast<std::size_t>(g);
if (mag[i] <= mag[i - 1] || mag[i] < mag[i + 1]) continue;
if (mag[i] < 0.02 * top) continue;
peaks.emplace_back(mag[i], per[i]);
}
std::sort(peaks.begin(), peaks.end(),
[](const std::pair<double, double>& a, const std::pair<double, double>& b) {
return a.first > b.first;
});
// Energy fraction OUTSIDE the fundamental's mainlobe — the honest "how much of this render
// is not the wanted tone" number, since a peak list alone can hide broadband debris.
double eTotal = 0.0, eFund = 0.0;
for (int g = 0; g < kGrid; ++g) {
const std::size_t i = static_cast<std::size_t>(g);
const double e = mag[i] * mag[i];
eTotal += e;
if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e;
}
std::printf(" %s spectrum (want period %.1f fr); strongest peaks >2%% of max:\n", label,
wantPeriod);
for (std::size_t k = 0; k < peaks.size() && k < 8; ++k) {
std::printf(" period %8.1f fr rel %.4f%s\n", peaks[k].second,
peaks[k].first / top,
std::fabs(peaks[k].second - wantPeriod) / wantPeriod < 0.03
? " <-- the wanted tone" : "");
}
std::printf(" energy outside the wanted tone's mainlobe: %.2f%%\n",
eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0);
}
// ---------------------------------------------------------------------------------------
// A. Splice geometry, observed off the shifter's own SpliceEvent stream
// ---------------------------------------------------------------------------------------
struct SpliceStats {
long long count = 0;
double minReloc = 1e18, maxReloc = -1e18;
std::int64_t minLag = 1LL << 40, maxLag = -(1LL << 40);
double meanInterval = 0.0;
bool jumpAlwaysNominal = true; // |jump| == window on every splice (steady state)
};
// Drives a bare PitchShifter over the same feed schedule Voice uses, recording every splice.
// The audio is not kept — this measures the DECISIONS, not the sound.
static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int64_t window,
double rate, double shift, std::size_t outFrames,
std::vector<double>* audio = nullptr) {
PitchShifter ps;
ps.configure(window);
ps.prime(src.data(), window);
ps.setShiftRatio(shift);
ps.setFeedRate(rate);
StretchCursor cur;
cur.start(window);
loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through
SpliceStats st;
if (audio != nullptr) audio->assign(outFrames, 0.0);
std::size_t lastSpliceAt = 0;
double intervalSum = 0.0;
long long intervals = 0;
for (std::size_t i = 0; i < outFrames; ++i) {
const std::int64_t due = cur.due(rate);
AudioSample last = 0.0f;
bool fed = false;
for (std::int64_t k = 0; k < due; ++k) {
if (fed) ps.writeFrame(last);
const std::int64_t q = cur.next(lp);
last = (q >= 0 && static_cast<std::size_t>(q) < src.size())
? src[static_cast<std::size_t>(q)] : 0.0f;
fed = true;
}
const AudioSample o = fed ? ps.process(last) : ps.processNoInput();
if (audio != nullptr) (*audio)[i] = static_cast<double>(o);
const SpliceEvent& ev = ps.lastSplice();
if (!ev.fired) continue;
++st.count;
const double reloc = std::fabs(static_cast<double>(ev.jump) -
static_cast<double>(ev.lag) - ev.frac);
if (reloc < st.minReloc) st.minReloc = reloc;
if (reloc > st.maxReloc) st.maxReloc = reloc;
if (ev.lag < st.minLag) st.minLag = ev.lag;
if (ev.lag > st.maxLag) st.maxLag = ev.lag;
if (std::llabs(ev.jump) != window) st.jumpAlwaysNominal = false;
if (lastSpliceAt != 0) { intervalSum += static_cast<double>(i - lastSpliceAt); ++intervals; }
lastSpliceAt = i;
}
st.meanInterval = intervals > 0 ? intervalSum / static_cast<double>(intervals) : 0.0;
return st;
}
// Is there a whole number of source periods inside the reachable relocation interval?
static bool alignmentReachable(double periodFrames, double lo, double hi, int* whichN) {
for (int n = 1; n <= 64; ++n) {
const double m = periodFrames * n;
if (m > hi) break;
if (m >= lo) { if (whichN != nullptr) *whichN = n; return true; }
}
if (whichN != nullptr) *whichN = 0;
return false;
}
// ---------------------------------------------------------------------------------------
// 1. The reachable relocation interval, measured
// ---------------------------------------------------------------------------------------
static void reportReachableInterval() {
std::printf("\n=== A. Reachable splice relocation interval (measured) ===\n");
for (const auto& g : {std::pair<int, std::int64_t>{44100, 2205},
std::pair<int, std::int64_t>{48000, 2400}}) {
const int sr = g.first;
const std::int64_t w = g.second;
// Broadband noise: every lag is a plausible candidate, so the search's own limits —
// not the source's periodicity — set the observed extremes.
std::vector<AudioSample> noise(600000);
std::uint32_t rng = 12345u;
for (auto& x : noise) {
rng = rng * 1664525u + 1013904223u;
x = static_cast<AudioSample>((static_cast<double>(rng >> 8) / 8388608.0) - 1.0);
}
SpliceStats up = spliceGeometry(noise, w, 1.0, 1.5, 120000); // up-shift: jump +w
SpliceStats dn = spliceGeometry(noise, w, 1.0, 0.7, 120000); // down-shift: jump -w
const double lo = std::min(up.minReloc, dn.minReloc);
const double hi = std::max(up.maxReloc, dn.maxReloc);
std::printf(" %d Hz, window %lld frames (%.1f ms):\n", sr, static_cast<long long>(w),
1000.0 * static_cast<double>(w) / sr);
std::printf(" up-shift splices %lld, lag [%lld, %lld], reloc [%.2f, %.2f]\n",
up.count, static_cast<long long>(up.minLag),
static_cast<long long>(up.maxLag), up.minReloc, up.maxReloc);
std::printf(" down-shift splices %lld, lag [%lld, %lld], reloc [%.2f, %.2f]\n",
dn.count, static_cast<long long>(dn.minLag),
static_cast<long long>(dn.maxLag), dn.minReloc, dn.maxReloc);
std::printf(" observed reachable relocation interval: [%.2f, %.2f] frames "
"= [%.2f, %.2f] ms\n", lo, hi, 1000.0 * lo / sr, 1000.0 * hi / sr);
std::printf(" structural bound (window +/- window/4): [%lld, %lld]\n",
static_cast<long long>(w - w / 4), static_cast<long long>(w + w / 4));
// The jump is nominal and the lag is inside +/- window/4 — the two facts the
// reachable interval is derived from.
CHECK(up.jumpAlwaysNominal && dn.jumpAlwaysNominal);
CHECK(up.minLag >= -(w / 4) && up.maxLag <= w / 4);
CHECK(dn.minLag >= -(w / 4) && dn.maxLag <= w / 4);
}
}
// The reachability predicate over frequency, at both geometries. Pure arithmetic over the
// interval measured above — no render, stated as such.
static void reportReachabilityByFrequency() {
std::printf("\n=== A2. Alignment reachability by frequency (arithmetic, not rendered) ===\n");
const double freqs[] = {12, 14, 16, 18, 20, 22, 24, 26, 26.6, 28, 30, 31, 31.9, 32,
34, 36, 40, 50, 60, 80, 88.2, 100, 140, 200};
for (const auto& g : {std::pair<int, std::int64_t>{44100, 2205},
std::pair<int, std::int64_t>{48000, 2400}}) {
const int sr = g.first;
const std::int64_t w = g.second;
const double lo = static_cast<double>(w - w / 4), hi = static_cast<double>(w + w / 4);
std::printf(" %d Hz / window %lld, interval [%.0f, %.0f] frames:\n", sr,
static_cast<long long>(w), lo, hi);
for (double f : freqs) {
const double P = static_cast<double>(sr) / f;
int n = 0;
const bool ok = alignmentReachable(P, lo, hi, &n);
if (ok) {
std::printf(" %6.1f Hz P=%8.1f ALIGNABLE (n=%d, n*P=%.1f)\n", f, P, n,
n * P);
} else {
// How far the nearest multiple sits outside the interval, and the phase error
// that residual forces at every splice.
double best = 1e18; double bestM = 0.0;
for (int k = 1; k <= 64; ++k) {
const double m = P * k;
const double d = m < lo ? lo - m : (m > hi ? m - hi : 0.0);
if (d < best) { best = d; bestM = m; }
}
std::printf(" %6.1f Hz P=%8.1f UNALIGNABLE (nearest n*P=%.1f, off by "
"%.1f frames = %.1f deg of phase)\n",
f, P, bestM, best, 360.0 * best / P);
}
}
}
}
// ---------------------------------------------------------------------------------------
// 2. Voice-level renders at 30 Hz
// ---------------------------------------------------------------------------------------
// The reassurance case: the SHIPPED default path. 30 Hz played at its root, rate 1.0, no
// transposition. The shift is exactly 1.0, so the tap's delay never drifts and no splice can
// fire; a primed shifter at unity is a bit-exact pass-through. Baseline is the SAME source
// through Varispeed at the root, which is a straight readPos_ += 1.0 read of the PCM — i.e.
// the unprocessed sample. This is NOT a comparison against a pre-change binary; it is the
// stronger claim that the path is transparent.
static void testRootRateUnityIsBitIdenticalToTheDirectRead() {
std::printf("\n=== B1. 30 Hz, root note, rate 1.0, no transposition ===\n");
const int sr = 44100;
const std::int64_t w = 2205;
const std::size_t frames = 300000, outFrames = 250000;
const SampleData pres = sineSample(30.0, sr, frames, PitchEngine::Preserve);
const SampleData vari = sineSample(30.0, sr, frames, PitchEngine::Varispeed);
const std::vector<double> p = renderVoice(pres, 60, 1.0, w, outFrames);
const std::vector<double> v = renderVoice(vari, 60, 1.0, w, outFrames);
std::size_t firstDiff = outFrames;
for (std::size_t i = 0; i < outFrames; ++i) {
if (p[i] != v[i]) { firstDiff = i; break; }
}
std::printf(" Preserve vs Varispeed at root, %zu frames: %s\n", outFrames,
firstDiff == outFrames ? "BIT-IDENTICAL"
: "differ (first at frame ?)");
if (firstDiff != outFrames) {
std::printf(" first difference at frame %zu (%.9f vs %.9f)\n", firstDiff, p[firstDiff],
v[firstDiff]);
}
CHECK(firstDiff == outFrames);
// And the same claim at the 48k geometry.
const SampleData pres48 = sineSample(30.0, 48000, frames, PitchEngine::Preserve);
const SampleData vari48 = sineSample(30.0, 48000, frames, PitchEngine::Varispeed);
const std::vector<double> p48 = renderVoice(pres48, 60, 1.0, 2400, outFrames);
const std::vector<double> v48 = renderVoice(vari48, 60, 1.0, 2400, outFrames);
bool same48 = true;
for (std::size_t i = 0; i < outFrames && same48; ++i) if (p48[i] != v48[i]) same48 = false;
std::printf(" same at 48k / window 2400: %s\n", same48 ? "BIT-IDENTICAL" : "DIFFER");
CHECK(same48);
// Splice count on the same conditions, read off the shifter directly.
std::vector<AudioSample> src(pres.frames.begin(), pres.frames.end());
const SpliceStats st = spliceGeometry(src, w, 1.0, 1.0, outFrames);
std::printf(" splices fired over %zu frames at shift 1.0, rate 1.0: %lld\n", outFrames,
st.count);
CHECK(st.count == 0);
// Onset at a MUCH larger window — the cost side of any window-resize option. prime()
// parks the tap on src[0] whatever the window, so frame 0 must still be source frame 0.
// Started at quarter-phase so src[0] is FULL SCALE, not the zero a sine would give: a
// frame-0 match against 0.0 would also pass on a voice that produced silence.
const SampleData cosPhase =
sineSample(30.0, sr, 300000, PitchEngine::Preserve, kPi / 2.0);
CHECK(cosPhase.frames[0] == 1.0f);
for (std::int64_t big : {std::int64_t{2205}, std::int64_t{8820}}) {
const std::vector<double> up = renderVoice(cosPhase, 67, 1.0, big, 64); // +7 st
std::printf(" window %5lld, +7 st: out[0]=%.9f (src[0]=%.9f), |out| over frames 1..63 "
"min %.6f\n", static_cast<long long>(big), up[0],
static_cast<double>(cosPhase.frames[0]),
*std::min_element(up.begin() + 1, up.end(),
[](double a, double b) { return std::fabs(a) < std::fabs(b); }));
CHECK(up[0] == static_cast<double>(cosPhase.frames[0])); // zero added latency
}
// A sample SHORTER than the window: start() primes the whole playable span and freezes
// the writer immediately. A larger window moves that threshold, so check it still speaks
// on frame 0 at the largest window swept below.
const SampleData shortSample =
sineSample(30.0, sr, 3000, PitchEngine::Preserve, kPi / 2.0);
const std::vector<double> shortOut = renderVoice(shortSample, 67, 1.0, 8820, 64);
std::printf(" 3000-frame sample under a 8820-frame window, +7 st: out[0]=%.9f src[0]=%.9f\n",
shortOut[0], static_cast<double>(shortSample.frames[0]));
CHECK(shortOut[0] == static_cast<double>(shortSample.frames[0]));
}
// One measured row: render through the Voice and report every metric for that condition.
static void measureRow(const char* label, double freqHz, int sr, std::int64_t window,
int note, double rate) {
const std::size_t frames = 900000;
const std::size_t outFrames = 300000;
const SampleData s = sineSample(freqHz, sr, frames, PitchEngine::Preserve);
const double shift = std::pow(2.0, (note - 60) / 12.0);
const std::vector<double> out = renderVoice(s, note, rate, window, outFrames);
bool finite = true;
double peak = 0.0;
for (double x : out) { if (!std::isfinite(x)) finite = false; peak = std::max(peak, std::fabs(x)); }
const double srcPeriod = static_cast<double>(sr) / freqHz;
const double wantPeriod = srcPeriod / shift; // pitch is the TAP's, not the feed's
const double wantCpf = 1.0 / wantPeriod;
const std::size_t from = 40000, to = 280000;
const double gotPeriod = periodIn(out, from, to);
double worstStep = 0.0;
const double drift = phaseDriftCycles(out, from, to, wantCpf, &worstStep);
const double resid = medianResidual(out, from, to, wantCpf);
std::vector<AudioSample> src(s.frames.begin(), s.frames.end());
const SpliceStats st = spliceGeometry(src, window, rate, shift, outFrames);
const double lo = static_cast<double>(window - window / 4);
const double hi = static_cast<double>(window + window / 4);
int n = 0;
const bool reach = alignmentReachable(srcPeriod, lo, hi, &n);
// Effective frequency error implied by the drift, and the phase step it works out to per
// splice — the number that says whether a splice is stepping the phase or not.
const double driftPerSplice = st.count > 0 ? drift / static_cast<double>(st.count) : 0.0;
std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | period got %8.2f want %8.2f "
"(%+.2f%%) | splices %4lld every %7.0f fr | phase drift %+8.3f cyc "
"(%+7.1f deg/splice, worst step %.1f deg) | resid %.4f | peak %.3f | "
"align %s%s\n",
label, freqHz, shift, rate, gotPeriod, wantPeriod,
wantPeriod > 0.0 ? 100.0 * (gotPeriod - wantPeriod) / wantPeriod : 0.0,
st.count, st.meanInterval, drift, 360.0 * driftPerSplice, 360.0 * worstStep,
resid, peak, reach ? "YES" : "NO",
reach ? "" : " <-- no whole period in the reachable interval");
CHECK(finite);
}
// Three independent pitch estimators plus the spectrum, on one condition. Where the
// zero-crossing count and the autocorrelation disagree, the render is not simply detuned —
// something else is crossing zero.
static void deepDive(const char* label, double freqHz, int sr, std::int64_t window, int note,
double rate) {
const SampleData s = sineSample(freqHz, sr, 900000, PitchEngine::Preserve);
const double shift = std::pow(2.0, (note - 60) / 12.0);
const std::vector<double> out = renderVoice(s, note, rate, window, 300000);
const double wantPeriod = (static_cast<double>(sr) / freqHz) / shift;
const double zc = periodIn(out, 40000, 280000);
// Search bounded to [0.5, 1.7] x the wanted period: a pure sine autocorrelates equally at
// EVERY multiple of its period, so an unbounded search reports 2P about half the time.
const double ac = autocorrPeriod(out, 60000, 60000,
std::max<std::int64_t>(40,
static_cast<std::int64_t>(wantPeriod * 0.5)),
static_cast<std::int64_t>(wantPeriod * 1.7));
std::printf(" %s (f=%.1f Hz, shift %.4f, rate %.2f, want period %.1f fr):\n", label, freqHz,
shift, rate, wantPeriod);
std::printf(" zero-crossing period %.2f (%+.2f%%) | autocorrelation period %.2f "
"(%+.2f%%)\n", zc, 100.0 * (zc - wantPeriod) / wantPeriod, ac,
100.0 * (ac - wantPeriod) / wantPeriod);
reportSpectrum(label, out, 60000, 131072, wantPeriod);
}
static void reportTransposedAt30Hz() {
std::printf("\n=== B2. 30 Hz transposed, rate 1.0 (44.1k / window 2205) ===\n");
measureRow("30 Hz +2 st", 30.0, 44100, 2205, 62, 1.0);
measureRow("30 Hz +7 st", 30.0, 44100, 2205, 67, 1.0);
measureRow("30 Hz -7 st", 30.0, 44100, 2205, 53, 1.0);
std::printf("\n=== B3. 30 Hz stretched, no transposition (44.1k / window 2205) ===\n");
measureRow("30 Hz rate 0.5", 30.0, 44100, 2205, 60, 0.5);
measureRow("30 Hz rate 2.0", 30.0, 44100, 2205, 60, 2.0);
std::printf("\n=== B4. the same six at 48k / window 2400 ===\n");
measureRow("30 Hz +2 st @48k", 30.0, 48000, 2400, 62, 1.0);
measureRow("30 Hz +7 st @48k", 30.0, 48000, 2400, 67, 1.0);
measureRow("30 Hz rate 2.0 @48k", 30.0, 48000, 2400, 60, 2.0);
}
// Where does the behaviour actually turn over? Swept at a fixed, modest transposition so the
// only thing changing is the source period against the reachable interval.
static void reportFrequencySweep() {
std::printf("\n=== B5. Frequency sweep, +2 st, rate 1.0 (44.1k / window 2205) ===\n");
const double freqs[] = {20, 22, 24, 25, 26, 26.5, 27, 28, 29, 30, 31, 31.5, 32, 33,
34, 36, 40, 45, 50, 60, 70, 80, 88.2, 100, 120, 140, 170, 200};
for (double f : freqs) measureRow("sweep +2 st", f, 44100, 2205, 62, 1.0);
std::printf("\n=== B6. Same sweep at rate 2.0, NO transposition ===\n");
for (double f : freqs) measureRow("sweep rate 2.0", f, 44100, 2205, 60, 2.0);
}
// ---------------------------------------------------------------------------------------
// 3. The P=500 case: geometric, or cadence?
// ---------------------------------------------------------------------------------------
// pitch_shift_tests' floor probe fails at source period 500 frames, rate 2.0, shift 0.25 and
// holds at 600/700. Under the geometric claim, alignment needs a whole number of source
// periods in [0.75w, 1.25w] = [1654, 2756]. This reports whether that is satisfied for each of
// the three periods — separating "no aligned landing point exists" (geometric) from "an
// aligned landing point exists but the cadence is too fast to use it" (the inequality already
// in time_stretch.h).
static void reportFloorProbeMechanism() {
std::printf("\n=== C. The P=500/600/700 floor probe: which mechanism? ===\n");
const std::int64_t w = 2205;
const int sr = 44100;
const double rate = 2.0;
const double shift = std::pow(2.0, -24.0 / 12.0); // 0.25
const double lo = static_cast<double>(w - w / 4), hi = static_cast<double>(w + w / 4);
for (double period : {500.0, 600.0, 700.0}) {
int n = 0;
const bool reach = alignmentReachable(period, lo, hi, &n);
const double freq = static_cast<double>(sr) / period;
// The cadence inequality from time_stretch.h, evaluated for this row.
const double cadence = static_cast<double>(w) / std::fabs(rate - shift);
const double outPeriod = period / shift;
std::printf(" P=%5.0f (%.1f Hz): alignable in [%.0f,%.0f]? %s%s | cadence %.0f fr vs "
"output period %.0f fr -> %s\n",
period, freq, lo, hi, reach ? "YES" : "NO",
reach ? "" : " (geometric failure)",
cadence, outPeriod,
cadence < outPeriod ? "SPLICE INSIDE A CYCLE (cadence failure)" : "ok");
measureRow("floor probe", freq, sr, w, 60 - 24, rate);
}
// The probe's OWN signal, reproduced exactly: a bare PitchShifter fed by the same
// schedule test_pitch_shift.cpp's runStretch uses, not the Voice. Its zero-crossing
// number is the one that is currently RED, so it is the one that has to be explained.
std::printf("\n --- the probe's exact signal (bare PitchShifter, runStretch schedule) ---\n");
for (double period : {500.0, 600.0, 700.0}) {
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 * static_cast<double>(i) / period));
}
std::vector<double> out;
const SpliceStats st = spliceGeometry(src, w, rate, shift, 60000, &out);
const double want = period / shift;
const double zc = periodIn(out, 20000, 50000);
const double ac = autocorrPeriod(out, 20000, 20000,
static_cast<std::int64_t>(want * 0.5),
static_cast<std::int64_t>(want * 1.7));
std::printf(" P=%.0f: zero-crossing %.2f (%+.2f%%) | autocorrelation %.2f (%+.2f%%) "
"| splices %lld every %.0f fr\n", period, zc, 100.0 * (zc - want) / want,
ac, 100.0 * (ac - want) / want, st.count, st.meanInterval);
reportSpectrum("probe", out, 20000, 32768, want);
}
std::printf("\n --- independent pitch estimators on the same three (through the Voice) ---\n");
deepDive("P=500", 44100.0 / 500.0, sr, w, 36, rate);
deepDive("P=600", 44100.0 / 600.0, sr, w, 36, rate);
deepDive("P=700", 44100.0 / 700.0, sr, w, 36, rate);
std::printf("\n --- and on the geometric cases, for contrast ---\n");
deepDive("30 Hz +2 st rate 1.0", 30.0, sr, w, 62, 1.0);
deepDive("30 Hz rate 2.0", 30.0, sr, w, 60, 2.0);
deepDive("29 Hz rate 2.0", 29.0, sr, w, 60, 2.0);
}
// What would a bigger window buy? The reachable interval is [0.75w, 1.25w], so it contains a
// whole number of source periods for EVERY period P <= 0.5w — i.e. a window of at least TWO
// source periods makes alignment reachable unconditionally. This sweeps 30 Hz across windows
// spanning that threshold (1470 * 2 = 2940 frames = 66.7 ms at 44.1k) and reports what
// actually changes. The window is an ARGUMENT to configure(); nothing shipped is altered.
static void reportWindowSweep() {
std::printf("\n=== D. Window sweep at 30 Hz — what a larger window would buy ===\n");
const int sr = 44100;
const double P = static_cast<double>(sr) / 30.0;
std::printf(" source period %.1f frames; alignment is unconditional once window >= 2P = "
"%.0f frames (%.1f ms)\n", P, 2.0 * P, 2000.0 * P / sr);
for (std::int64_t w : {std::int64_t{2205}, std::int64_t{2646}, std::int64_t{2940},
std::int64_t{3528}, std::int64_t{4410}, std::int64_t{8820}}) {
const double lo = static_cast<double>(w - w / 4), hi = static_cast<double>(w + w / 4);
int n = 0;
const bool reach = alignmentReachable(P, lo, hi, &n);
std::printf("\n window %lld fr (%.1f ms), interval [%.0f, %.0f]: %s\n",
static_cast<long long>(w), 1000.0 * static_cast<double>(w) / sr, lo, hi,
reach ? "ALIGNABLE" : "unalignable");
// Per-voice Preserve state: two shifter rings of 2*window floats (L/R) plus the
// window-sized prime scratch = 5*window floats (voice.cpp presizePreserveShifters).
const double bytes = 5.0 * static_cast<double>(w) * 4.0;
std::printf(" per-voice Preserve state %.1f KB; at the 32-voice ceiling %.2f MB\n",
bytes / 1024.0, 32.0 * bytes / (1024.0 * 1024.0));
measureRow(" 30 Hz +2 st", 30.0, sr, w, 62, 1.0);
measureRow(" 30 Hz rate 2.0", 30.0, sr, w, 60, 2.0);
deepDive(" +2 st", 30.0, sr, w, 62, 1.0);
deepDive(" rate 2.0", 30.0, sr, w, 60, 2.0);
}
}
// Alignable neighbours under identical conditions — without these the out-of-band-energy
// numbers above have no scale.
static void reportAlignableControls() {
std::printf("\n=== E. Alignable controls (same conditions, a frequency that CAN align) ===\n");
deepDive("34 Hz +2 st rate 1.0", 34.0, 44100, 2205, 62, 1.0);
deepDive("34 Hz rate 2.0", 34.0, 44100, 2205, 60, 2.0);
deepDive("20 Hz +2 st rate 1.0", 20.0, 44100, 2205, 62, 1.0);
deepDive("220 Hz +2 st rate 1.0", 220.0, 44100, 2205, 62, 1.0);
deepDive("220 Hz rate 2.0", 220.0, 44100, 2205, 60, 2.0);
}
int main() {
reportReachableInterval();
reportReachabilityByFrequency();
testRootRateUnityIsBitIdenticalToTheDirectRead();
reportTransposedAt30Hz();
reportFrequencySweep();
reportFloorProbeMechanism();
reportAlignableControls();
reportWindowSweep();
if (g_fail == 0) {
std::printf("\nall preserve_low_frequency measurements completed\n");
return 0;
}
std::printf("\n%d preserve_low_frequency check(s) failed\n", g_fail);
return 1;
}
+397
View File
@@ -20,8 +20,11 @@
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>
using namespace reasampler;
@@ -2880,6 +2883,393 @@ 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 are a witness against `phase-g`'s tip, commit 0a7778b — the last
// commit before this track's rate seam — not a self-consistency check. To re-derive: check
// out 0a7778b, add this file's stretchProbeSample/hashStream/renderVoice/test body to it, and
// drop the trailing `, rate` argument from renderVoice's `v.start(...)` call (0a7778b's
// Voice::start has no 5th parameter) — then build, run, and print the hashes. 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.
// This bit-exact check is what actually carries "no first-frame smear"; the loop
// below is a coarser, complementary DROPOUT detector (see its own comment).
CHECK(first == s.frames[500]);
// ...and it keeps speaking: no first-window DROPOUT while the schedule settles.
// `lo > 0.5` over twenty 256-frame peak windows catches a gap of roughly a window,
// but a smeared or phase-scrambled first window can still peak above 0.5 and pass
// here — it cannot see that; the CHECK above is what does. 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 two assertions above hold even if stretchRate_ were ignored outright — the loop's
// constant content proves nothing about cadence. A one-time marker AFTER the primed window
// but BEFORE the loop start is the source-frame witness that the feed genuinely consumes
// source AT THE RATE: note-on primes the ring with the first `window` source frames up
// front (played back at 1 frame/output-frame, independent of rate — a marker inside that
// span was measured landing at a FIXED output frame at every rate, confirming it is not a
// rate witness). Past it, new content only enters the ring via the ongoing due()-scheduled
// feed, at `rate` source frames per output frame on average: the marker's single output
// appearance lands at `window + (markerFrame - window) / rate` output frames. Note == root
// (shift == 1.0), isolating the rate's effect from the pitch engine's own transposition.
//
// Excludes rate 2.0: at shift 1.0 that is drift = |rate-shift| = 1.0 exactly, and this
// geometry's own splice trigger (0.75x window output frames from note-on, measured) fires
// BEFORE the primed span even finishes playing back (< window frames) whenever drift >=
// ~0.75 — so no marker placed "past the prime" can be reached before a splice relocates
// the tap first. Confirmed by measurement, not assumed: a rate-2.0 attempt at this marker
// came back with the tap having moved on (no witness value in the output at all). The
// write-side consumption-at-the-rate claim at every rate, splice-immune because it never
// goes through the shifter, is what test_time_stretch.cpp's StretchCursor tests assert.
//
// A marker placed INSIDE the steady-state loop instead would NOT show rate-dependence
// either: once ring-resident, the read tap's own pace is governed by SHIFT alone ("shift
// the output" — ratio_ advances posA_ every output frame unconditionally), so it revisits
// every loopLength ring slots at 1 slot/output-frame regardless of how fast the writer
// filled them — confirmed by measurement (median recurrence gap 200 frames at rate 0.5,
// 1.0 AND 2.0 alike, for a 200-frame loop). Rate governs the feed/splice cadence, not the
// loop's own output period, once its content is already in the ring.
for (double rate : {0.5, 1.0}) {
SampleData s;
s.frames.assign(1000, 0.0f);
for (int i = 300; i < 900; ++i) s.frames[i] = 0.5f;
s.frames[650] = 1.0f; // past the 600-frame primed span, before the loop at 700
s.rootNote = 60;
s.sampleRate = 48000;
s.loop.hasLoop = true;
s.loop.start = 700;
s.loop.end = 900;
s.play.adsr = flatAdsr();
s.play.pitchEngine = PitchEngine::Preserve;
Voice v;
v.presizePreserveShifters(600);
v.start(60, 127, s, /*declickTakeover=*/false, rate); // root note: shift == 1.0
const std::size_t total = 3000;
std::vector<AudioSample> out(total);
for (std::size_t i = 0; i < total; ++i) out[i] = v.renderFrame();
std::size_t hitAt = 0;
for (std::size_t i = 0; i < total; ++i) {
if (out[i] > 0.7f) { hitAt = i; break; }
}
CHECK(hitAt > 0);
const double want = 600.0 + (650.0 - 600.0) / rate;
if (!approx(static_cast<double>(hitAt), want, want * 0.15 + 5.0)) {
std::printf(" rate %.2f: marker at frame %zu want %.2f\n", rate, hitAt, want);
}
CHECK(approx(static_cast<double>(hitAt), want, want * 0.15 + 5.0));
}
}
// --- 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).
//
// Methodology: std::chrono::steady_clock (not std::clock() — a single wall-clock diff has no
// warm-up and no spread), kWarmupReps discarded, kTimedReps repetitions per rate, median +
// [min, max] reported. secs is wall-clock for 1.0 s of audio on ONE thread with no other work
// scheduled onto it, so 100*secs is % of REALTIME consumed — not "% of one core" (that would
// additionally claim core-pinned exclusivity this benchmark never establishes).
//
// A separate, one-off Release A/B (unity-now vs the pre-stretch build at commit 0a7778b, same
// methodology, standalone harness outside this tree) found the two statistically
// indistinguishable at ~79-82 ns/voice/frame; that is a point-in-time finding to re-derive if
// this path changes materially, not a hardcoded regression bound here. ---
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;
const int kWarmupReps = 2;
const int kTimedReps = 7;
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<double> nsPerVoiceFrame;
nsPerVoiceFrame.reserve(kTimedReps);
for (int rep = 0; rep < kWarmupReps + kTimedReps; ++rep) {
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 auto t0 = std::chrono::steady_clock::now();
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 =
std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
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
if (rep >= kWarmupReps) {
nsPerVoiceFrame.push_back(secs * 1e9 / (static_cast<double>(blockFrames) *
static_cast<double>(voiceCount)));
}
}
std::sort(nsPerVoiceFrame.begin(), nsPerVoiceFrame.end());
const double medianNs = nsPerVoiceFrame[nsPerVoiceFrame.size() / 2];
const double secsAtMedian =
medianNs * static_cast<double>(blockFrames) * static_cast<double>(voiceCount) / 1e9;
std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: median %.1f ns/voice/"
"frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime at the median)\n",
rate, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(), kTimedReps,
100.0 * secsAtMedian);
}
}
int main() {
testEveryKeyPlaysTheLoadedCapture();
testUnplayableCaptureRefusesEveryNote();
@@ -3006,6 +3396,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;
}