230 lines
16 KiB
C++
230 lines
16 KiB
C++
#pragma once
|
|
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
|
|
// 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
|
|
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
|
|
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
|
|
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
|
|
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
|
|
// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same
|
|
// 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.
|
|
//
|
|
// WHY FREEZE THE TAIL (GA3-Preserve tail fix, 2026-07). GA2's prime fixed the ONSET; the
|
|
// mirror problem lived at the note END. When the source ran out, the caller held the LAST
|
|
// REAL SAMPLE as the feed — a DC plateau with no waveform for the correlation to align on.
|
|
// Splices landing in or referenced against it were unalignable, so the tap alternated
|
|
// real-tone / dead-DC at the splice cadence, the dead fraction growing as the plateau
|
|
// displaced real ring history (the DAW report: periodic troughs "almost like ring
|
|
// modulation", ~1:20 tone-to-silence at the very end). freezeTail() removes the padding at
|
|
// the source: the WRITER parks, the ring keeps its all-real final two windows, and the
|
|
// aligned-splice machinery recycles that frozen tail — a continuous tone until the caller's
|
|
// own note end. See freezeTail() below.
|
|
//
|
|
// 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). `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.
|
|
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <vector>
|
|
|
|
#include "core/audio/peaks.h" // AudioSample (float)
|
|
|
|
namespace reasampler::instrument::engine {
|
|
|
|
using audio::AudioSample;
|
|
|
|
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG
|
|
// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation
|
|
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller
|
|
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the
|
|
// follower applies exactly this decision instead of running its own search. Both channels
|
|
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel
|
|
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice:
|
|
// stereo image wander at the splice cadence plus comb coloration on any mono sum.
|
|
struct SpliceEvent {
|
|
bool fired = false; // a splice was scheduled on this frame
|
|
std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed)
|
|
std::int64_t lag = 0; // correlation best integer lag
|
|
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
|
|
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
|
|
};
|
|
|
|
// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel;
|
|
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice
|
|
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned.
|
|
//
|
|
// 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 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; 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 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()].
|
|
// When the PLAYABLE source is shorter than one window, prime only the real span and call
|
|
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
|
|
// short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring
|
|
// are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material.
|
|
// 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.
|
|
// 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 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);
|
|
|
|
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process()
|
|
// except the splice decision is NOT computed here — when `master.fired` is true this
|
|
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is
|
|
// considered. The caller must process the master channel FIRST each frame and pass its
|
|
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their
|
|
// ring state then advances in lockstep, so the follower's own trigger would have fired
|
|
// on the same frame anyway; skipping its search only removes the second correlation
|
|
// burst (strictly cheaper, never costlier). RT-safe: same guarantees as process().
|
|
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
|
|
|
|
// The splice decision made by the most recent process()/processLinked() call (fired ==
|
|
// false when that frame spliced nothing). Feed to a follower channel's processLinked().
|
|
const SpliceEvent& lastSplice() const { return lastSplice_; }
|
|
|
|
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame
|
|
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore
|
|
// their input and write nothing, but read, splice, and crossfade exactly as before over
|
|
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last
|
|
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices
|
|
// landing in or referenced against it were unalignable, so the tap alternated real-tone /
|
|
// dead-DC at the splice cadence (the DAW "ring modulation" troughs, growing toward the
|
|
// note end as the plateau displaced real history). With the writer frozen the padding
|
|
// never enters the ring: every splice stays waveform-aligned against real content and
|
|
// the output remains a continuous tone — the final <= one window recycles the frozen
|
|
// tail (correlation-aligned, crossfaded) instead of decaying into chopped DC, and the
|
|
// caller's own note end (its output-frame anchor) bounds how long that lasts. Idempotent;
|
|
// RT-safe (flag + bounded arithmetic, no allocation); cleared by reset()/prime()/warm().
|
|
void freezeTail();
|
|
|
|
bool tailFrozen() const { return tailFrozen_; }
|
|
|
|
// 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.
|
|
bool configured() const { return window_ > 1; }
|
|
|
|
std::int64_t window() const { return window_; }
|
|
|
|
private:
|
|
double readTap(double pos) const; // fractional ring read, linear interp
|
|
// 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). Records the decision in
|
|
// lastSplice_ for a linked follower channel.
|
|
void splice(std::int64_t nominalJump, double delay);
|
|
// Apply a master channel's already-computed splice decision verbatim (no search) —
|
|
// the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_.
|
|
void applySplice(const SpliceEvent& ev);
|
|
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
|
|
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
|
|
AudioSample processImpl(AudioSample in, const SpliceEvent* linked);
|
|
|
|
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 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, fadeLen_)
|
|
std::int64_t fadeFrames_ = 0; // NOMINAL crossfade length (window_/4)
|
|
std::int64_t fadeLen_ = 0; // LIVE crossfade length for the in-flight splice —
|
|
// ratio-scaled at splice time so an up-shift's outgoing
|
|
// tap can never drain into the writer mid-fade
|
|
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
|
|
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
|
|
// the reference read forward from the tap stays behind
|
|
// 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)
|
|
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01):
|
|
// cleared at the top of every frame, set on a splice
|
|
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
|
|
// recycles the ring's frozen real tail, splices still
|
|
// aligned. With the writer parked, a tap drains toward it
|
|
// at ratio_ (not ratio_-1) per frame — splice() scales the
|
|
// live fade by that rate.
|
|
};
|
|
|
|
} // namespace reasampler::instrument::engine
|