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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user