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:
2026-07-28 06:37:38 -04:00
parent 22d7893431
commit 436a685984
3 changed files with 103 additions and 19 deletions
+45 -11
View File
@@ -40,7 +40,7 @@ void PitchShifter::configure(std::int64_t windowFrames) {
posA_ = posB_ = 0.0;
fading_ = false;
fadePos_ = 0;
fadeFrames_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
ratio_ = 1.0;
return;
}
@@ -48,17 +48,23 @@ void PitchShifter::configure(std::int64_t windowFrames) {
ringLen_ = 2 * window_;
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
// Geometry (all quarters of the window):
// - fadeFrames_: the splice crossfade — long enough to be smooth, short enough that the
// outgoing tap cannot cross the writer mid-fade for ratios up to ~2x/0.5x.
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade.
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any
// tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
// - corrFrames_: the dot-product length (capped so a splice burst stays bounded).
// - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay).
// - corrFrames_: the correlation segment length. At an up-splice the reference segment
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists
// between the tap and the writer — the cap expresses that safety rather than leaving
// it coincidental. 512 bounds the splice burst.
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
maxLag_ = window_ / 4;
corrFrames_ = std::min<std::int64_t>(window_ / 4, 512);
dLow_ = window_ / 4;
dHigh_ = ringLen_ - window_ / 4;
corrFrames_ = std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512));
fadeLen_ = 0;
reset();
}
@@ -72,11 +78,13 @@ void PitchShifter::reset() {
posB_ = posA_;
fading_ = false;
fadePos_ = 0;
fadeLen_ = 0;
} else {
writePos_ = 0;
posA_ = posB_ = 0.0;
fading_ = false;
fadePos_ = 0;
fadeLen_ = 0;
}
ratio_ = 1.0;
}
@@ -118,14 +126,22 @@ void PitchShifter::splice(std::int64_t nominalJump) {
auto scoreAt = [&](std::int64_t lag) -> double {
std::int64_t ia = iA;
std::int64_t ic = ((iA - nominalJump + lag) % ringLen_ + ringLen_) % ringLen_;
double s = 0.0;
double s = 0.0, ec = 0.0;
for (std::int64_t k = 0; k < corrFrames_; ++k) {
s += static_cast<double>(ring_[static_cast<std::size_t>(ia)]) *
static_cast<double>(ring_[static_cast<std::size_t>(ic)]);
const double a = static_cast<double>(ring_[static_cast<std::size_t>(ia)]);
const double c = static_cast<double>(ring_[static_cast<std::size_t>(ic)]);
s += a * c;
ec += c * c;
if (++ia >= ringLen_) ia = 0;
if (++ic >= ringLen_) ic = 0;
}
return s;
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward
// the higher-energy lag, so on a decaying tail every up-splice would prefer the
// loudest candidate over the best-ALIGNED one — a small level step per splice that
// the amplitude-complementary fade cannot hide. The reference segment's energy is
// constant across lags, so dividing by sqrt(Ec) alone ranks identically to the full
// normalized form. A zero-energy candidate scores 0 (splicing into silence is benign).
return ec > 0.0 ? s / std::sqrt(ec) : 0.0;
};
std::int64_t bestLag = 0;
@@ -156,6 +172,24 @@ void PitchShifter::splice(std::int64_t nominalJump) {
while (p < 0.0) p += len;
while (p >= len) p -= len;
posA_ = p;
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st =
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal
// fade; ratio 4 gets ~window/12 — shorter but still a smooth burst. Down-shifts grow the
// outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within
// window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew
// mid-fade is covered by the same margin for any realistic per-frame bias.
fadeLen_ = fadeFrames_;
if (ratio_ > 1.0) {
const double headroom = static_cast<double>(dLow_) - (ratio_ - 1.0) - 2.0;
const std::int64_t safe =
headroom > 0.0 ? static_cast<std::int64_t>(headroom / (ratio_ - 1.0)) : 1;
fadeLen_ = std::max<std::int64_t>(1, std::min(fadeFrames_, safe));
}
fading_ = true;
fadePos_ = 0;
}
@@ -171,10 +205,10 @@ AudioSample PitchShifter::process(AudioSample in) {
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
double out = readTap(posA_);
if (fading_) {
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeFrames_);
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
const double gNew = 0.5 * (1.0 - std::cos(kPi * t));
out = gNew * out + (1.0 - gNew) * readTap(posB_);
if (++fadePos_ >= fadeFrames_) fading_ = false;
if (++fadePos_ >= fadeLen_) fading_ = false;
} else {
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
+8 -3
View File
@@ -105,10 +105,15 @@ private:
double posA_ = 0.0; // active read tap (advances at the shift ratio)
double posB_ = 0.0; // outgoing tap during a splice crossfade
bool fading_ = false; // a splice crossfade is in flight
std::int64_t fadePos_ = 0; // crossfade progress, [0, fadeFrames_)
std::int64_t fadeFrames_ = 0; // crossfade length (window_/4)
std::int64_t fadePos_ = 0; // crossfade progress, [0, fadeLen_)
std::int64_t fadeFrames_ = 0; // NOMINAL crossfade length (window_/4)
std::int64_t fadeLen_ = 0; // LIVE crossfade length for the in-flight splice —
// ratio-scaled at splice time so an up-shift's outgoing
// tap can never drain into the writer mid-fade
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
std::int64_t corrFrames_ = 0; // correlation dot-product length (window_/4, capped)
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind
// the writer BY CONSTRUCTION at an up-splice)
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
double ratio_ = 1.0; // current shift ratio (>0)