Γ-W1-T5: a real Preserve time-stretcher — write rate is duration, tap rate is pitch
Generalizes the correlation-aligned SOLA delay line so the feed and the shift are independent rates over one ring. Unity is bit-identical to the shipped read, asserted against a hash baseline captured pre-change.
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
#pragma once
|
||||
// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
|
||||
// pitch_shift — per-voice pitch shifter and time-stretcher (the Preserve engine's DSP core).
|
||||
// Time-domain delay-line 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 old/new taps crossfade (raised-cosine). Source and output are
|
||||
// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
|
||||
// `readPos_ += ratio_` resample path).
|
||||
// point is waveform-aligned, then old/new taps crossfade (raised-cosine).
|
||||
//
|
||||
// The WRITE rate (how fast source is consumed = duration) and the TAP rate (setShiftRatio =
|
||||
// pitch) are INDEPENDENT, and only their difference drives the splice cadence. Feeding 1:1 via
|
||||
// process() holds duration and moves pitch; feeding faster/slower via writeFrame() /
|
||||
// processNoInput() moves duration at whatever pitch the tap is set to. Nothing here resamples
|
||||
// to preserve duration — the splice/overlap-add IS the pitch-preserving mechanism, which is
|
||||
// what the "WDL_Resampler is not a Preserve engine" invariant asks for.
|
||||
//
|
||||
// Regression history — do not revert any of these:
|
||||
// - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
|
||||
@@ -89,6 +94,13 @@ public:
|
||||
// ratio) so a bad input never runs the tap backward or stalls it.
|
||||
void setShiftRatio(double ratio);
|
||||
|
||||
// Source frames written per output frame — 1.0 unless the caller is stretching. Used ONLY
|
||||
// to size a splice crossfade safely: the outgoing tap closes on the write head at
|
||||
// (ratio - feedRate) per frame, so a fade sized against an assumed 1.0 overruns when the
|
||||
// source is fed slower than the output runs and the tail of the fade reads lapped content.
|
||||
// Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
|
||||
void setFeedRate(double rate);
|
||||
|
||||
// Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
|
||||
// pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
|
||||
// writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
|
||||
@@ -103,6 +115,20 @@ public:
|
||||
// their ring state advances in lockstep. RT-safe: same guarantees as process().
|
||||
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
|
||||
|
||||
// Writes one source frame WITHOUT producing an output frame — the stretch path's surplus
|
||||
// input when the source is consumed faster than the output runs. No splice can fire here:
|
||||
// splices are decided on the read side. No-op while unconfigured or tail-frozen, and it
|
||||
// deliberately leaves lastSplice_ alone so a linked follower's schedule is unaffected.
|
||||
// RT-safe.
|
||||
void writeFrame(AudioSample in);
|
||||
|
||||
// Produces one output frame WITHOUT consuming a source frame — the stretch path's starved
|
||||
// output frame when the source is consumed slower than the output runs. Identical to
|
||||
// process()/processLinked() in every other respect. Returns 0 while unconfigured (there is
|
||||
// no input to pass through). RT-safe.
|
||||
AudioSample processNoInput();
|
||||
AudioSample processNoInputLinked(const SpliceEvent& master);
|
||||
|
||||
const SpliceEvent& lastSplice() const { return lastSplice_; }
|
||||
|
||||
// Call once the source stream is exhausted — no real frame remains to feed process().
|
||||
@@ -137,7 +163,9 @@ private:
|
||||
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);
|
||||
// `write` false is the starved stretch frame: read/splice/advance the taps, but consume no
|
||||
// input and hold the write head (the same shape tail-freezing already takes).
|
||||
AudioSample processImpl(AudioSample in, const SpliceEvent* linked, bool write);
|
||||
|
||||
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
|
||||
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
|
||||
@@ -161,6 +189,7 @@ private:
|
||||
// clamps its up-jump to this so it never lands in
|
||||
// unwritten silence
|
||||
double ratio_ = 1.0; // current shift ratio (>0)
|
||||
double feedRate_ = 1.0; // source frames written per output frame; splice-fade only
|
||||
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
|
||||
// at the top of every frame, set on a splice
|
||||
bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
|
||||
|
||||
Reference in New Issue
Block a user