fix(preserve): correlation-aligned splices replace dual-tap OLA — fixed w/2 tap offset anti-phase-cancelled crossfades (beating/partials on repitched sines); spectral-purity test added
This commit is contained in:
@@ -12,6 +12,11 @@
|
||||
// 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 (GA-Preserve regression) — a repitched PURE SINE must come out as a
|
||||
// SINGLE tone at the shifted frequency: near-total least-squares fit to the shifted
|
||||
// sinusoid, and no deep amplitude beating across the run. This is the test that fails on
|
||||
// any splice/crossfade phase-alignment defect (the DAW "multiple partials from a sine"
|
||||
// report).
|
||||
|
||||
#include "../src/vst/pitch_shift.h"
|
||||
|
||||
@@ -172,11 +177,82 @@ static void testRtDisciplineAndPassthrough() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5. Spectral purity: a repitched pure sine stays a SINGLE shifted tone. ---
|
||||
static void testRepitchSpectralPurity() {
|
||||
// Frequencies are in cycles/sample (rate-free). The source tone is chosen ADVERSARIALLY:
|
||||
// f0 * (window/2) = 5.5125 cycles, i.e. a fractional part of ~0.51 — content half a window
|
||||
// apart in the ring is near ANTI-PHASE. The old dual-tap design (taps hard-locked w/2
|
||||
// apart) cancelled almost completely at every crossfade midpoint for such tones — the DAW
|
||||
// "severe beating / multiple partials from a pure sine" bug. A correct shifter keeps the
|
||||
// output a single sinusoid at ratio*f0 with a steady amplitude.
|
||||
const std::int64_t w = 2205; // ~50 ms @ 44.1k (the product window)
|
||||
const double f0 = 0.005; // source: period 200 samples
|
||||
const double ratios[] = {std::pow(2.0, 2.0 / 12.0), // +2 semitones (the DAW report: D from C)
|
||||
std::pow(2.0, -3.0 / 12.0), // -3 semitones (down-shift path)
|
||||
2.0}; // octave up (fastest splice cadence)
|
||||
for (double r : ratios) {
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(r);
|
||||
const std::size_t n = 120000;
|
||||
std::vector<double> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const double x = std::sin(2.0 * kPi * f0 * static_cast<double>(i));
|
||||
out[i] = static_cast<double>(ps.process(static_cast<AudioSample>(x)));
|
||||
}
|
||||
|
||||
// Least-squares fit of a*sin + b*cos at the SHIFTED frequency over the settled span
|
||||
// (past 3 windows of onset/latency). Solve the exact 2x2 normal equations so a
|
||||
// non-integer cycle count doesn't leak into the residual.
|
||||
const std::size_t from = static_cast<std::size_t>(3 * w);
|
||||
const double f1 = r * f0;
|
||||
double sss = 0.0, scc = 0.0, ssc = 0.0, sys = 0.0, syc = 0.0;
|
||||
for (std::size_t i = from; i < n; ++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 = from; i < n; ++i) {
|
||||
const double ph = 2.0 * kPi * f1 * static_cast<double>(i);
|
||||
const double fit = a * std::sin(ph) + b * std::cos(ph);
|
||||
const double resid = out[i] - fit;
|
||||
residSq += resid * resid;
|
||||
fitSq += fit * fit;
|
||||
}
|
||||
const std::size_t span = n - from;
|
||||
const double fitRms = std::sqrt(fitSq / static_cast<double>(span));
|
||||
const double residRms = std::sqrt(residSq / static_cast<double>(span));
|
||||
CHECK(fitRms > 0.5); // the shifted tone is actually there (unit sine ~0.707)
|
||||
CHECK(residRms < 0.1 * fitRms); // >=99% of the energy in the ONE shifted tone
|
||||
|
||||
// No beating: sliding-window RMS must not dip (the old design dipped to ~13% of peak).
|
||||
const std::size_t win = 2000, hop = 1000;
|
||||
double minRms = 1e9, maxRms = 0.0;
|
||||
for (std::size_t s0 = from; s0 + win <= n; s0 += hop) {
|
||||
double e = 0.0;
|
||||
for (std::size_t i = s0; i < s0 + win; ++i) e += out[i] * out[i];
|
||||
const double rms = std::sqrt(e / static_cast<double>(win));
|
||||
if (rms < minRms) minRms = rms;
|
||||
if (rms > maxRms) maxRms = rms;
|
||||
}
|
||||
CHECK(maxRms > 0.0);
|
||||
CHECK(minRms > 0.8 * maxRms); // steady amplitude — no crossfade cancellation
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDurationInvariance();
|
||||
testUnityRoughlyReproduces();
|
||||
testTransposeDirection();
|
||||
testRtDisciplineAndPassthrough();
|
||||
testRepitchSpectralPurity();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all pitch_shift tests passed\n");
|
||||
|
||||
Reference in New Issue
Block a user