Files
reasampler/tests/test_pitch_shift.cpp
T
daniel 93230208ff Γ-W1-T7: make Preserve's splices pitch-synchronous — the jump is a whole number of the source's own period, detected once at load
30 Hz out-of-band energy 15.45% -> 0.00%; the 29 Hz rate-2.0 detune -133 -> +0 cents.
An unknown period keeps the fixed-window geometry bit for bit. The detector cannot
reach process(): sampler_core does not link it.
2026-08-02 13:50:14 -04:00

1058 lines
56 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.
// 10. pitch-synchronous splices — the nominal jump snapped to a whole number of source
// periods: the jump law itself, the bit-identical unknown-period fallback, 30 Hz and
// 29 Hz (the two symptoms of the unalignable gap), and the cadence corner, which this
// leaves where it found it.
#include "../src/core/instrument/engine/pitch_shift.h"
#include "tone_metrics.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#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.
// `sourcePeriod` > 0 puts the shifter on the pitch-synchronous jump the loader would have
// given it; 0 (the default) is the fixed-window fallback every pre-PSOLA call here exercises.
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, double sourcePeriod = 0.0) {
PitchShifter ps;
ps.configure(w);
ps.prime(src.data(), w);
ps.setShiftRatio(shift);
ps.setFeedRate(feedRate);
ps.setSourcePeriod(sourcePeriod);
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 is 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. Those readings are stable and are what the bounds below hold.
//
// **CORRECTED — what those three readings MEAN.** They were once read as the corner's damage.
// They are almost entirely the metric's own floor: an ideal tone at the same want-period,
// measured identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because
// a long period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6%
// band. The corner's real EXCESS over that floor is 0.23% / 0.51% / 0.30% — small, real, and
// nothing like the headline numbers. The alignable control's 0.10% is genuinely near-zero only
// because its want-period is short enough to have almost no floor. The bounds below are kept
// as a stable regression tripwire on the raw readings; read the excess, not the reading.
//
// This measures the FIXED-WINDOW path — no source period is set, which is what a capture with
// no single period (percussive, polyphonic, noise) gets. What the same corner does once the
// splice is pitch-synchronous is the test immediately after this one.
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);
}
}
// --- 10. Pitch-synchronous splices: the nominal jump is a whole number of SOURCE periods. ---
// The out-of-band metric's OWN floor at a given period: a mathematically perfect tone, measured
// with exactly the parameters a render is. A long period under a fixed segment leaks part of
// its own mainlobe outside the +/-6% band, and that leakage grows steeply with the period — so
// a raw reading at period 2800 is not comparable with one at period 800, and neither is
// comparable with zero. The EXCESS over this floor is the honest "how much of this render is
// not the tone" number.
static double idealToneFloorPercent(double wantPeriod, std::size_t from, std::size_t len) {
std::vector<double> v(from + len + 2);
for (std::size_t i = 0; i < v.size(); ++i) {
v[i] = std::sin(2.0 * kPi * static_cast<double>(i) / wantPeriod);
}
return reasampler::test_support::energyOutsideFundamentalPercent(v, from, len, wantPeriod);
}
// A splice can only phase-align on a landing point a whole number of source periods away, and
// the search only reaches [0.75, 1.25] windows. periodAlignedJump is what puts an aligned point
// at the CENTRE of that interval instead of hoping one falls inside it.
static void testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound() {
const std::int64_t w = 2205; // the product window at 44.1k
const std::int64_t maxJump = 2756; // 1.25 * w, the shifter's own jumpMax_
// Unknown period, and a period too long for even ONE whole one to fit: the fixed window,
// unchanged. Both are the documented fallback, and both must be EXACTLY today's geometry.
CHECK(periodAlignedJump(w, maxJump, 0.0) == w);
CHECK(periodAlignedJump(w, maxJump, -5.0) == w);
CHECK(periodAlignedJump(w, maxJump, 3000.0) == w);
// 30 Hz at 44.1k (P = 1470): two periods overshoot the bound, so it takes ONE — which is
// the case the whole track exists for. The pre-PSOLA geometry could reach neither 1470 nor
// 2940 from a 2205 nominal, since the search only spans [1654, 2756].
CHECK(periodAlignedJump(w, maxJump, 1470.0) == 1470);
CHECK(2 * 1470 > maxJump); // the witness that one period is forced, not merely chosen
// 220 Hz (P = 200.4545): eleven periods land within a frame of the window itself, so the
// splice cadence is essentially untouched while every landing is aligned.
CHECK(periodAlignedJump(w, maxJump, 44100.0 / 220.0) == 2205);
// A period just under the bound is taken whole; the result is never over the bound, at any
// period in the band. Sweeping is what proves the shrink loop terminates correctly rather
// than one hand-picked value doing so.
for (double p = 20.0; p < 3200.0; p += 0.37) {
const std::int64_t j = periodAlignedJump(w, maxJump, p);
CHECK(j >= 1);
if (p > static_cast<double>(maxJump)) {
CHECK(j == w); // out of reach -> fallback
} else {
CHECK(j <= maxJump);
// Aligned: the jump is a whole number of periods, to within the rounding to frames.
const double n = static_cast<double>(j) / p;
CHECK(std::fabs(n - std::floor(n + 0.5)) * p < 0.51);
}
}
}
// setSourcePeriod(0) and never calling it are the same state, not merely similar ones — the
// cheap half of the fallback claim. The EXPENSIVE half, that the fallback still matches the
// engine as it shipped, is testPreserveUnityRateIsBitIdenticalToTheShippedRead in
// test_sampler_core.cpp: it hashes four rendered streams (including transposed ones that
// really splice) against a baseline captured from commit 0a7778b, and it passes unmodified.
static void testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry() {
const std::int64_t w = 2205;
const std::vector<AudioSample> src = sine(400000, 400000.0 / 196.37);
const std::vector<double> never = runStretch(src, w, 1.0, 1.5, 40000, nullptr);
const std::vector<double> zeroed = runStretch(src, w, 1.0, 1.5, 40000, nullptr, 0.0);
bool same = true;
for (std::size_t i = 0; i < never.size(); ++i) if (never[i] != zeroed[i]) same = false;
CHECK(same);
}
// THE case this track exists for. 30 Hz sits in the only unalignable gap above 16 Hz at the
// product's 50 ms window: its nearest whole multiple misses the reachable interval by 184
// frames (45 degrees of phase), and the investigation measured the resulting sidebands at
// 3.57% out-of-band at +2 st / rate 1.0 and 15.45% at rate 2.0, against 0.00% on alignable
// controls. Here the same two conditions run with and without the source period, against a
// 34 Hz control that was alignable all along.
//
// The absolute numbers are NOT the harness's: a 1470-frame period under a 32768-frame segment
// leaks part of its own mainlobe outside the +/-6% band, so every reading here carries the same
// floor. That is exactly why the control is measured at the same length — the assertion is that
// 30 Hz reaches the control's floor, not that it reaches zero.
static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() {
using reasampler::test_support::energyOutsideFundamentalPercent;
const std::int64_t w = 2205;
const std::size_t srcLen = 400000, outFrames = 60000, from = 20000, len = 32768;
struct Row { const char* label; double freq; double rate; double semis; };
const Row rows[] = {
{"30 Hz +2 st, rate 1.0", 30.0, 1.0, 2.0},
{"30 Hz rate 2.0", 30.0, 2.0, 0.0},
{"34 Hz +2 st, rate 1.0", 34.0, 1.0, 2.0}, // control: alignable without a period
{"34 Hz rate 2.0", 34.0, 2.0, 0.0},
};
double controlWorst = 0.0, subjectWorst = 0.0;
for (const Row& r : rows) {
const double period = 44100.0 / r.freq;
std::vector<AudioSample> src(srcLen);
for (std::size_t i = 0; i < srcLen; ++i) {
src[i] = static_cast<AudioSample>(
std::sin(2.0 * kPi * static_cast<double>(i) / period));
}
const double shift = std::pow(2.0, r.semis / 12.0);
const double want = period / shift;
const std::vector<double> off = runStretch(src, w, r.rate, shift, outFrames, nullptr);
const std::vector<double> on =
runStretch(src, w, r.rate, shift, outFrames, nullptr, period);
for (double v : on) CHECK(std::isfinite(v));
const double floor = idealToneFloorPercent(want, from, len);
const double pctOff = energyOutsideFundamentalPercent(off, from, len, want) - floor;
const double pctOn = energyOutsideFundamentalPercent(on, from, len, want) - floor;
std::printf(" [30 Hz] %-24s (want %6.1f fr, metric floor %.2f%%) excess energy: "
"fixed window %6.2f%% -> pitch-synchronous %6.2f%%\n", r.label, want, floor,
pctOff, pctOn);
if (r.freq == 34.0) controlWorst = std::max(controlWorst, pctOn);
else subjectWorst = std::max(subjectWorst, pctOn);
}
// 30 Hz stops being a special case: with the period known its excess over the metric's own
// floor is no worse than the alignable neighbour's, measured identically. Against the
// control rather than against a fixed number, so the assertion cannot be satisfied by a
// change that merely raised the floor everywhere.
std::printf(" [30 Hz] worst subject excess %.2f%% vs worst control excess %.2f%%\n",
subjectWorst, controlWorst);
CHECK(subjectWorst < 0.10);
CHECK(subjectWorst <= controlWorst + 0.05); // 0.05 absorbs the floor subtraction's sign noise
}
// The sharpest single symptom of the geometry: at 29 Hz the nearest multiple misses the
// reachable interval ONE-SIDED rather than straddling, so the per-splice phase steps stop
// cancelling and accumulate into a real detune — the investigation measured -133 cents at
// rate 2.0 with NO transposition at all. Rate moves duration; it must not move pitch.
static void testTwentyNineHertzAtRateTwoKeepsItsPitch() {
using reasampler::test_support::autocorrelationPeriod;
const std::int64_t w = 2205;
const double period = 44100.0 / 29.0; // 1520.7 frames
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 * static_cast<double>(i) / period));
}
auto centsOf = [&](double sourcePeriod) {
const std::vector<double> out =
runStretch(src, w, /*rate=*/2.0, /*shift=*/1.0, 60000, nullptr, sourcePeriod);
const double got = autocorrelationPeriod(out, 20000, 20000,
static_cast<std::int64_t>(period * 0.5),
static_cast<std::int64_t>(period * 1.7));
return 1200.0 * std::log2(got / period);
};
const double centsOff = centsOf(0.0);
const double centsOn = centsOf(period);
std::printf(" [29 Hz] rate 2.0, no transposition: fixed window %+.1f cents -> "
"pitch-synchronous %+.1f cents\n", centsOff, centsOn);
CHECK(std::fabs(centsOn) < 10.0);
// The fixed-window reading is asserted too, and that is what makes the pair non-vacuous: a
// setSourcePeriod that silently did nothing would render both identically and fail here.
CHECK(std::fabs(centsOff) > 50.0);
}
// The cadence corner (rate 2.0, -24 st, source periods above the 315-frame safe floor) is the
// OTHER mechanism — a splice landing inside a single perceived cycle. Measured against the
// metric's own floor, PSOLA moves it by nothing: 0.23/0.51/0.30% excess becomes 0.24/0.49/0.30%.
//
// That is not a shortfall, it is what the corner turned out to be. Correcting the previous
// test's reading (see its comment) shrank the corner from a 7-21% headline to a sub-1% excess,
// which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each splice
// land in phase, and these splices already did; what it cannot do is make them less frequent.
// So this asserts NO REGRESSION, not an improvement, and says so rather than claiming one.
static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
using reasampler::test_support::energyOutsideFundamentalPercent;
const std::int64_t w = 2205;
const double shift = std::pow(2.0, -24.0 / 12.0);
const std::size_t outFrames = 60000, from = 20000, len = 32768;
for (double period : {500.0, 600.0, 700.0}) {
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 * static_cast<double>(i) / period));
}
const std::vector<double> off = runStretch(src, w, 2.0, shift, outFrames, nullptr);
const std::vector<double> on =
runStretch(src, w, 2.0, shift, outFrames, nullptr, period);
for (double v : on) CHECK(std::isfinite(v));
const double want = period / shift;
const double floor = idealToneFloorPercent(want, from, len);
const double pctOff = energyOutsideFundamentalPercent(off, from, len, want);
const double pctOn = energyOutsideFundamentalPercent(on, from, len, want);
std::printf(" [cadence corner, PSOLA] period %.0f (want %.0f, metric floor %.2f%%): "
"excess %.2f%% -> %.2f%%\n", period, want, floor, pctOff - floor,
pctOn - floor);
CHECK(pctOn - floor < 1.0); // the corner's real excess, PSOLA or not
CHECK(pctOn < pctOff + 0.05); // and PSOLA costs it nothing
}
}
// 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();
testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound();
testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry();
testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown();
testTwentyNineHertzAtRateTwoKeepsItsPitch();
testCadenceCornerIsUnmovedByAPitchSynchronousSplice();
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;
}