Files
reasampler/tests/test_pitch_shift.cpp
T

316 lines
15 KiB
C++

// 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
// <windows.h> 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). Ratios bracket the real playable range: +24 st (ratio 4 — the geometry-fix
// target where an unscaled fade reads stale data) and a full octave down included.
// 6. unity contract — the header's two hard claims, asserted bit-exactly: at ratio 1.0 the
// shifter IS a clean window/2 delay (out[i] == in[i - w/2] to the bit; no splice, no
// interpolation error), which is simultaneously the latency == window/2 assertion.
#include "../src/vst/pitch_shift.h"
#include <cmath>
#include <cstdio>
#include <vector>
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<AudioSample> sine(std::size_t frames, double cycles) {
std::vector<AudioSample> s(frames);
for (std::size_t i = 0; i < frames; ++i) {
s[i] = static_cast<float>(std::sin(2.0 * kPi * cycles *
static_cast<double>(i) / static_cast<double>(frames)));
}
return s;
}
// Average spacing between positive-going zero crossings (the observed period).
static double observedPeriod(const std::vector<AudioSample>& out, std::size_t from) {
std::vector<std::size_t> 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<double>(up[i] - up[i - 1]);
return sum / static_cast<double>(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<AudioSample> 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<double>(n) / cycles; // 200
const std::vector<AudioSample> in = sine(n, cycles);
PitchShifter ps;
ps.configure(2205);
ps.warm();
ps.setShiftRatio(1.0);
std::vector<AudioSample> 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<double>(n) / cycles; // 200
const std::vector<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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
// on TWO axes simultaneously:
// (a) f0*(w/2) = (2205/2)/196 = 1102/196 ≈ 5.622 cycles (frac ≈ 0.622) — content half a
// window apart in the ring is near ANTI-PHASE. The old dual-tap design cancelled
// almost completely at every crossfade midpoint for such tones — the DAW "severe
// beating / multiple partials from a pure sine" bug.
// (b) ringLen_*f0 = 4410/196 = 22.5 EXACTLY — at ratio 4 the write head advances 4 taps
// per output frame, so each splice-period the outgoing tap crosses the writer at the
// HALF-period point of the source waveform (sign flip), producing a visible null when
// gNew == gOld if fadeLen_ is not clamped to headroom. With f0=0.005 this product
// is 22.05 (frac ≈ 0.05), near a zero-crossing — the artifact is near-benign, so the
// +24 st purity case would pass even with the clamping reverted. f0=1/196 forces the
// half-integer alignment that makes the pre-fix artifact catastrophic.
const std::int64_t w = 2205; // ~50 ms @ 44.1k (the product window)
const double f0 = 1.0 / 196.0; // source: period 196 samples; see adversarial note above
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 (nominal-fade boundary)
std::pow(2.0, 24.0 / 12.0), // +24 st: ratio 4 — the ratio-scaled-
// fade target (unscaled fade would
// read stale data at ~75% gain)
std::pow(2.0, -12.0 / 12.0)}; // octave down (full down-shift path)
for (double r : ratios) {
PitchShifter ps;
ps.configure(w);
ps.warm();
ps.setShiftRatio(r);
const std::size_t n = 120000;
std::vector<double> out(n);
for (std::size_t i = 0; i < n; ++i) {
const double x = std::sin(2.0 * kPi * f0 * static_cast<double>(i));
out[i] = static_cast<double>(ps.process(static_cast<AudioSample>(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<std::size_t>(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<double>(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<double>(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<double>(span));
const double residRms = std::sqrt(residSq / static_cast<double>(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).
// The window must RESOLVE a within-fade dip (the ratio-4 fade is only ~w/12 = 183
// frames; the original win=2000 averaged straight over total cancellation), yet a
// window that is not an integer number of output periods has phase-dependent RMS on a
// pure sine (at ratio 0.5 the output period is 400 frames — a fixed 256 window dips
// to ~0.78 of max on the CLEAN signal alone). Smallest phase-clean choice: exactly one
// output period per window (50..400 frames here), hop of half a window.
const std::size_t win = static_cast<std::size_t>(std::lround(1.0 / f1));
const std::size_t hop = std::max<std::size_t>(1, win / 2);
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<double>(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
}
}
// --- 6. Unity contract: bit-exact window/2 delay == the latency claim. ---
static void testUnityBitExactAndLatency() {
// The header claims a configured shifter at ratio 1.0 is a CLEAN window/2 delay: the tap
// is parked mid-band (no splice ever fires) at an integral delay (no interpolation error),
// so every output equals the input from exactly w/2 frames earlier TO THE BIT. This is
// simultaneously the latency assertion: steady-state latency == window/2, no more, no
// less. warm() has already consumed the cold-start region, so the first w/2 outputs are
// the tail of the warm-up silence and everything after is the delayed input verbatim.
const std::int64_t w = 2205; // the product window (odd: w/2 truncates)
const std::int64_t lat = w / 2; // 1102
PitchShifter ps;
ps.configure(w);
ps.warm();
ps.setShiftRatio(1.0);
const std::size_t n = 6000;
const std::vector<AudioSample> in = sine(n, 37.0);
std::vector<AudioSample> out(n);
for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
std::size_t badSilence = 0, badDelay = 0;
for (std::size_t i = 0; i < static_cast<std::size_t>(lat); ++i) {
if (out[i] != 0.0f) ++badSilence; // pre-latency region: warm-up silence, exact
}
for (std::size_t i = static_cast<std::size_t>(lat); i < n; ++i) {
if (out[i] != in[i - static_cast<std::size_t>(lat)]) ++badDelay; // bit-exact delay
}
CHECK(badSilence == 0);
CHECK(badDelay == 0);
}
int main() {
testDurationInvariance();
testUnityRoughlyReproduces();
testTransposeDirection();
testRtDisciplineAndPassthrough();
testRepitchSpectralPurity();
testUnityBitExactAndLatency();
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;
}