Files
reasampler/tests/test_pitch_shift.cpp
T
daniel abace156a5 Fix inverted splice-cadence test: assert artifact energy, not zero-crossing period
Zero-crossing counting was anti-correlated with the real defect (splice debris
fools it). Now asserts energy outside the fundamental, with an alignable control,
matching test_preserve_low_frequency.cpp's approach.
2026-08-02 13:47:19 -04:00

838 lines
43 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.
// 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>
#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)
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);
}
}
// --- 8. Stereo linked lag (Q-W0 T1-01): a follower channel driven via processLinked()
// applies EXACTLY the master's splice decision — same firing frame, same jump, same
// lag, same sub-sample frac, same fade length — so a stereo pair shares ONE splice
// schedule (no inter-channel offset re-drawn per splice: the pre-fix image-wander /
// mono-sum-combing mechanism). The divergence witness: an INDEPENDENT shifter fed the
// follower's content picks a different lag on the same schedule, proving the mirror
// assertion is not vacuous (the two channels' contents genuinely disagree on the best
// alignment). A third shifter (`mirror`), primed with the SAME content as the master
// and driven via processLinked() with the master's own decisions, must reproduce the
// master's output BIT-IDENTICALLY every frame — this is the review-rider strengthening:
// the `ef == em` mirror check above only proves lastSplice_ was copied verbatim (which
// applySplice() always does), not that applySplice() actually reproduces splice()'s
// effect on posA_/fadeLen_/audio output; a same-content bit-identical check catches a
// real divergence there (e.g. an asymmetry between applySplice()'s unconditional
// `max(1, ev.fadeLen)` and splice()'s own fadeLen_ assignment). This driven-every-frame
// setup keeps both master and follower in lockstep the whole run (posA_/writePos_ stay
// identical since jumps are geometric, not content-dependent), so it exercises
// applySplice() on every splice — never the Q-W0 remediation self-healing fallback
// (own-search splice on a stale follower), which only fires when a follower has been
// skipped a block relative to the master (mono-render-block starvation). ---
static void testStereoLinkedLagSharedSchedule() {
const std::int64_t w = 2205; // the product window
const std::size_t n = 40000; // ~17 splice cycles at ratio 2
// Decorrelated "stereo" content: two different non-integer-period tones, so each
// channel's own correlation optimum lands on a different lag.
const double fL = 1.0 / 196.37;
const double fR = 1.0 / 123.13;
std::vector<AudioSample> srcL(n + static_cast<std::size_t>(w));
std::vector<AudioSample> srcR(n + static_cast<std::size_t>(w));
for (std::size_t i = 0; i < srcL.size(); ++i) {
srcL[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * fL * static_cast<double>(i)));
srcR[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * fR * static_cast<double>(i)));
}
PitchShifter master, follower, independent, mirror;
master.configure(w);
follower.configure(w);
independent.configure(w);
mirror.configure(w);
master.prime(srcL.data(), w);
follower.prime(srcR.data(), w); // linked: R content, master's decisions
independent.prime(srcR.data(), w); // control: R content, OWN search (pre-fix behavior)
mirror.prime(srcL.data(), w); // SAME content as master: bit-identical witness
master.setShiftRatio(2.0);
follower.setShiftRatio(2.0);
independent.setShiftRatio(2.0);
mirror.setShiftRatio(2.0);
int spliceCount = 0;
bool followerDiverged = false;
bool independentDiverged = false;
bool mirrorDiverged = false;
for (std::size_t i = 0; i < n; ++i) {
const std::size_t si = i + static_cast<std::size_t>(w);
const AudioSample oM = master.process(srcL[si]);
const SpliceEvent& em = master.lastSplice();
const AudioSample oR = follower.processLinked(srcR[si], em);
CHECK(std::isfinite(oR));
// The follower mirrors the master's decision EXACTLY, every frame (fired == false
// frames included). In this driven-every-frame lockstep run the follower never falls
// behind, so it never reaches the Q-W0 self-healing fallback — every splice here goes
// through applySplice(), same as the mirror check below.
const SpliceEvent& ef = follower.lastSplice();
if (ef.fired != em.fired || ef.jump != em.jump || ef.lag != em.lag ||
ef.frac != em.frac || ef.fadeLen != em.fadeLen) {
followerDiverged = true;
}
if (em.fired) ++spliceCount;
// The control: same content as the follower, own search. Its decision differing
// from the master's proves the mirror assertion above is load-bearing.
(void)independent.process(srcR[si]);
const SpliceEvent& ei = independent.lastSplice();
if (ei.fired != em.fired || ei.lag != em.lag || ei.frac != em.frac) {
independentDiverged = true;
}
// The bit-identical witness: same content as the master, master's decisions applied
// via applySplice() instead of computed via splice() — the two code paths must produce
// the exact same sample stream.
const AudioSample oMirror = mirror.processLinked(srcL[si], em);
if (oMirror != oM) mirrorDiverged = true;
}
CHECK(spliceCount >= 3); // the run actually exercised several splices
CHECK(!followerDiverged); // linked lag: one decision, one schedule, both channels
CHECK(independentDiverged); // non-tautology witness: unlinked channels DO disagree
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();
testTransposeDirection();
testRtDisciplineAndPassthrough();
testRepitchSpectralPurityAndOnset();
testUnityBitExactAndLatency();
testFreezeTailContinuousTone();
testStereoLinkedLagSharedSchedule();
testStretchMovesDurationNotPitch();
testStretchAndShiftComposeSafely();
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
testStretchEntryPointsOnPassThrough();
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;
}