// Standalone tests for reasampler::PitchShifter — the S16 Preserve-engine DSP core. No VST3, // no REAPER, no vendor, no test framework. The compile-time proof it does NOT drag the WDL // chain is the CMake target linking only pitch_shift (+ peaks). // // Covers (PLAN.md S16 / CONTEXT.md §Pitch engine modes — Preserve): // 1. duration invariance — N inputs yield N outputs at every shift ratio (the load-bearing // Preserve property: a transposed render is the SAME frame length as the un-transposed one). // 2. unity pass-through fidelity — ratio 1.0 reproduces the input closely (a shifter at unity // must not mangle the signal). // 3. transpose direction — an octave-up shift raises the observed pitch (period shortens), an // octave-down lowers it (period lengthens), measured on a synthesized sine. // 4. RT discipline surrogate — after configure()+warm() (the off-thread setup), a long // process() run never resizes the ring (checked via window() constancy) and never returns // NaN/inf; pass-through (unconfigured) returns input verbatim. // 5. spectral purity (GA-Preserve regression) — a repitched PURE SINE must come out as a // SINGLE tone at the shifted frequency: near-total least-squares fit to the shifted // sinusoid, and no deep amplitude beating across the run. This is the test that fails on // any splice/crossfade phase-alignment defect (the DAW "multiple partials from a sine" // report). #include "../src/vst/pitch_shift.h" #include #include #include using namespace reasampler; 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 bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; } constexpr double kPi = 3.14159265358979323846; // A sine of `cycles` periods over `frames` frames. static std::vector sine(std::size_t frames, double cycles) { std::vector s(frames); for (std::size_t i = 0; i < frames; ++i) { s[i] = static_cast(std::sin(2.0 * kPi * cycles * static_cast(i) / static_cast(frames))); } return s; } // Average spacing between positive-going zero crossings (the observed period). static double observedPeriod(const std::vector& out, std::size_t from) { std::vector up; for (std::size_t i = from + 1; i < out.size(); ++i) { if (out[i - 1] <= 0.0f && out[i] > 0.0f) up.push_back(i); } if (up.size() < 2) return 0.0; double sum = 0.0; for (std::size_t i = 1; i < up.size(); ++i) sum += static_cast(up[i] - up[i - 1]); return sum / static_cast(up.size() - 1); } // --- 1. Duration invariance across shift ratios. --- static void testDurationInvariance() { // The core Preserve property: whatever the shift ratio, one input frame yields one output // frame. So a shifter fed N frames produces exactly N frames — a transposed render is the // same length as an un-transposed one (unlike Varispeed, where an octave up halves length). const std::size_t n = 4000; const std::vector in = sine(n, 40.0); const double ratios[] = {0.5, 1.0, 2.0, std::pow(2.0, 7.0 / 12.0)}; for (double r : ratios) { PitchShifter ps; ps.configure(2205); // ~50 ms @ 44.1k ps.warm(); ps.setShiftRatio(r); std::size_t produced = 0; for (std::size_t i = 0; i < n; ++i) { const AudioSample o = ps.process(in[i]); (void)o; ++produced; // exactly one output per input, unconditionally. } CHECK(produced == n); // duration held at every ratio. } } // --- 2. Unity pass-through fidelity. --- static void testUnityRoughlyReproduces() { // At ratio 1.0 the shifter should reproduce the input's PITCH faithfully (the OLA taps run // in lockstep with the writer). Amplitude/phase warble is allowed (basic OLA), but the // observed period must match the source period within a small tolerance past the warm-up. const std::size_t n = 8000; const double cycles = 40.0; const double nativePeriod = static_cast(n) / cycles; // 200 const std::vector in = sine(n, cycles); PitchShifter ps; ps.configure(2205); ps.warm(); ps.setShiftRatio(1.0); std::vector out(n); for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); // Measure past the initial half-window latency region. const double p = observedPeriod(out, 3000); CHECK(p > 0.0); CHECK(approx(p, nativePeriod, nativePeriod * 0.10)); // within 10% of source period } // --- 3. Transpose direction: up shortens the period, down lengthens it. --- static void testTransposeDirection() { const std::size_t n = 12000; const double cycles = 60.0; const double nativePeriod = static_cast(n) / cycles; // 200 const std::vector in = sine(n, cycles); // Octave up: output period ~ half the source period (higher pitch). { PitchShifter ps; ps.configure(2205); ps.warm(); ps.setShiftRatio(2.0); std::vector out(n); for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); const double p = observedPeriod(out, 4000); CHECK(p > 0.0); CHECK(approx(p, nativePeriod / 2.0, nativePeriod * 0.15)); // period halves } // Octave down: output period ~ double the source period (lower pitch). { PitchShifter ps; ps.configure(2205); ps.warm(); ps.setShiftRatio(0.5); std::vector out(n); for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); const double p = observedPeriod(out, 4000); CHECK(p > 0.0); CHECK(approx(p, nativePeriod * 2.0, nativePeriod * 0.30)); // period doubles } } // --- 4. RT discipline surrogate + pass-through. --- static void testRtDisciplineAndPassthrough() { // Unconfigured shifter passes input through verbatim (a Varispeed voice never allocates one). { PitchShifter ps; CHECK(!ps.configured()); CHECK(ps.process(0.37f) == 0.37f); // exact pass-through CHECK(ps.process(-0.9f) == -0.9f); } // Configured: the window is fixed at configure() and never changes across a long run (no // per-frame Resize), and no output is NaN/inf (numerically well-behaved OLA). { PitchShifter ps; ps.configure(1024); ps.warm(); const std::int64_t w = ps.window(); CHECK(w == 1024); ps.setShiftRatio(std::pow(2.0, 5.0 / 12.0)); const std::vector in = sine(20000, 100.0); for (std::size_t i = 0; i < in.size(); ++i) { const AudioSample o = ps.process(in[i]); CHECK(std::isfinite(o)); } CHECK(ps.window() == w); // window unchanged -> ring never resized mid-run } // A non-positive shift ratio is ignored (keeps the last valid ratio) — never stalls/reverses. { PitchShifter ps; ps.configure(512); ps.warm(); ps.setShiftRatio(1.0); ps.setShiftRatio(-2.0); // ignored ps.setShiftRatio(0.0); // ignored for (int i = 0; i < 2000; ++i) CHECK(std::isfinite(ps.process(0.5f))); } // Degenerate window (<= 1) stays pass-through even after configure. { PitchShifter ps; ps.configure(1); CHECK(!ps.configured()); CHECK(ps.process(0.25f) == 0.25f); } } // --- 5. Spectral purity: a repitched pure sine stays a SINGLE shifted tone. --- static void testRepitchSpectralPurity() { // Frequencies are in cycles/sample (rate-free). The source tone is chosen ADVERSARIALLY: // f0 * (window/2) = 5.5125 cycles, i.e. a fractional part of ~0.51 — content half a window // apart in the ring is near ANTI-PHASE. The old dual-tap design (taps hard-locked w/2 // apart) cancelled almost completely at every crossfade midpoint for such tones — the DAW // "severe beating / multiple partials from a pure sine" bug. A correct shifter keeps the // output a single sinusoid at ratio*f0 with a steady amplitude. const std::int64_t w = 2205; // ~50 ms @ 44.1k (the product window) const double f0 = 0.005; // source: period 200 samples const double ratios[] = {std::pow(2.0, 2.0 / 12.0), // +2 semitones (the DAW report: D from C) std::pow(2.0, -3.0 / 12.0), // -3 semitones (down-shift path) 2.0}; // octave up (fastest splice cadence) for (double r : ratios) { PitchShifter ps; ps.configure(w); ps.warm(); ps.setShiftRatio(r); const std::size_t n = 120000; std::vector out(n); for (std::size_t i = 0; i < n; ++i) { const double x = std::sin(2.0 * kPi * f0 * static_cast(i)); out[i] = static_cast(ps.process(static_cast(x))); } // Least-squares fit of a*sin + b*cos at the SHIFTED frequency over the settled span // (past 3 windows of onset/latency). Solve the exact 2x2 normal equations so a // non-integer cycle count doesn't leak into the residual. const std::size_t from = static_cast(3 * w); const double f1 = r * f0; double sss = 0.0, scc = 0.0, ssc = 0.0, sys = 0.0, syc = 0.0; for (std::size_t i = from; i < n; ++i) { const double ph = 2.0 * kPi * f1 * static_cast(i); const double s = std::sin(ph), c = std::cos(ph); sss += s * s; scc += c * c; ssc += s * c; sys += out[i] * s; syc += out[i] * c; } const double det = sss * scc - ssc * ssc; CHECK(det > 0.0); const double a = (sys * scc - syc * ssc) / det; const double b = (syc * sss - sys * ssc) / det; double residSq = 0.0, fitSq = 0.0; for (std::size_t i = from; i < n; ++i) { const double ph = 2.0 * kPi * f1 * static_cast(i); const double fit = a * std::sin(ph) + b * std::cos(ph); const double resid = out[i] - fit; residSq += resid * resid; fitSq += fit * fit; } const std::size_t span = n - from; const double fitRms = std::sqrt(fitSq / static_cast(span)); const double residRms = std::sqrt(residSq / static_cast(span)); CHECK(fitRms > 0.5); // the shifted tone is actually there (unit sine ~0.707) CHECK(residRms < 0.1 * fitRms); // >=99% of the energy in the ONE shifted tone // No beating: sliding-window RMS must not dip (the old design dipped to ~13% of peak). const std::size_t win = 2000, hop = 1000; double minRms = 1e9, maxRms = 0.0; for (std::size_t s0 = from; s0 + win <= n; s0 += hop) { double e = 0.0; for (std::size_t i = s0; i < s0 + win; ++i) e += out[i] * out[i]; const double rms = std::sqrt(e / static_cast(win)); if (rms < minRms) minRms = rms; if (rms > maxRms) maxRms = rms; } CHECK(maxRms > 0.0); CHECK(minRms > 0.8 * maxRms); // steady amplitude — no crossfade cancellation } } int main() { testDurationInvariance(); testUnityRoughlyReproduces(); testTransposeDirection(); testRtDisciplineAndPassthrough(); testRepitchSpectralPurity(); if (g_fail == 0) { std::printf("all pitch_shift tests passed\n"); return 0; } std::printf("%d pitch_shift check(s) failed\n", g_fail); return 1; }