fix(preserve): prime SOLA rings with real source (zero-latency, gap-free onset) + sub-sample splice alignment — clean repitch C1..C8

This commit is contained in:
2026-07-28 08:49:05 -04:00
parent e99bdcf264
commit 2ef3514bc7
6 changed files with 447 additions and 238 deletions
+100 -24
View File
@@ -8,13 +8,17 @@
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
// that delay leaves the safe band [dLow, dHigh], the tap is RELOCATED by a nominal jump of
// one window (+window toward older content for up-shifts, -window toward the writer for
// down-shifts), refined by a cross-correlation search over +/- maxLag so the relocated read
// point is WAVEFORM-ALIGNED with what the outgoing tap was about to play. Old and new taps
// then crossfade over fadeFrames with a raised-cosine, amplitude-complementary pair (in-phase
// content sums to exactly unity gain). For a pure sine the correlation snaps the jump to an
// integer period count, so the output stays a single tone at the shifted frequency — the
// GA-Preserve acceptance bar. At unity ratio the delay is frozen mid-band and no splice ever
// fires: the shifter is a clean window/2 delay.
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on
// the spectrogram). Old and new taps then crossfade over fadeFrames with a raised-cosine,
// amplitude-complementary pair (in-phase content sums to exactly unity gain). For a pure
// sine the correlation snaps the jump to an (integer + fraction) period count, so the output
// stays a single tone at the shifted frequency — the GA-Preserve acceptance bar. At unity
// ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the
// stream through with ZERO added latency; a silence-warmed one is a clean window delay.
#include "pitch_shift.h"
@@ -41,6 +45,7 @@ void PitchShifter::configure(std::int64_t windowFrames) {
fading_ = false;
fadePos_ = 0;
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
filled_ = 0;
ratio_ = 1.0;
return;
}
@@ -70,11 +75,13 @@ void PitchShifter::configure(std::int64_t windowFrames) {
void PitchShifter::reset() {
if (window_ > 1) {
// Zero the ring and seed the active tap half a window behind the writer — mid safe
// band, so unity holds it there forever and either shift direction has drift room.
// Zero the ring and seed the active tap one window behind the writer — the exact
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there
// forever and either shift direction has maximal drift room. No history is declared
// (filled_ = 0): follow with prime() or warm() before streaming.
std::fill(ring_.begin(), ring_.end(), 0.0f);
writePos_ = 0;
posA_ = static_cast<double>(ringLen_ - window_ / 2);
posA_ = static_cast<double>(ringLen_ - window_);
posB_ = posA_;
fading_ = false;
fadePos_ = 0;
@@ -86,13 +93,42 @@ void PitchShifter::reset() {
fadePos_ = 0;
fadeLen_ = 0;
}
filled_ = 0;
ratio_ = 1.0;
}
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
if (window_ <= 1) return; // pass-through needs no priming
// Clamp to one window: the intended call primes exactly window() frames, and delay ==
// count must stay inside the safe band so the seed does not itself trigger a splice.
if (count < 0) count = 0;
if (count > window_) count = window_;
std::fill(ring_.begin(), ring_.end(), 0.0f);
for (std::int64_t i = 0; i < count; ++i) ring_[static_cast<std::size_t>(i)] = src[i];
// Writer continues after the primed span; the tap parks ON src[0] (delay == count), so
// the very first process() output is src[0] — zero structural latency at every ratio.
writePos_ = count % ringLen_;
posA_ = posB_ = 0.0;
fading_ = false;
fadePos_ = 0;
fadeLen_ = 0;
filled_ = count;
// ratio_ deliberately untouched: the voice sets it per frame around the prime.
}
void PitchShifter::warm() {
if (window_ <= 1) return; // pass-through needs no warm-up
// Push one full window of silence so the tap reaches steady state before real audio.
for (std::int64_t i = 0; i < window_; ++i) process(0.0f);
// A prime() with one window of silence: same geometry (tap parked mid-band one window
// behind the writer), the zeros declared as valid history. At unity this is a bit-exact
// window() delay; an up-shift plays ~a window of silence before speaking (the pre-GA2
// onset) — stream callers with access to the upcoming source should prime() instead.
std::fill(ring_.begin(), ring_.end(), 0.0f);
writePos_ = window_ % ringLen_;
posA_ = posB_ = 0.0;
fading_ = false;
fadePos_ = 0;
fadeLen_ = 0;
filled_ = window_;
}
void PitchShifter::setShiftRatio(double ratio) {
@@ -114,20 +150,41 @@ double PitchShifter::readTap(double pos) const {
return s0 + (s1 - s0) * frac;
}
void PitchShifter::splice(std::int64_t nominalJump) {
void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward
// older content, -window_ = jump toward the writer), refined by a correlation search so
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content.
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best):
// a bounded burst of ~ (maxLag_/2 + 7) * corrFrames_ multiply-adds, once per splice.
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best,
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_
// multiply-adds, once per splice.
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.
std::int64_t jump = nominalJump;
if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
if (jump > maxJump) jump = maxJump;
if (jump < 1) jump = 1;
}
// The correlation reference reads FORWARD from the tap; keep it strictly behind the
// writer even when the trigger undershot dLow_ by a large per-frame drift (extreme
// up-ratios): d - corr must stay >= 0.
const std::int64_t corr = std::max<std::int64_t>(1, std::min<std::int64_t>(corrFrames_, d - 1));
const std::int64_t iA =
((static_cast<std::int64_t>(posA_) % ringLen_) + ringLen_) % ringLen_;
auto scoreAt = [&](std::int64_t lag) -> double {
std::int64_t ia = iA;
std::int64_t ic = ((iA - nominalJump + lag) % ringLen_ + ringLen_) % ringLen_;
std::int64_t ic = ((iA - jump + lag) % ringLen_ + ringLen_) % ringLen_;
double s = 0.0, ec = 0.0;
for (std::int64_t k = 0; k < corrFrames_; ++k) {
for (std::int64_t k = 0; k < corr; ++k) {
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;
@@ -163,11 +220,28 @@ void PitchShifter::splice(std::int64_t nominalJump) {
}
}
// Hand the current position to the outgoing tap and relocate the active one. Integer lag
// on top of the nominal jump preserves posA_'s fractional part — sub-sample continuity
// between the two taps, so the residual phase error is bounded by half a sample.
// SUB-SAMPLE peak (GA2 alias fix): the integer-lag best leaves a residual misalignment of
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
// silence) keeps the integer lag, which is already benign there.
double frac = 0.0;
{
const double sM = scoreAt(bestLag - 1);
const double sP = scoreAt(bestLag + 1);
const double den = sM - 2.0 * bestScore + sP;
if (den < 0.0) {
frac = 0.5 * (sM - sP) / den;
if (frac > 0.5) frac = 0.5;
if (frac < -0.5) frac = -0.5;
}
}
// Hand the current position to the outgoing tap and relocate the active one.
posB_ = posA_;
double p = posA_ - static_cast<double>(nominalJump) + static_cast<double>(bestLag);
double p = posA_ - static_cast<double>(jump) + static_cast<double>(bestLag) + frac;
const double len = static_cast<double>(ringLen_);
while (p < 0.0) p += len;
while (p >= len) p -= len;
@@ -200,8 +274,10 @@ void PitchShifter::splice(std::int64_t nominalJump) {
AudioSample PitchShifter::process(AudioSample in) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
// 1. Write the incoming sample at the write head (source rate).
// 1. Write the incoming sample at the write head (source rate). One more slot of the
// ring now holds valid history (capped at the ring length once it has wrapped).
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
// Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
@@ -222,9 +298,9 @@ AudioSample PitchShifter::process(AudioSample in) {
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_);
splice(+window_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_);
splice(-window_, d);
}
}
+41 -10
View File
@@ -29,13 +29,24 @@
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
// the SHELL, never in the pure core.
//
// WHY PRIME WITH REAL CONTENT (GA2-Preserve onset fix, 2026-07). Splices RELOCATE the tap
// into ring HISTORY — at note onset a silence-warmed ring has none, so every early splice
// jumped into zeros: a burst/gap/burst stutter for the first ~2 windows of every off-root
// note (the DAW "zero-sample gaps in the first few ms"; at +48 st the ~300 Hz gap cadence
// reads as a square-ish buzz). But this engine is NOT a streaming context: the caller owns
// the whole decoded sample, so the FUTURE of the stream is known at note-on. `prime()`
// pre-fills the ring with the actual first window of upcoming source and parks the tap on
// its oldest frame — output frame 0 IS source frame 0 (zero structural latency at every
// ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever
// land in unwritten silence.
//
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
// wav_trim do the same).
//
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
// thread, at voice allocation). `warm()` pre-fills the ring with silence so steady-state
// latency is reached before the first real sample (no cold-start click). `process()` does
// thread, at voice allocation). `prime()` / `warm()` only copy into the pre-sized ring
// (bounded, allocation-free — safe on the audio thread at note-on). `process()` does
// NO allocation and NO locks — it reads/writes the pre-sized ring only. The splice-time
// correlation search is a bounded burst of multiply-adds (coarse+refine over a fixed lag
// range) that fires once per splice cadence (window / |ratio-1| frames), never per frame.
@@ -61,14 +72,25 @@ public:
// that for splice/search headroom) and derive the fade/search geometry. `windowFrames`
// <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by
// zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running
// state. A larger window = fewer splices and a deeper alignment search but more latency
// (steady-state latency stays window/2); the shell picks it from kPreserveWindowMs.
// state. A larger window = fewer splices and a deeper alignment search; a PRIMED shifter
// has no added latency regardless (see prime()); the shell picks it from kPreserveWindowMs.
void configure(std::int64_t windowFrames);
// Pre-fill the ring with silence (one full window of zero writes) so the read tap reaches
// steady state before the first real sample. Removes the cold-start seam (the S16 "onset
// click absent" requirement) — call once at voice allocation after configure(). No-op when
// unconfigured (pass-through needs no warm-up).
// Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO
// structural latency at every ratio, and splices always have `count` frames of real
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]; pass
// the full window (pad the tail with silence yourself if the source is shorter — trailing
// silence IS the true stream there). RT-safe: bounded copy into the pre-sized ring, no
// allocation. No-op when unconfigured. The current shift ratio is left untouched.
void prime(const AudioSample* src, std::int64_t count);
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and
// declare that window of silence as valid history. Kept for callers with no access to the
// upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking —
// the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a
// bit-exact window() delay. No-op when unconfigured.
void warm();
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
@@ -85,8 +107,10 @@ public:
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
AudioSample process(AudioSample in);
// Reset running state to a freshly-warmed-equivalent silence (ring zeroed, heads re-seeded)
// Reset running state to silence (ring zeroed, heads re-seeded mid-band, fill count zeroed)
// WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window.
// Follow with prime() (or warm()) before streaming: a bare reset has no declared history,
// so an immediate up-shift would starve its splices.
void reset();
// True once configure() sized a real ring (window > 1). A pass-through shifter is false.
@@ -96,7 +120,10 @@ public:
private:
double readTap(double pos) const; // fractional ring read, linear interp
void splice(std::int64_t nominalJump); // relocate the active tap, start the fade
// Relocate the active tap by ~`nominalJump` frames of added delay (clamped to the filled
// span for up-jumps) and start the crossfade. `delay` is the tap's current delay behind
// the writer (the caller just computed it for the trigger test).
void splice(std::int64_t nominalJump, double delay);
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
@@ -116,6 +143,10 @@ private:
// 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)
std::int64_t filled_ = 0; // frames of VALID history behind the writer (prime count
// + frames streamed, capped at ringLen_). splice() clamps
// its up-jump to this so no splice lands in unwritten
// silence — the GA2 onset-gap fix.
double ratio_ = 1.0; // current shift ratio (>0)
};
+94 -55
View File
@@ -254,9 +254,19 @@ double PitchEnvelope::tick() {
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_.
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. The
// prime scratch (one window, reused per channel) is sized here for the same reason: start()
// assembles the first window of the upcoming source stream into it with zero allocation.
shiftL_.configure(windowFrames);
shiftR_.configure(windowFrames);
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
}
bool Voice::sustainLoopUsable() const {
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
const SampleLoop& loop = sample_->loop;
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
}
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
@@ -305,13 +315,13 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
// FA1 unity bypass, RE-SCOPED (Phase S): a UNITY-SHIFT Preserve voice — note at the
// effective root (baseRatio_ == 1.0, exact per keyTrackedRatio) with the pitch envelope
// off — is demoted to the Varispeed read path ONLY when the caller opted in. At ratio 1.0
// the two engines are byte-identical EXCEPT the shifter's structural onset cost (a
// half-window ring-fill delay), which buys nothing at unity — but skipping it makes
// the root note speak ~25 ms EARLIER than its neighbors, an audible timing step in a
// chromatic MIDI line (the FA1-review Major). So: the PREVIEW card (always at root,
// latency-critical, no line to be uneven against) passes true; the MIDI VoiceEngine
// passes false and keeps one uniform onset across the keyboard.
// off — is demoted to the Varispeed read path ONLY when the caller opted in. Since the
// GA2 prime fix the shifter has ZERO structural onset latency at every ratio (the ring
// is primed with the first window of source), so the original FA1 timing concern (a
// ~25 ms root-vs-neighbor onset step) no longer exists in either direction: onset is
// uniform across the keyboard with or without the bypass. The demotion survives purely
// as a work-skip — a unity voice pays no per-frame shifter cost — still scoped to the
// PREVIEW card (true); the MIDI VoiceEngine passes false, keeping one code path per line.
if (unityVarispeedBypass && pitchEngine_ == PitchEngine::Preserve &&
baseRatio_ == 1.0 && !p.pitchEnv.enabled) {
pitchEngine_ = PitchEngine::Varispeed;
@@ -359,18 +369,38 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
pitchEnv_.configure(p.pitchEnv);
pitchEnv_.noteOn();
// --- Preserve engine (S16): reset + pre-warm the ALREADY-SIZED per-channel shifters. The
// rings were allocated off-thread by presizePreserveShifters (the engine calls it at
// construction), so this RT-safe path only zeroes state (reset) and runs a silence pass
// (warm) to settle the OLA taps before the first output frame — NO allocation here.
// Varispeed voices never touch the shifters (advanceFrame checks configured()), so a
// Varispeed instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
// --- Preserve engine (S16, GA2 onset fix): PRIME the ALREADY-SIZED per-channel shifters
// with the first window of the ACTUAL upcoming source stream (loop-unrolled under the
// sustain-loop wrap rule, silence past the sample end — that silence IS the true
// stream there). The tap parks on source frame `start`, so the voice speaks on output
// frame 0 at EVERY ratio (no ring-fill silence), and every splice has a full window
// of real history to land in — the fix for the DAW onset zero-gaps (a silence-warmed
// ring made every early splice jump into zeros). The rings and the prime scratch were
// allocated off-thread by presizePreserveShifters (the engine calls it at
// construction); this path is a bounded copy — NO allocation here. Varispeed voices
// never touch the shifters (advanceFrame checks configured()), so a Varispeed
// instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
shiftL_.reset();
shiftL_.warm();
if (sample.channelCount() == 2 && shiftR_.configured()) {
shiftR_.reset();
shiftR_.warm();
const std::int64_t w = shiftL_.window();
const bool loopWrap = sustainLoopUsable();
const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
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;
for (std::int64_t i = 0; i < w; ++i) {
if (loopWrap) {
while (p >= loop.end) p -= loopLen;
}
primeBuf_[static_cast<std::size_t>(i)] =
(p < frameCount) ? pcmCh[static_cast<std::size_t>(p)] : 0.0f;
++p;
}
(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;
}
}
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
@@ -440,8 +470,7 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
const SampleLoop& loop = sample_->loop;
const bool loopUsable = playMode_ == PlayMode::Gate && loop.hasLoop &&
loop.end > loop.start && loop.start >= 0 && loop.end <= frameCount;
const bool loopUsable = sustainLoopUsable();
if (loopUsable) {
const double loopLen = static_cast<double>(loop.end - loop.start);
while (readPos_ >= static_cast<double>(loop.end)) {
@@ -460,34 +489,11 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
return 0.0f;
}
// Linear interpolation between the two bracketing SOURCE frames. For the loop case, the
// second point wraps to loopStart so the seam is continuous.
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (loopUsable && i1 >= loop.end) {
i1 = loop.start; // seamless wrap for the interpolation partner.
}
const bool i0ok = (i0 >= 0 && i0 < frameCount);
const bool i1ok = (i1 >= 0 && i1 < frameCount);
// Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine.
const double amp = tickAmplitude();
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick();
// Raw interpolated source values (pre-shift). These are the SOURCE stream both engines read;
// Varispeed applies pitch by the read RATE, Preserve applies it by the shifter.
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
double srcR = 0.0;
if (stereo) {
srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
}
// The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0)
// this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read
// (no per-frame transcendental), byte-identical to pre-S16.
@@ -495,36 +501,69 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// PRESERVE: read the source at unity rate (duration held) and TRANSPOSE the output by
// 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to the shift amount, not the
// read rate — pitch bends, duration unchanged (S16 contract).
// PRESERVE: feed the shifters the SOURCE stream at unity rate (duration held) and
// TRANSPOSE the output by 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to
// the shift amount, not the read rate — pitch bends, duration unchanged (S16
// contract). The feed runs one window AHEAD of readPos_ (the rings were primed with
// that window at start()), under the SAME sustain-loop wrap rule as the anchor, and
// reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so
// there is nothing to interpolate). Past the sample end the stream is silence — the
// shifter keeps transposing the real tail it already holds.
if (loopUsable) {
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;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(static_cast<AudioSample>(srcL)));
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
outL = shiftedL * gain;
if (stereo) {
if (shiftR_.configured()) {
if (haveR && shiftR_.configured()) {
// Genuine stereo: an independent shifter transposes channel 1. Each shifter is
// process()'d EXACTLY ONCE per output frame (never twice — that would advance its
// heads twice and corrupt the OLA state).
// 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;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.process(static_cast<AudioSample>(srcR))) * gain;
outRlocal = static_cast<double>(shiftR_.process(feedR)) * gain;
} else {
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
// value from srcL (== srcR since pcmR aliases pcm); mirror it to R. Do NOT call
// shiftL_.process again this frame.
// value from the mono feed; mirror it to R. Do NOT call shiftL_.process again
// this frame.
outRlocal = shiftedL * gain;
}
}
++feedPos_;
// Preserve advances the read head at the SOURCE rate (duration preserved).
ratio_ = 1.0;
} else {
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch
// envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the
// envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
//
// Linear interpolation between the two bracketing SOURCE frames at the read head. For
// the loop case, the second point wraps to loopStart so the seam is continuous.
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (loopUsable && i1 >= loop.end) {
i1 = loop.start; // seamless wrap for the interpolation partner.
}
const bool i0ok = (i0 >= 0 && i0 < frameCount);
const bool i1ok = (i1 >= 0 && i1 < frameCount);
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
outL = srcL * gain;
if (stereo) outRlocal = srcR * gain;
if (stereo) {
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
outRlocal = srcR * gain;
}
ratio_ = baseRatio_ * envFactor;
}
+24 -6
View File
@@ -466,7 +466,8 @@ public:
// meaningful while active(). NOTE (Phase S re-scope of FA1): the unity-shift demotion to
// Varispeed is now OPT-IN via start()'s unityVarispeedBypass — only the preview card takes
// it; a MIDI Preserve voice keeps its shifter at every note so a chromatic line has one
// uniform onset (no ~25 ms step at the root).
// uniform onset. GA2 update: the primed shifter speaks on frame 0 at every ratio, so the
// demotion is purely a per-frame work-skip — onset timing is uniform either way.
PitchEngine pitchEngine() const { return pitchEngine_; }
// The SampleData this voice is playing (nullptr when never started). The engine's mono
// legato path compares it against the new note's resolved sample — a same-sample takeover
@@ -474,11 +475,12 @@ public:
const SampleData* playingSample() const { return sample_; }
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
// audio thread (this allocates). The engine calls it once at construction so start() — which
// runs on the audio thread inside process() — never allocates: start() only reset()s + warm()s
// the already-sized rings. `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap no-op
// in the underlying vector.
// audio thread (this allocates; also sizes the prime scratch buffer). The engine calls it
// once at construction so start() — which runs on the audio thread inside process() — never
// allocates: start() only prime()s the already-sized rings with the first window of source
// (a bounded copy). `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap
// no-op in the underlying vector.
void presizePreserveShifters(std::int64_t windowFrames);
// Renders one frame's contribution, advancing the read head and envelope by one
@@ -511,6 +513,12 @@ private:
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
double tickAmplitude();
// True when the sustain loop applies to this voice: GATE mode with a valid, non-empty loop
// inside the sample (S15 — Trigger one-shots never loop). The single source of truth for
// the wrap rule shared by the output anchor (readPos_), the Preserve feed (feedPos_), and
// the start()-time ring prime.
bool sustainLoopUsable() const;
bool active_ = false;
bool releasing_ = false;
int note_ = 0;
@@ -533,10 +541,20 @@ private:
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
//
// GA2 onset fix: the shifter rings are PRIMED at start() with the first window of the
// actual upcoming source (loop-unrolled, silence past the end) — output frame 0 is source
// frame `start`, no ring-fill silence, and splices always land in real history. feedPos_
// is the integer SOURCE frame the shifters are fed next; it runs exactly one window AHEAD
// of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule.
// primeBuf_ is the presized scratch the prime stream is assembled into (never touched
// outside start()).
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
PitchEnvelope pitchEnv_;
PitchShifter shiftL_;
PitchShifter shiftR_;
std::int64_t feedPos_ = 0;
std::vector<AudioSample> primeBuf_;
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's
// most recent rendered output (post-gain, incl. any running declick) so a takeover/steal