Files
reasampler/tests/test_pitch_shift.cpp
T

510 lines
25 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.37 ≈ 5.609 cycles (frac ≈ 0.609) — 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.37 ≈ 22.46 — near the half-integer alignment that makes the
// pre-fix fade-headroom artifact visible at ratio 4 (+24 st). Additionally, period
// 196.37 is NON-INTEGER, so the correlation peak is NOT on the integer lag grid; the
// sub-sample parabolic refinement is LOAD-BEARING to stay at the -84 dB floor — the
// old integer f0=1/196 put the optimum on the grid and the parabola contributed
// nothing, making the -30 dB floor reachable without it.
//
// 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.37; // NON-INTEGER period: the sub-sample correlation peak
// is NOT on the integer grid, so the parabolic
// refinement MUST contribute to achieve a clean
// aligned splice — reverting it now FAILS this test.
// (Old integer 1/196 put the optimum on the grid,
// letting the parabola contribute nothing; the 30 dB
// residual floor was then reachable without it.)
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 84 dB under the tone.
// With the non-integer f0=1/196.37, the sub-sample parabolic refinement is
// LOAD-BEARING: reverting it raises the floor to ~-50 dB (ratio 1/8), failing here.
// With integer f0=1/196 the optimum was on the integer grid and the parabola
// contributed nothing — the old floor of -30 dB was reachable without it.
// Engine steady-state measures -84 dB and better across all 7 tested ratios;
// moderate ratios (+/-2 st, octaves) sit at -88 dB typical.
CHECK(residRms < 0.000063 * fitRms); // -84 dB floor
}
}
}
// --- 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);
}
}
// --- 7. Tail wind-down (GA3): freezeTail() at source exhaustion keeps the output a
// continuous, full-amplitude tone at the shifted frequency — the splice machinery
// recycles the ring's frozen ALL-REAL tail instead of chopping against held-DC
// padding (the DAW "ring modulation" troughs growing toward the note end). ---
static void testFreezeTailContinuousTone() {
const std::int64_t w = 2205;
const double f0 = 1.0 / 196.37; // non-integer period (the test-5 adversarial tone)
const std::size_t stream = 20000; // frames fed before exhaustion (several splice cycles)
const double ratios[] = {std::pow(2.0, 7.0 / 12.0), // +7 st (the DAW report regime)
2.0, // octave up
std::pow(2.0, 24.0 / 12.0), // +24 st: fast frozen drain
std::pow(2.0, -5.0 / 12.0), // -5 st (down-shift tail)
1.0}; // unity: frozen delay drains at 1 —
// splices NOW fire even at unity
for (double r : ratios) {
PitchShifter ps;
ps.configure(w);
std::vector<AudioSample> src(stream + 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);
ps.setShiftRatio(r);
for (std::size_t i = 0; i < stream; ++i) {
(void)ps.process(src[i + static_cast<std::size_t>(w)]);
}
// Source exhausted: freeze (idempotent) and keep producing for one full window — the
// longest a Voice runs frozen (its own note end lands within a window of exhaustion).
CHECK(!ps.tailFrozen());
ps.freezeTail();
ps.freezeTail(); // double-freeze harmless
CHECK(ps.tailFrozen());
const std::size_t tail = static_cast<std::size_t>(w);
std::vector<double> out(tail);
for (std::size_t i = 0; i < tail; ++i) {
out[i] = static_cast<double>(ps.process(0.0f)); // input ignored while frozen
CHECK(std::isfinite(out[i]));
}
// (a) No dead stretches: a unit-amplitude tone dwells below 0.05 only a few frames
// per zero crossing; the pre-GA3 DC chop ran hundreds.
std::size_t worstGap = 0, run = 0;
for (std::size_t i = 0; i < tail; ++i) {
if (std::fabs(out[i]) < 0.05) {
++run;
if (run > worstGap) worstGap = run;
} else {
run = 0;
}
}
CHECK(worstGap < 24);
// (b) Full amplitude throughout: every 256-frame block spans > a half period at all
// tested ratios, so a continuous tone peaks near 1.0 in each.
for (std::size_t b = 0; b + 256 <= tail; b += 256) {
double peak = 0.0;
for (std::size_t i = b; i < b + 256; ++i) {
if (std::fabs(out[i]) > peak) peak = std::fabs(out[i]);
}
CHECK(peak > 0.5);
CHECK(peak < 1.1); // aligned complementary fades: no cancellation, no bulge
}
}
// Freeze landing MID-CROSSFADE: at ratio 2 from a fresh prime the tap drains from delay
// w at 1/frame, splices at w/4 (frame 3w/4), then fades for w/4 frames — so frame
// 3w/4 + w/8 is deterministically mid-fade. The frozen writer makes the outgoing tap
// close at the FULL ratio; the transition caps the live fade so it completes before
// reading lapped content — output must stay finite, gap-free, and bounded.
{
PitchShifter ps;
ps.configure(w);
std::vector<AudioSample> src(4 * 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);
ps.setShiftRatio(2.0);
const std::size_t preFreeze = static_cast<std::size_t>(3 * w / 4 + w / 8);
double lastPre = 0.0;
for (std::size_t i = 0; i < preFreeze; ++i) {
lastPre = static_cast<double>(ps.process(src[i + static_cast<std::size_t>(w)]));
}
ps.freezeTail();
std::size_t worstGap = 0, run = 0;
for (std::size_t i = 0; i < static_cast<std::size_t>(w); ++i) {
const double o = static_cast<double>(ps.process(0.0f));
CHECK(std::isfinite(o));
CHECK(std::fabs(o) < 1.1);
if (std::fabs(o) < 0.05) {
++run;
if (run > worstGap) worstGap = run;
} else {
run = 0;
}
}
CHECK(worstGap < 24);
// reset()/prime() clear the freeze: the shifter is fully reusable for the next
// note-on, and a primed unity run is STILL bit-exact zero-latency (no stale state).
ps.reset();
CHECK(!ps.tailFrozen());
ps.prime(src.data(), w);
ps.setShiftRatio(1.0);
std::size_t badZeroLat = 0;
for (std::size_t i = 0; i < 2000; ++i) {
if (ps.process(src[i + static_cast<std::size_t>(w)]) != src[i]) ++badZeroLat;
}
CHECK(badZeroLat == 0);
}
// STEP-DETECTOR: freeze-transition continuity using a ramp source where tapA and tapB
// read values that differ by a predictable constant (≈ A * w / N), making the crossfade
// gain step directly visible in the output. With a ramp, the per-frame natural change is
// A/(N) * ratio ≈ 0.0002 per frame; the un-fixed gain step is ~0.247 * (w/N) ≈ 0.062 —
// roughly 300x the natural rate. A threshold of 0.02 clearly separates fixed from unfixed.
//
// The ramp also defeats correlation-alignment (all lags score equally on a linear ramp),
// so the splice jump of one window guarantees tapA - tapB = A*w/N regardless of lag.
{
PitchShifter ps;
ps.configure(w);
// Ramp from 0.0 to 1.0 over 4*w frames (same buffer size as the mid-crossfade case).
const std::size_t rampLen = 4 * static_cast<std::size_t>(w);
std::vector<AudioSample> ramp(rampLen);
for (std::size_t i = 0; i < rampLen; ++i) {
ramp[i] = static_cast<AudioSample>(static_cast<double>(i) /
static_cast<double>(rampLen - 1));
}
ps.prime(ramp.data(), w);
ps.setShiftRatio(2.0);
// Drive to the deterministic mid-fade freeze point: same preFreeze offset as above.
const std::size_t preFreeze = static_cast<std::size_t>(3 * w / 4 + w / 8);
double lastPre = 0.0;
for (std::size_t i = 0; i < preFreeze; ++i) {
lastPre = static_cast<double>(
ps.process(ramp[i + static_cast<std::size_t>(w)]));
}
ps.freezeTail();
// First frozen frame — if gNew steps at the freeze boundary the output jumps by
// ~deltaGain * (tapA - tapB) ≈ 0.247 * 0.25 = 0.062.
const double firstFrozen = static_cast<double>(ps.process(0.0f));
CHECK(std::isfinite(firstFrozen));
// Natural per-frame ramp advance at ratio 2 ≈ 2/(4*w - 1) ≈ 0.0002; the un-fixed
// step is ~0.062. Threshold 0.02 is 100x the natural rate but well below the step.
const double transitionStep = std::fabs(firstFrozen - lastPre);
CHECK(transitionStep < 0.02);
}
}
int main() {
testDurationInvariance();
testUnityRoughlyReproduces();
testTransposeDirection();
testRtDisciplineAndPassthrough();
testRepitchSpectralPurityAndOnset();
testUnityBitExactAndLatency();
testFreezeTailContinuousTone();
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;
}