fix(preserve): correlation-aligned splices replace dual-tap OLA — fixed w/2 tap offset anti-phase-cancelled crossfades (beating/partials on repitched sines); spectral-purity test added
This commit is contained in:
+150
-68
@@ -1,34 +1,32 @@
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract and the S16-F2
|
||||
// route-(b) rationale (WDL drags <windows.h>, so the Preserve DSP is house-native here).
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2
|
||||
// route-(b) rationale (WDL drags <windows.h>), and the GA-Preserve root cause that replaced
|
||||
// the naive dual-tap OLA with correlation-aligned splices.
|
||||
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
|
||||
//
|
||||
// Algorithm: a single delay ring of `window_` frames. The write head advances one frame per
|
||||
// input sample (source rate → duration preserved). TWO read taps chase the write head, offset
|
||||
// by half a window; each advances by the shift `ratio_` per frame. A tap that would cross the
|
||||
// write head wraps by a full window (so it stays a bounded delay behind the writer). The two
|
||||
// taps are crossfaded by an equal-power window keyed to each tap's distance from the write
|
||||
// head, so the wrap discontinuity of one tap is masked by the other mid-window — the classic
|
||||
// two-grain time-domain pitch shifter, no FFT.
|
||||
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
|
||||
// sample (source rate -> duration preserved). ONE active read tap advances by the shift
|
||||
// `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.
|
||||
|
||||
#include "pitch_shift.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// A Hann OLA window over a grain phase in [0,1): 0.5(1 - cos(2*pi*phase)). Zero at the grain
|
||||
// ends (where a tap wraps — the discontinuity), unity mid-grain. Two grains offset by half a
|
||||
// window PARTITION UNITY (w(p) + w(p+0.5) == 1 for all p), so the two crossfaded taps sum to a
|
||||
// gain of exactly 1 everywhere — no amplitude ripple across the window, and each tap's wrap
|
||||
// seam is masked because its window is 0 exactly there.
|
||||
double hannWeight(double phase) {
|
||||
while (phase < 0.0) phase += 1.0;
|
||||
while (phase >= 1.0) phase -= 1.0;
|
||||
return 0.5 * (1.0 - std::cos(2.0 * 3.14159265358979323846 * phase));
|
||||
}
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -37,37 +35,129 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
if (window_ <= 1) {
|
||||
// Pass-through: no ring, process() returns input unchanged.
|
||||
ring_.clear();
|
||||
ringLen_ = 0;
|
||||
writePos_ = 0;
|
||||
readPos_ = 0.0;
|
||||
posA_ = posB_ = 0.0;
|
||||
fading_ = false;
|
||||
fadePos_ = 0;
|
||||
fadeFrames_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
|
||||
ratio_ = 1.0;
|
||||
return;
|
||||
}
|
||||
ring_.assign(static_cast<std::size_t>(window_), 0.0f);
|
||||
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
|
||||
ringLen_ = 2 * window_;
|
||||
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
|
||||
// Geometry (all quarters of the window):
|
||||
// - fadeFrames_: the splice crossfade — long enough to be smooth, short enough that the
|
||||
// outgoing tap cannot cross the writer mid-fade for ratios up to ~2x/0.5x.
|
||||
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any
|
||||
// tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
|
||||
// - corrFrames_: the dot-product length (capped so a splice burst stays bounded).
|
||||
// - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay).
|
||||
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
|
||||
maxLag_ = window_ / 4;
|
||||
corrFrames_ = std::min<std::int64_t>(window_ / 4, 512);
|
||||
dLow_ = window_ / 4;
|
||||
dHigh_ = ringLen_ - window_ / 4;
|
||||
reset();
|
||||
}
|
||||
|
||||
void PitchShifter::reset() {
|
||||
if (window_ > 1) {
|
||||
// Zero the ring and seed the read head a half-window behind the writer so the two taps
|
||||
// (readPos_ and readPos_ + window/2) straddle the writer from the first frame.
|
||||
// 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.
|
||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||
writePos_ = 0;
|
||||
readPos_ = static_cast<double>(window_) / 2.0;
|
||||
posA_ = static_cast<double>(ringLen_ - window_ / 2);
|
||||
posB_ = posA_;
|
||||
fading_ = false;
|
||||
fadePos_ = 0;
|
||||
} else {
|
||||
writePos_ = 0;
|
||||
readPos_ = 0.0;
|
||||
posA_ = posB_ = 0.0;
|
||||
fading_ = false;
|
||||
fadePos_ = 0;
|
||||
}
|
||||
ratio_ = 1.0;
|
||||
}
|
||||
|
||||
void PitchShifter::warm() {
|
||||
if (window_ <= 1) return; // pass-through needs no warm-up
|
||||
// Push one full window of silence so the taps reach steady state before real audio.
|
||||
// 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);
|
||||
}
|
||||
|
||||
void PitchShifter::setShiftRatio(double ratio) {
|
||||
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run taps backward/stall)
|
||||
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run the tap backward/stall)
|
||||
}
|
||||
|
||||
double PitchShifter::readTap(double pos) const {
|
||||
// Fractional linear interpolation with ring wrap.
|
||||
double p = pos;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (p < 0.0) p += len;
|
||||
while (p >= len) p -= len;
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(p);
|
||||
const double frac = p - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (i1 >= ringLen_) i1 = 0;
|
||||
const double s0 = static_cast<double>(ring_[static_cast<std::size_t>(i0)]);
|
||||
const double s1 = static_cast<double>(ring_[static_cast<std::size_t>(i1)]);
|
||||
return s0 + (s1 - s0) * frac;
|
||||
}
|
||||
|
||||
void PitchShifter::splice(std::int64_t nominalJump) {
|
||||
// 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.
|
||||
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_;
|
||||
double s = 0.0;
|
||||
for (std::int64_t k = 0; k < corrFrames_; ++k) {
|
||||
s += static_cast<double>(ring_[static_cast<std::size_t>(ia)]) *
|
||||
static_cast<double>(ring_[static_cast<std::size_t>(ic)]);
|
||||
if (++ia >= ringLen_) ia = 0;
|
||||
if (++ic >= ringLen_) ic = 0;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
std::int64_t bestLag = 0;
|
||||
double bestScore = -std::numeric_limits<double>::infinity();
|
||||
for (std::int64_t lag = -maxLag_; lag <= maxLag_; lag += 4) {
|
||||
const double s = scoreAt(lag);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
const std::int64_t coarse = bestLag;
|
||||
for (std::int64_t lag = coarse - 3; lag <= coarse + 3; ++lag) {
|
||||
if (lag == coarse || lag < -maxLag_ || lag > maxLag_) continue;
|
||||
const double s = scoreAt(lag);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
posB_ = posA_;
|
||||
double p = posA_ - static_cast<double>(nominalJump) + static_cast<double>(bestLag);
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (p < 0.0) p += len;
|
||||
while (p >= len) p -= len;
|
||||
posA_ = p;
|
||||
fading_ = true;
|
||||
fadePos_ = 0;
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::process(AudioSample in) {
|
||||
@@ -76,49 +166,41 @@ AudioSample PitchShifter::process(AudioSample in) {
|
||||
// 1. Write the incoming sample at the write head (source rate).
|
||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||
|
||||
const double w = static_cast<double>(window_);
|
||||
const double half = w / 2.0;
|
||||
// 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
|
||||
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
|
||||
double out = readTap(posA_);
|
||||
if (fading_) {
|
||||
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeFrames_);
|
||||
const double gNew = 0.5 * (1.0 - std::cos(kPi * t));
|
||||
out = gNew * out + (1.0 - gNew) * readTap(posB_);
|
||||
if (++fadePos_ >= fadeFrames_) fading_ = false;
|
||||
} else {
|
||||
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
|
||||
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
|
||||
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At
|
||||
// unity the delay is frozen at window/2 and neither trigger ever fires.
|
||||
double d = static_cast<double>(writePos_) - posA_;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (d < 0.0) d += len;
|
||||
while (d >= len) d -= len;
|
||||
if (d <= static_cast<double>(dLow_)) {
|
||||
splice(+window_);
|
||||
} else if (d >= static_cast<double>(dHigh_)) {
|
||||
splice(-window_);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read the two taps, each a bounded delay behind the writer. tap0 is `readPos_`; tap1 is
|
||||
// a half-window ahead of it (mod window). Distance-from-writer drives the crossfade so a
|
||||
// tap near the writer (about to wrap) is faded out while its partner (mid-window) is up.
|
||||
auto readTap = [&](double pos) -> double {
|
||||
// Fractional linear interpolation with ring wrap.
|
||||
double p = pos;
|
||||
while (p < 0.0) p += w;
|
||||
while (p >= w) p -= w;
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(p);
|
||||
const double frac = p - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (i1 >= window_) i1 = 0;
|
||||
const double s0 = static_cast<double>(ring_[static_cast<std::size_t>(i0)]);
|
||||
const double s1 = static_cast<double>(ring_[static_cast<std::size_t>(i1)]);
|
||||
return s0 + (s1 - s0) * frac;
|
||||
};
|
||||
|
||||
const double tap0 = readTap(readPos_);
|
||||
const double tap1 = readTap(readPos_ + half);
|
||||
|
||||
// Distance of tap0 behind the write head, in [0, window). Its crossfade phase is that
|
||||
// distance over the window; tap1 (half a window offset) gets the complementary phase.
|
||||
double dist0 = static_cast<double>(writePos_) - readPos_;
|
||||
while (dist0 < 0.0) dist0 += w;
|
||||
while (dist0 >= w) dist0 -= w;
|
||||
const double phase0 = dist0 / w;
|
||||
|
||||
// Hann windows offset by half a grain partition unity, so the two taps sum to gain 1 with
|
||||
// each tap's wrap seam masked by its window zero. phase0 drives tap0; tap1 (half-window
|
||||
// offset) is at phase0 + 0.5.
|
||||
const double g0 = hannWeight(phase0);
|
||||
const double g1 = hannWeight(phase0 + 0.5);
|
||||
const double out = tap0 * g0 + tap1 * g1;
|
||||
|
||||
// 3. Advance heads: write head one frame (source rate), read head by the shift ratio.
|
||||
// 4. Advance heads: write head one frame (source rate), tap(s) by the shift ratio.
|
||||
++writePos_;
|
||||
if (writePos_ >= window_) writePos_ = 0;
|
||||
readPos_ += ratio_;
|
||||
while (readPos_ >= w) readPos_ -= w;
|
||||
while (readPos_ < 0.0) readPos_ += w;
|
||||
if (writePos_ >= ringLen_) writePos_ = 0;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
posA_ += ratio_;
|
||||
while (posA_ >= len) posA_ -= len;
|
||||
if (fading_) {
|
||||
posB_ += ratio_;
|
||||
while (posB_ >= len) posB_ -= len;
|
||||
}
|
||||
|
||||
return static_cast<AudioSample>(out);
|
||||
}
|
||||
|
||||
+52
-23
@@ -1,9 +1,23 @@
|
||||
#pragma once
|
||||
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
|
||||
// engine's DSP core. Time-domain overlap-add (OLA) with two half-window-offset read taps
|
||||
// crossfaded to hide the ring-wrap seam. Source is consumed 1:1 and output produced 1:1
|
||||
// (duration held); only the PITCH changes — an octave up plays the same wall-clock length
|
||||
// as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
|
||||
// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES
|
||||
// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts
|
||||
// out of its safe delay band it is relocated by a nominal window jump REFINED BY A
|
||||
// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new
|
||||
// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and
|
||||
// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same
|
||||
// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
|
||||
//
|
||||
// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the
|
||||
// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head
|
||||
// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs
|
||||
// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency-
|
||||
// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint
|
||||
// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine
|
||||
// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity
|
||||
// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump
|
||||
// is snapped to the best waveform match within a bounded lag search — a pure sine's jump
|
||||
// lands on an integer period count and the output stays a single shifted tone.
|
||||
//
|
||||
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
|
||||
// route (a) `WDL_SimplePitchShifter`. But its include chain
|
||||
@@ -22,8 +36,9 @@
|
||||
// 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
|
||||
// NO allocation and NO locks — it reads/writes the pre-sized ring only. All state is plain
|
||||
// value fields, so a voice owning one by value costs a fixed ring buffer per channel.
|
||||
// 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.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -33,39 +48,41 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// A per-channel time-domain OLA pitch shifter. One instance transposes ONE channel; a stereo
|
||||
// voice owns two (or a stereo-aware wrapper) — the algorithm is per-sample and channel-count
|
||||
// agnostic, matching the S7 "one read head, per-channel value" idiom of the core.
|
||||
// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel;
|
||||
// a stereo voice owns two — the algorithm is per-sample and channel-count agnostic, matching
|
||||
// the S7 "one read head, per-channel value" idiom of the core.
|
||||
//
|
||||
// The default-constructed shifter is INERT: with no configure() it passes input through
|
||||
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
|
||||
// byte-identical to the pre-S16 engine.
|
||||
class PitchShifter {
|
||||
public:
|
||||
// Size the delay ring for `windowFrames` (the OLA grain length) and prepare the two
|
||||
// read taps a half-window apart. `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 = smoother on large
|
||||
// transpositions but more latency; the shell picks it from the Preserve quality setting.
|
||||
// Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x
|
||||
// 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.
|
||||
void configure(std::int64_t windowFrames);
|
||||
|
||||
// Pre-fill the ring with silence (one full window of zero writes) so the read taps reach
|
||||
// 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).
|
||||
void warm();
|
||||
|
||||
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
|
||||
// 1.0 = no shift (pass-through-equivalent output). Set per frame is fine (cheap); the tap
|
||||
// advance simply uses the current value. Values <= 0 are ignored (kept at the last valid
|
||||
// ratio) so a bad input never runs the taps backward or stalls them.
|
||||
// 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is
|
||||
// fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored
|
||||
// (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it.
|
||||
void setShiftRatio(double ratio);
|
||||
|
||||
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
|
||||
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
|
||||
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
|
||||
// head, reads the two half-window-offset taps advancing at the shift ratio, crossfades
|
||||
// them by the write-head-relative distance (equal-power), and advances both heads by one.
|
||||
// head, reads the active tap (crossfading against the outgoing tap while a splice fade is
|
||||
// live), then advances the write head by one and the tap(s) by the shift ratio. When the
|
||||
// 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)
|
||||
@@ -78,10 +95,22 @@ public:
|
||||
std::int64_t window() const { return window_; }
|
||||
|
||||
private:
|
||||
std::vector<AudioSample> ring_; // delay line, length `window_` (channel-local)
|
||||
std::int64_t window_ = 0; // OLA grain length in frames; <= 1 = pass-through
|
||||
double readTap(double pos) const; // fractional ring read, linear interp
|
||||
void splice(std::int64_t nominalJump); // relocate the active tap, start the fade
|
||||
|
||||
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
|
||||
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
|
||||
std::int64_t ringLen_ = 0; // ring length (2 * window_): splice + search headroom
|
||||
std::int64_t writePos_ = 0; // integer write head into the ring (source rate)
|
||||
double readPos_ = 0.0; // fractional read head (advances at shift ratio)
|
||||
double posA_ = 0.0; // active read tap (advances at the shift ratio)
|
||||
double posB_ = 0.0; // outgoing tap during a splice crossfade
|
||||
bool fading_ = false; // a splice crossfade is in flight
|
||||
std::int64_t fadePos_ = 0; // crossfade progress, [0, fadeFrames_)
|
||||
std::int64_t fadeFrames_ = 0; // crossfade length (window_/4)
|
||||
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
|
||||
std::int64_t corrFrames_ = 0; // correlation dot-product length (window_/4, capped)
|
||||
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)
|
||||
double ratio_ = 1.0; // current shift ratio (>0)
|
||||
};
|
||||
|
||||
|
||||
@@ -284,8 +284,8 @@ 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 OLA shifter's structural onset cost (a
|
||||
// half-window delay + Hann fade-in), which buys nothing at unity — but skipping it makes
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user