fix(pitch_shift): ratio-scale splice fade so +24st up-shifts never read stale data; normalize SOLA correlation by candidate energy; tests bracket 4x/0.5x + unity/latency asserts
This commit is contained in:
@@ -16,7 +16,11 @@
|
||||
// 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).
|
||||
// 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.
|
||||
|
||||
#include "../src/vst/pitch_shift.h"
|
||||
|
||||
@@ -187,9 +191,13 @@ static void testRepitchSpectralPurity() {
|
||||
// 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)
|
||||
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 (nominal-fade boundary)
|
||||
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)
|
||||
for (double r : ratios) {
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
@@ -233,7 +241,14 @@ static void testRepitchSpectralPurity() {
|
||||
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;
|
||||
// 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 = win / 2;
|
||||
double minRms = 1e9, maxRms = 0.0;
|
||||
for (std::size_t s0 = from; s0 + win <= n; s0 += hop) {
|
||||
double e = 0.0;
|
||||
@@ -247,12 +262,42 @@ static void testRepitchSpectralPurity() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6. Unity contract: bit-exact window/2 delay == the latency claim. ---
|
||||
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);
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
CHECK(badSilence == 0);
|
||||
CHECK(badDelay == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDurationInvariance();
|
||||
testUnityRoughlyReproduces();
|
||||
testTransposeDirection();
|
||||
testRtDisciplineAndPassthrough();
|
||||
testRepitchSpectralPurity();
|
||||
testUnityBitExactAndLatency();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all pitch_shift tests passed\n");
|
||||
|
||||
Reference in New Issue
Block a user