Γ-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,8 +1,9 @@
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
|
||||
//
|
||||
// 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
|
||||
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per source
|
||||
// frame the caller feeds; the active read tap advances by the shift `ratio_` per OUTPUT frame,
|
||||
// so its delay behind the writer drifts at (feedRate - ratio) per frame — one frame in, one
|
||||
// frame out (`feedRate == 1`) preserves duration, and any other feed cadence stretches it. When
|
||||
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
|
||||
// one window — clamped to the filled span so it never lands in unwritten silence — refined
|
||||
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
|
||||
@@ -38,6 +39,7 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
|
||||
filled_ = 0;
|
||||
ratio_ = 1.0;
|
||||
feedRate_ = 1.0;
|
||||
tailFrozen_ = false;
|
||||
lastSplice_ = SpliceEvent{};
|
||||
return;
|
||||
@@ -85,6 +87,7 @@ void PitchShifter::reset() {
|
||||
}
|
||||
filled_ = 0;
|
||||
ratio_ = 1.0;
|
||||
feedRate_ = 1.0;
|
||||
tailFrozen_ = false;
|
||||
lastSplice_ = SpliceEvent{};
|
||||
}
|
||||
@@ -93,7 +96,7 @@ void PitchShifter::freezeTail() {
|
||||
if (window_ <= 1 || tailFrozen_) return;
|
||||
tailFrozen_ = true;
|
||||
// An in-flight crossfade was sized for a retreating writer (outgoing tap drains at
|
||||
// ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
|
||||
// ratio-feedRate per frame); frozen, it closes at the full ratio instead. Cap the live fade so
|
||||
// it completes before tap B reaches the parked writer and reads lapped content mid-fade.
|
||||
if (fading_) {
|
||||
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
|
||||
@@ -158,6 +161,10 @@ void PitchShifter::setShiftRatio(double ratio) {
|
||||
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run the tap backward/stall)
|
||||
}
|
||||
|
||||
void PitchShifter::setFeedRate(double rate) {
|
||||
if (rate > 0.0) feedRate_ = rate;
|
||||
}
|
||||
|
||||
double PitchShifter::readTap(double pos) const {
|
||||
// Fractional linear interpolation with ring wrap.
|
||||
double p = pos;
|
||||
@@ -266,18 +273,19 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
while (p >= len) p -= len;
|
||||
posA_ = p;
|
||||
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
|
||||
// writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
|
||||
// writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
|
||||
// and play stale read-ahead data. Cap the live fade at the drain headroom actually
|
||||
// available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
|
||||
// drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
|
||||
// so they always keep the full fade.
|
||||
// writer at (ratio - feedRate) per frame; the nominal window/4 fade only keeps it behind
|
||||
// the writer while that rate stays under ~1 — beyond that (e.g. +24 st = ratio 4, or a
|
||||
// half-speed feed under any up-shift) it would cross mid-fade and play stale read-ahead
|
||||
// data. Cap the live fade at the drain headroom actually available, minus 2 (trigger
|
||||
// undershoot + interpolator read-ahead margin). A drain rate at or below zero (down-shifts,
|
||||
// and up-shifts the feed outruns) can't reach the ring end within window/4 frames, so those
|
||||
// always keep the full fade.
|
||||
//
|
||||
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
|
||||
// either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
|
||||
// applies at every ratio (including unity, since delay now drains at unity too).
|
||||
// either shift direction, so the drain rate is ratio_ regardless of feed and the cap applies
|
||||
// at every ratio (including unity, since delay now drains at unity too).
|
||||
fadeLen_ = fadeFrames_;
|
||||
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
|
||||
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - feedRate_);
|
||||
if (drainRate > 0.0) {
|
||||
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
|
||||
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
|
||||
@@ -310,14 +318,27 @@ void PitchShifter::applySplice(const SpliceEvent& ev) {
|
||||
lastSplice_ = ev; // observable mirror (tests assert follower == master per frame)
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); }
|
||||
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr, true); }
|
||||
|
||||
AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) {
|
||||
return processImpl(in, &master);
|
||||
return processImpl(in, &master, true);
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
|
||||
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
|
||||
AudioSample PitchShifter::processNoInput() { return processImpl(0.0f, nullptr, false); }
|
||||
|
||||
AudioSample PitchShifter::processNoInputLinked(const SpliceEvent& master) {
|
||||
return processImpl(0.0f, &master, false);
|
||||
}
|
||||
|
||||
void PitchShifter::writeFrame(AudioSample in) {
|
||||
if (window_ <= 1 || tailFrozen_) return;
|
||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||
if (filled_ < ringLen_) ++filled_;
|
||||
if (++writePos_ >= ringLen_) writePos_ = 0;
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked, bool write) {
|
||||
if (window_ <= 1) return write ? in : 0.0f; // pass-through (unconfigured / degenerate)
|
||||
|
||||
// Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
|
||||
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
|
||||
@@ -325,8 +346,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
|
||||
// Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
|
||||
// ring keeps its all-real final two windows) and hold the write head; read/splice/fade
|
||||
// below run unchanged over the frozen content.
|
||||
if (!tailFrozen_) {
|
||||
// below run unchanged over the frozen content. A starved stretch frame (`write` false) takes
|
||||
// the identical shape: no input was due this output frame, so there is nothing to write.
|
||||
if (write && !tailFrozen_) {
|
||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||
if (filled_ < ringLen_) ++filled_;
|
||||
}
|
||||
@@ -378,8 +400,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
}
|
||||
}
|
||||
|
||||
// Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
|
||||
if (!tailFrozen_) {
|
||||
// Advance heads: write head one frame (parked while tail-frozen or starved), tap(s) by the
|
||||
// shift ratio.
|
||||
if (write && !tailFrozen_) {
|
||||
++writePos_;
|
||||
if (writePos_ >= ringLen_) writePos_ = 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user