589a8e078b
Generalizes the correlation-aligned SOLA delay line so the feed and the shift are independent rates over one ring. Unity is bit-identical to the shipped read, asserted against a hash baseline captured pre-change.
770 lines
39 KiB
C++
770 lines
39 KiB
C++
// Standalone tests for reasampler::PitchShifter — the S16 Preserve-engine DSP core. No VST3,
|
||
// no REAPER, no vendor, no test framework. The compile-time proof it does NOT drag the WDL
|
||
// <windows.h> chain is the CMake target linking only pitch_shift (+ peaks).
|
||
//
|
||
// Covers (PLAN.md S16 / CONTEXT.md §Pitch engine modes — Preserve):
|
||
// 1. duration invariance — N inputs yield N outputs at every shift ratio (the load-bearing
|
||
// Preserve property: a transposed render is the SAME frame length as the un-transposed one).
|
||
// 2. unity pass-through fidelity — ratio 1.0 reproduces the input closely (a shifter at unity
|
||
// must not mangle the signal).
|
||
// 3. transpose direction — an octave-up shift raises the observed pitch (period shortens), an
|
||
// octave-down lowers it (period lengthens), measured on a synthesized sine.
|
||
// 4. RT discipline surrogate — after configure()+warm() (the off-thread setup), a long
|
||
// process() run never resizes the ring (checked via window() constancy) and never returns
|
||
// NaN/inf; pass-through (unconfigured) returns input verbatim.
|
||
// 5. spectral purity + onset integrity (GA / GA2 regressions) — a PRIMED repitched PURE
|
||
// SINE must come out as a SINGLE tone at the shifted frequency FROM THE VERY FIRST
|
||
// MILLISECOND: no zero-gaps anywhere (the GA2 DAW report: silence-warmed rings made
|
||
// every early splice jump into zeros — burst/gap/burst stutter in the first few ms),
|
||
// and a per-block least-squares residual floor that catches harmonics, splice-cadence
|
||
// sideband combs, and crossfade cancellation alike. Ratios cover the FULL playable
|
||
// range the DAW report exercised: +2/-3 st, +/-1 octave, +24 st, +48 st (C8 from C4,
|
||
// ratio 16) and -36 st (C1 from C4, ratio 1/8).
|
||
// 6. unity + latency contract — asserted bit-exactly: a warm()ed shifter at ratio 1.0 IS a
|
||
// clean window delay; a prime()d one has ZERO added latency (out[i] == src[i] to the
|
||
// bit) — the GA2 immediate-onset claim.
|
||
// 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 <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 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();
|
||
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;
|
||
}
|