diff --git a/src/vst/pitch_shift.cpp b/src/vst/pitch_shift.cpp index ec95ec0..ed6cdf7 100644 --- a/src/vst/pitch_shift.cpp +++ b/src/vst/pitch_shift.cpp @@ -186,9 +186,12 @@ void PitchShifter::splice(std::int64_t nominalJump) { fadeLen_ = fadeFrames_; if (ratio_ > 1.0) { const double headroom = static_cast(dLow_) - (ratio_ - 1.0) - 2.0; - const std::int64_t safe = - headroom > 0.0 ? static_cast(headroom / (ratio_ - 1.0)) : 1; - fadeLen_ = std::max(1, std::min(fadeFrames_, safe)); + // Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios + // at very high sample rates (where headroom/(ratio_-1.0) could overflow int64). + const double safeDbl = headroom > 0.0 + ? std::min(headroom / (ratio_ - 1.0), static_cast(fadeFrames_)) + : 1.0; + fadeLen_ = std::max(1, static_cast(safeDbl)); } fading_ = true; fadePos_ = 0; diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index b909bf8..73f441a 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -183,14 +183,21 @@ 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. + // 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 + // window apart in the ring is near ANTI-PHASE. The old dual-tap design cancelled + // almost completely at every crossfade midpoint for such tones — the DAW "severe + // beating / multiple partials from a pure sine" bug. + // (b) ringLen_*f0 = 4410/196 = 22.5 EXACTLY — at ratio 4 the write head advances 4 taps + // per output frame, so each splice-period the outgoing tap crosses the writer at the + // HALF-period point of the source waveform (sign flip), producing a visible null when + // gNew == gOld if fadeLen_ is not clamped to headroom. With f0=0.005 this product + // 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. const std::int64_t w = 2205; // ~50 ms @ 44.1k (the product window) - const double f0 = 0.005; // source: period 200 samples + 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) std::pow(2.0, -3.0 / 12.0), // -3 semitones (down-shift path) 2.0, // octave up (nominal-fade boundary) @@ -248,7 +255,7 @@ static void testRepitchSpectralPurity() { // 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::lround(1.0 / f1)); - const std::size_t hop = win / 2; + const std::size_t hop = std::max(1, win / 2); double minRms = 1e9, maxRms = 0.0; for (std::size_t s0 = from; s0 + win <= n; s0 += hop) { double e = 0.0;