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:
2026-07-28 06:19:26 -04:00
parent 104a25f390
commit 22d7893431
4 changed files with 280 additions and 93 deletions
+52 -23
View File
@@ -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)
};