fix(preserve): prime SOLA rings with real source (zero-latency, gap-free onset) + sub-sample splice alignment — clean repitch C1..C8

This commit is contained in:
2026-07-28 08:49:05 -04:00
parent e99bdcf264
commit 2ef3514bc7
6 changed files with 447 additions and 238 deletions
+124 -89
View File
@@ -12,15 +12,17 @@
// 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). Ratios bracket the real playable range: +24 st (ratio 4 — the geometry-fix
// target where an unscaled fade reads stale data) and a full octave down included.
// 6. unity contract — the header's two hard claims, asserted bit-exactly: at ratio 1.0 the
// shifter IS a clean window/2 delay (out[i] == in[i - w/2] to the bit; no splice, no
// interpolation error), which is simultaneously the latency == window/2 assertion.
// 5. spectral purity + onset integrity (GA / GA2 regressions) — a PRIMED repitched PURE
// SINE must come out as a SINGLE tone at the shifted frequency FROM THE VERY FIRST
// MILLISECOND: no zero-gaps anywhere (the GA2 DAW report: silence-warmed rings made
// every early splice jump into zeros — burst/gap/burst stutter in the first few ms),
// and a per-block least-squares residual floor that catches harmonics, splice-cadence
// sideband combs, and crossfade cancellation alike. Ratios cover the FULL playable
// range the DAW report exercised: +2/-3 st, +/-1 octave, +24 st, +48 st (C8 from C4,
// ratio 16) and -36 st (C1 from C4, ratio 1/8).
// 6. unity + latency contract — asserted bit-exactly: a warm()ed shifter at ratio 1.0 IS a
// clean window delay; a prime()d one has ZERO added latency (out[i] == src[i] to the
// bit) — the GA2 immediate-onset claim.
#include "../src/vst/pitch_shift.h"
@@ -181,8 +183,9 @@ static void testRtDisciplineAndPassthrough() {
}
}
// --- 5. Spectral purity: a repitched pure sine stays a SINGLE shifted tone. ---
static void testRepitchSpectralPurity() {
// --- 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 = 1102/196 ≈ 5.622 cycles (frac ≈ 0.622) — content half a
@@ -196,106 +199,138 @@ static void testRepitchSpectralPurity() {
// is 22.05 (frac ≈ 0.05), near a zero-crossing — the artifact is near-benign, so the
// +24 st purity case would pass even with the clamping reverted. f0=1/196 forces the
// half-integer alignment that makes the pre-fix artifact catastrophic.
//
// 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.0; // source: period 196 samples; see adversarial note above
const double ratios[] = {std::pow(2.0, 2.0 / 12.0), // +2 semitones (the DAW report: D from C)
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 (nominal-fade boundary)
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, -12.0 / 12.0)}; // octave down (full down-shift path)
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);
ps.warm();
ps.setShiftRatio(r);
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) {
const double x = std::sin(2.0 * kPi * f0 * static_cast<double>(i));
out[i] = static_cast<double>(ps.process(static_cast<AudioSample>(x)));
out[i] = static_cast<double>(ps.process(src[i + static_cast<std::size_t>(w)]));
}
// 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);
// (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;
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 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 30 dB under the tone.
// (Post-fix the engine measures ~-75 dB and better; the old integer-lag splices
// sat near -59 dB sidebands and the warm-zero onset failed outright.)
CHECK(residRms < 0.0316 * fitRms);
}
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).
// The window must RESOLVE a within-fade dip (the ratio-4 fade is only ~w/12 = 183
// frames; the original win=2000 averaged straight over total cancellation), yet a
// window that is not an integer number of output periods has phase-dependent RMS on a
// pure sine (at ratio 0.5 the output period is 400 frames — a fixed 256 window dips
// to ~0.78 of max on the CLEAN signal alone). Smallest phase-clean choice: exactly one
// output period per window (50..400 frames here), hop of half a window.
const std::size_t win = static_cast<std::size_t>(std::lround(1.0 / f1));
const std::size_t hop = std::max<std::size_t>(1, win / 2);
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
}
}
// --- 6. Unity contract: bit-exact window/2 delay == the latency claim. ---
// --- 6. Unity + latency contract: warm = bit-exact window delay; primed = bit-exact ZERO
// latency. ---
static void testUnityBitExactAndLatency() {
// The header claims a configured shifter at ratio 1.0 is a CLEAN window/2 delay: the tap
// is parked mid-band (no splice ever fires) at an integral delay (no interpolation error),
// so every output equals the input from exactly w/2 frames earlier TO THE BIT. This is
// simultaneously the latency assertion: steady-state latency == window/2, no more, no
// less. warm() has already consumed the cold-start region, so the first w/2 outputs are
// the tail of the warm-up silence and everything after is the delayed input verbatim.
const std::int64_t w = 2205; // the product window (odd: w/2 truncates)
const std::int64_t lat = w / 2; // 1102
PitchShifter ps;
ps.configure(w);
ps.warm();
ps.setShiftRatio(1.0);
// 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, 37.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>(lat); ++i) {
if (out[i] != 0.0f) ++badSilence; // pre-latency region: warm-up silence, exact
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);
}
for (std::size_t i = static_cast<std::size_t>(lat); i < n; ++i) {
if (out[i] != in[i - static_cast<std::size_t>(lat)]) ++badDelay; // bit-exact delay
// 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);
}
CHECK(badSilence == 0);
CHECK(badDelay == 0);
}
int main() {
@@ -303,7 +338,7 @@ int main() {
testUnityRoughlyReproduces();
testTransposeDirection();
testRtDisciplineAndPassthrough();
testRepitchSpectralPurity();
testRepitchSpectralPurityAndOnset();
testUnityBitExactAndLatency();
if (g_fail == 0) {