351 lines
17 KiB
C++
351 lines
17 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 + onset integrity (GA / GA2 regressions) — a PRIMED repitched PURE
|
|
// SINE must come out as a SINGLE tone at the shifted frequency FROM THE VERY FIRST
|
|
// MILLISECOND: no zero-gaps anywhere (the GA2 DAW report: silence-warmed rings made
|
|
// every early splice jump into zeros — burst/gap/burst stutter in the first few ms),
|
|
// and a per-block least-squares residual floor that catches harmonics, splice-cadence
|
|
// sideband combs, and crossfade cancellation alike. Ratios cover the FULL playable
|
|
// range the DAW report exercised: +2/-3 st, +/-1 octave, +24 st, +48 st (C8 from C4,
|
|
// ratio 16) and -36 st (C1 from C4, ratio 1/8).
|
|
// 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.
|
|
|
|
#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 + onset integrity: a PRIMED repitched pure sine is a SINGLE shifted
|
|
// tone from the very first millisecond. ---
|
|
static void testRepitchSpectralPurityAndOnset() {
|
|
// 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.
|
|
//
|
|
// The shifter is driven exactly as the Voice drives it since GA2: prime() with the first
|
|
// window of the source, then stream the CONTINUATION — so the measurements start at
|
|
// output frame 0 and the onset regime (early splices near the primed boundary, the DAW
|
|
// "zero-sample gaps in the first few ms" report) is inside the assertions, not skipped.
|
|
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 (D from C)
|
|
std::pow(2.0, -3.0 / 12.0), // -3 semitones (down-shift path)
|
|
2.0, // octave up (the GA2 report: C5)
|
|
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, 48.0 / 12.0), // +48 st: ratio 16 — C8 from C4 (the
|
|
// GA2 "awful at C8" report; fast
|
|
// splice cadence, short fades)
|
|
std::pow(2.0, -12.0 / 12.0), // octave down (full down-shift path)
|
|
std::pow(2.0, -36.0 / 12.0)}; // -36 st: ratio 1/8 — C1 from C4
|
|
// (the GA2 down-shift report)
|
|
for (double r : ratios) {
|
|
PitchShifter ps;
|
|
ps.configure(w);
|
|
const std::size_t n = 120000;
|
|
std::vector<AudioSample> src(n + static_cast<std::size_t>(w));
|
|
for (std::size_t i = 0; i < src.size(); ++i) {
|
|
src[i] = static_cast<AudioSample>(
|
|
std::sin(2.0 * kPi * f0 * static_cast<double>(i)));
|
|
}
|
|
ps.prime(src.data(), w); // the Voice's note-on path: real content, not warm zeros
|
|
ps.setShiftRatio(r);
|
|
std::vector<double> out(n);
|
|
for (std::size_t i = 0; i < n; ++i) {
|
|
out[i] = static_cast<double>(ps.process(src[i + static_cast<std::size_t>(w)]));
|
|
}
|
|
|
|
// (a) ONSET/GAP integrity over the ENTIRE run, frame 0 included: no near-zero run
|
|
// longer than 32 frames (~0.7 ms). A unit-amplitude shifted sine dwells below 1e-3
|
|
// for well under one frame per zero crossing even at the lowest ratio here, while the
|
|
// pre-fix onset gaps were hundreds to thousands of frames of literal silence.
|
|
std::size_t worstGap = 0, run = 0;
|
|
for (std::size_t i = 0; i < n; ++i) {
|
|
if (std::fabs(out[i]) < 1e-3) {
|
|
++run;
|
|
if (run > worstGap) worstGap = run;
|
|
} else {
|
|
run = 0;
|
|
}
|
|
}
|
|
CHECK(worstGap < 32);
|
|
|
|
// (b) PER-BLOCK least-squares fit of a*sin + b*cos at the SHIFTED frequency, from the
|
|
// FIRST block. Fitting phase per block deliberately tolerates the slow (pitch-true,
|
|
// inaudible) SOLA phase wander across seconds while catching everything audible:
|
|
// harmonics ("square-ish"), splice-cadence sideband combs (the spectrogram alias
|
|
// lines), crossfade cancellation, and onset gaps all land in the residual or collapse
|
|
// the in-block fit amplitude. Solve the exact 2x2 normal equations per block.
|
|
const double f1 = r * f0;
|
|
const std::size_t block = 4096;
|
|
for (std::size_t b0 = 0; b0 + block <= n; b0 += block) {
|
|
double sss = 0.0, scc = 0.0, ssc = 0.0, sys = 0.0, syc = 0.0;
|
|
for (std::size_t i = b0; i < b0 + block; ++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 = b0; i < b0 + block; ++i) {
|
|
const double ph = 2.0 * kPi * f1 * static_cast<double>(i);
|
|
const double fit = a * std::sin(ph) + b * std::cos(ph);
|
|
residSq += (out[i] - fit) * (out[i] - fit);
|
|
fitSq += fit * fit;
|
|
}
|
|
const double fitRms = std::sqrt(fitSq / static_cast<double>(block));
|
|
const double residRms = std::sqrt(residSq / static_cast<double>(block));
|
|
// The shifted tone is there at full amplitude (unit sine RMS ~0.707) in EVERY
|
|
// block — a gapped or beating block collapses this...
|
|
CHECK(fitRms > 0.6);
|
|
CHECK(fitRms < 0.8);
|
|
// ...and it is the ONLY thing there: residual at least 30 dB under the tone.
|
|
// (Post-fix the engine measures ~-75 dB and better; the old integer-lag splices
|
|
// sat near -59 dB sidebands and the warm-zero onset failed outright.)
|
|
CHECK(residRms < 0.0316 * fitRms);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 6. Unity + latency contract: warm = bit-exact window delay; primed = bit-exact ZERO
|
|
// latency. ---
|
|
static void testUnityBitExactAndLatency() {
|
|
// A configured shifter at ratio 1.0 parks the tap mid-band (no splice ever fires) at an
|
|
// integral delay (no interpolation error). After warm() that delay is exactly one window
|
|
// of declared silence, so out[i] == in[i - w] to the bit. After prime() with the first
|
|
// window of source the tap sits ON src[0] — out[i] == src[i] to the bit from the very
|
|
// first frame: the GA2 zero-structural-latency (immediate onset) claim.
|
|
const std::int64_t w = 2205; // the product window
|
|
const std::size_t n = 6000;
|
|
const std::vector<AudioSample> in = sine(n + static_cast<std::size_t>(w), 37.0);
|
|
// warm(): a clean, bit-exact one-window delay of the streamed input.
|
|
{
|
|
PitchShifter ps;
|
|
ps.configure(w);
|
|
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]);
|
|
std::size_t badSilence = 0, badDelay = 0;
|
|
for (std::size_t i = 0; i < static_cast<std::size_t>(w); ++i) {
|
|
if (out[i] != 0.0f) ++badSilence; // pre-latency region: declared silence, exact
|
|
}
|
|
for (std::size_t i = static_cast<std::size_t>(w); i < n; ++i) {
|
|
if (out[i] != in[i - static_cast<std::size_t>(w)]) ++badDelay; // bit-exact delay
|
|
}
|
|
CHECK(badSilence == 0);
|
|
CHECK(badDelay == 0);
|
|
}
|
|
// prime(): zero added latency — the output IS the source from frame 0, bit-exact.
|
|
{
|
|
PitchShifter ps;
|
|
ps.configure(w);
|
|
ps.prime(in.data(), w);
|
|
ps.setShiftRatio(1.0);
|
|
std::size_t badZeroLat = 0;
|
|
for (std::size_t i = 0; i < n; ++i) {
|
|
if (ps.process(in[i + static_cast<std::size_t>(w)]) != in[i]) ++badZeroLat;
|
|
}
|
|
CHECK(badZeroLat == 0);
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
testDurationInvariance();
|
|
testUnityRoughlyReproduces();
|
|
testTransposeDirection();
|
|
testRtDisciplineAndPassthrough();
|
|
testRepitchSpectralPurityAndOnset();
|
|
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;
|
|
}
|