fix(preserve): clamp Preserve feed to real-content bound at tail; lock sub-sample refinement with non-integer f0; fix stale onset-latency doc + clamp comment

This commit is contained in:
2026-07-28 09:10:03 -04:00
parent 2ef3514bc7
commit f7dfaa9f41
4 changed files with 64 additions and 33 deletions
+8 -6
View File
@@ -160,12 +160,14 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
const std::int64_t d = static_cast<std::int64_t>(delay);
// GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the
// search (and the +/-0.5-sample parabolic refinement, and the interpolator) can touch is
// delay d + jump + maxLag + 1, so cap the jump at filled_ - d - maxLag_ - 1. In steady
// state (filled_ == ringLen_) this is > window_ and the nominal jump is untouched; near
// a primed onset it shrinks the jump to what real history exists (still many source
// periods with a full-window prime). The floor of 1 is only reachable on the documented
// degenerate reset-without-prime path — garbage-tolerant, never out-of-range.
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search,
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead),
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample of
// conservative margin, never out-of-range. In steady state (filled_ == ringLen_) this is
// > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to
// what real history exists (still many source periods with a full-window prime). The floor of
// 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant.
std::int64_t jump = nominalJump;
if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
+28 -10
View File
@@ -386,22 +386,25 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
// Both channels walk identical SOURCE positions (the walk depends only on loop geometry,
// not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1.
std::int64_t p = start;
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
std::int64_t p = start;
std::int64_t q = start;
for (std::int64_t i = 0; i < w; ++i) {
if (loopWrap) {
while (p >= loop.end) p -= loopLen;
while (q >= loop.end) q -= loopLen;
}
primeBuf_[static_cast<std::size_t>(i)] =
(p < frameCount) ? pcmCh[static_cast<std::size_t>(p)] : 0.0f;
++p;
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
++q;
}
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), w);
// Both channels walk identical positions; the per-frame feed continues at `p`,
// exactly one window ahead of the output anchor readPos_.
feedPos_ = p;
if (ch == 0) p = q; // capture the end position once from channel 0's walk
}
// Per-frame feed continues at `p`, exactly one window ahead of readPos_.
feedPos_ = p;
}
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
}
@@ -513,8 +516,23 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
const std::int64_t loopLen = loop.end - loop.start;
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
}
const bool feedOk = (feedPos_ >= 0 && feedPos_ < frameCount);
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
// validThrough_ tail clamp (symmetric with the onset filled_ clamp in pitch_shift).
// feedPos_ runs one window AHEAD of readPos_; past the last real source frame the feed
// would write zeros into the ring, letting splices land in a silent tail — the same
// burst/gap/burst stutter as the onset zero-gap (just at note END for up-shifts).
// For Trigger mode the last real frame is playEnd_-1 (the user's chosen stop); for Gate
// it is frameCount-1 (the sample's own end). When feedPos_ overruns this bound, clamp
// to the last real frame — the shifter holds that frame's content rather than ingesting
// silence, so splices always land in real-content history at BOTH ends of the note.
const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
// Clamped read position: feedPos_ may legitimately exceed feedBound (it just tracks
// where we "would" be), so clamp only the read, not the counter itself.
const std::int64_t clampedFeedPos =
(feedPos_ < feedBound) ? feedPos_ : (feedBound - 1);
const bool feedOk = (clampedFeedPos >= 0 && clampedFeedPos < frameCount);
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(clampedFeedPos)] : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
@@ -526,7 +544,7 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// heads twice and corrupt the OLA state). Gated on haveR so a MONO sample never
// touches shiftR_ — start() only primes it for genuinely stereo samples, and a
// stale un-primed ring must not leak a previous note into this one.
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(clampedFeedPos)] : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal = static_cast<double>(shiftR_.process(feedR)) * gain;
} else {
+3 -1
View File
@@ -118,7 +118,9 @@ inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
// smoother on big transpositions, more onset latency. One knob, resolved at voice allocation.
// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first
// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix).
// One knob, resolved at voice allocation.
inline constexpr double kPreserveWindowMs = 50.0;
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always