fix(pitch_shift): re-anchor fadePos_ on freeze so gNew is continuous at the freeze frame; guard left cast; add step-detector + loop-never-freezes regression

This commit is contained in:
2026-07-28 10:09:23 -04:00
parent 917626a287
commit 4f7e15390f
3 changed files with 65 additions and 4 deletions
+15 -3
View File
@@ -105,15 +105,27 @@ void PitchShifter::freezeTail() {
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at // An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at
// ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live // ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest- // fade so it completes before tap B reaches the parked writer and reads lapped (oldest-
// window) content mid-fade. `+1` keeps fadeLen_ > fadePos_, so t stays < 1 in process(). // window) content mid-fade. fadePos_ is re-anchored to the same fractional t so gNew is
// continuous at the freeze frame (no gain step); see the re-anchor block below.
if (fading_) { if (fading_) {
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
const double tOld =
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
double dB = static_cast<double>(writePos_) - posB_; double dB = static_cast<double>(writePos_) - posB_;
const double len = static_cast<double>(ringLen_); const double len = static_cast<double>(ringLen_);
while (dB < 0.0) dB += len; while (dB < 0.0) dB += len;
while (dB >= len) dB -= len; while (dB >= len) dB -= len;
const double left = (dB - 2.0) / ratio_; // Clamp in double before the int64 cast (matches splice() pattern; guards against UB
// when dB/ratio_ is very large, e.g. near-unity ratio at a high sample rate).
double left = (dB - 2.0) / ratio_;
if (left > static_cast<double>(fadeFrames_)) left = static_cast<double>(fadeFrames_);
const std::int64_t leftFrames = left > 1.0 ? static_cast<std::int64_t>(left) : 1; const std::int64_t leftFrames = left > 1.0 ? static_cast<std::int64_t>(left) : 1;
fadeLen_ = std::min(fadeLen_, fadePos_ + leftFrames); const std::int64_t newFadeLen = std::min(fadeLen_, fadePos_ + leftFrames);
// Re-anchor: tOld < 1 because we are mid-fade, so newFadePos < newFadeLen (still fading).
fadePos_ = static_cast<std::int64_t>(tOld * static_cast<double>(newFadeLen));
fadeLen_ = newFadeLen;
} }
} }
+40 -1
View File
@@ -421,8 +421,9 @@ static void testFreezeTailContinuousTone() {
ps.prime(src.data(), w); ps.prime(src.data(), w);
ps.setShiftRatio(2.0); ps.setShiftRatio(2.0);
const std::size_t preFreeze = static_cast<std::size_t>(3 * w / 4 + w / 8); 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) { for (std::size_t i = 0; i < preFreeze; ++i) {
(void)ps.process(src[i + static_cast<std::size_t>(w)]); lastPre = static_cast<double>(ps.process(src[i + static_cast<std::size_t>(w)]));
} }
ps.freezeTail(); ps.freezeTail();
std::size_t worstGap = 0, run = 0; std::size_t worstGap = 0, run = 0;
@@ -450,6 +451,44 @@ static void testFreezeTailContinuousTone() {
} }
CHECK(badZeroLat == 0); 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);
}
} }
int main() { int main() {
+10
View File
@@ -1318,6 +1318,16 @@ static void testPreserveGateStereoLoopComposes() {
} }
CHECK(maxL > 0.05); CHECK(maxL > 0.05);
CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved
// Loop-never-freezes regression: the Preserve voice must NOT spuriously freeze when the
// read tap hits the sample end and the loop wraps it back. A spuriously frozen voice
// stops writing to the ring and the looped tail would go silent past the sample end.
// Check a block far past the sample end (sample = 400 frames; window = 512; well past
// any single-pass tail region) to catch any wrap-before-exhaustion ordering error.
double maxLFar = 0.0;
for (std::size_t i = 1800; i < 2000; ++i) {
if (std::fabs(left[i]) > maxLFar) maxLFar = std::fabs(left[i]);
}
CHECK(maxLFar > 0.05); // still alive at frame 1800 (4.5× the 400-frame sample length)
} }
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. --- // --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---