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