Fix cross-rate Preserve splices, re-stamp on host-rate change, name the rate refusal

The splice crossfade now counts output frames and stops before the ring's far end (this also fixes a lap at matched rate). The search strides by the rate ratio. The editor takes its time base from its own decode. Same-rate output is pinned to hashes from before the change.
This commit is contained in:
2026-09-25 14:17:11 -04:00
parent ec60cf78c6
commit 64f314a6a4
24 changed files with 592 additions and 183 deletions
+12 -3
View File
@@ -178,15 +178,24 @@ voice reads SOURCE frames and emits OUTPUT frames, and `SampleData::sourceRateRa
the shifter's tap at note-on.
- **Source frames:** the PCM, the loop and its crossfade, the start point, the Trigger span, the
spline phase, the detected period, and the Preserve ring (`preserveWindowFrames`).
spline phase, the detected period, and the Preserve ring (`preserveWindowFrames`) with every
splice distance in it.
- **Output frames (the host's):** every stage time in `SampleData::play` (resolved at
`SampleData::renderRate`), the filter solve, the live ramps and the declick. A source-offset
envelope converts through `stageFitRate`, which carries the ratio.
`SampleData::renderRate`), the filter solve, the live ramps, the declick, and the shifter's
splice crossfade (`PitchShifter::setSourceRateRatio`, which also strides the splice search to
a matched rate's cost). A source-offset envelope converts through `stageFitRate`, which
carries the ratio.
- **Rejected alternative: resample at load.** It would round every loop point into the host
domain (detuning a single-cycle loop), force the editor and the bank intrinsics through a
conversion at every edge, and need a band-limited resampler in the core. The cost of the
chosen shape is that a mismatched capture's ROOT note is interpolated like any other repitched
note already is.
- **That cost is asymmetric.** Nothing band-limits the read, so a file ABOVE the host rate
folds whatever it holds over the host's Nyquist into the audible band even at its root — the
aliasing a matched capture only meets when transposed up. A file BELOW the host rate is read
slower than one frame per frame and only images, the milder direction. Offline captures
render at the project rate (`shell/capture` pins `RENDER_SRATE`), so the exposed case is an
ingested high-rate file, or a host running below the rate a capture was made at.
### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30)
+75 -30
View File
@@ -60,6 +60,8 @@ void PitchShifter::configure(std::int64_t windowFrames) {
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
sourceRatio_ = 1.0;
stride_ = 1;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
return;
@@ -67,26 +69,43 @@ void PitchShifter::configure(std::int64_t windowFrames) {
// 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_: nominal splice crossfade; only safe while the outgoing tap can't reach
// the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
// past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
// Geometry (all quarters of the window, in SOURCE frames):
// - maxLag_: 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).
// - dLow_/dHigh_: safe delay band; unity parks the tap mid-band (window/2 delay).
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
// the splice burst.
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
// fadeFrames_ and corrFrames_ also scale with the source ratio — applySourceRatio.
maxLag_ = window_ / 4;
dLow_ = window_ / 4;
dHigh_ = ringLen_ - window_ / 4;
corrFrames_ = std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512));
// The delay band is (dHigh_ - dLow_) wide and the search can add up to maxLag_ on either
// side; one frame more than that and a jump could land exactly ON a trigger boundary.
jumpMax_ = std::max<std::int64_t>(1, dHigh_ - dLow_ - maxLag_ - 1);
fadeLen_ = 0;
reset();
reset(); // applies the unity source ratio
}
void PitchShifter::applySourceRatio() {
if (window_ <= 1) return;
// (setSourceRateRatio owns why each of these moves with the ratio.)
// - fadeFrames_: nominal splice crossfade, in OUTPUT frames. Only safe while the outgoing
// tap can reach neither the writer nor the ring's far end before the fade ends; splice()
// shortens the live fade whenever the drift says it would. Rounded, so 1.0 lands on
// window/4 exactly.
// - stride_: unstrided, the search's cost would grow with the ratio — a burst on one audio
// frame, stacked across every voice that splices on it.
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 SAMPLED
// frames bounds the splice burst and keeps the reference's wall-clock span.
// Both clamped in double before the int64 cast: the setter accepts any positive ratio.
const double fade = static_cast<double>(window_ / 4) / sourceRatio_ + 0.5;
fadeFrames_ = std::max<std::int64_t>(
static_cast<std::int64_t>(
std::min(fade, static_cast<double>(std::numeric_limits<std::int32_t>::max()))),
1);
const double stride = std::min(sourceRatio_ + 0.5, static_cast<double>(maxLag_));
stride_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(stride));
corrFrames_ =
std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512 * stride_));
}
void PitchShifter::reset() {
@@ -111,6 +130,8 @@ void PitchShifter::reset() {
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
sourceRatio_ = 1.0;
applySourceRatio();
period_ = 0.0;
jump_ = window_ > 1 ? window_ : 0;
tailFrozen_ = false;
@@ -195,6 +216,12 @@ void PitchShifter::setFeedRate(double rate) {
if (rate > 0.0) feedRate_ = rate;
}
void PitchShifter::setSourceRateRatio(double ratio) {
if (!(ratio > 0.0)) return;
sourceRatio_ = ratio;
applySourceRatio();
}
double PitchShifter::readTap(double pos) const {
// Fractional linear interpolation with ring wrap.
double p = pos;
@@ -214,9 +241,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
// content, -window_ = toward the writer), refined by a correlation search so the relocated
// read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
// (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
// sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
// per splice.
// (step 4*stride_ over +/- maxLag_) then fine (+/- 3 strides around the coarse best, +/-
// stride_-1 single frames around that, then a parabolic sub-sample peak), each score
// sampling every stride_-th frame: a bounded burst of ~ (maxLag_/(2*stride_) + 2*stride_ +
// 7) * corrFrames_/stride_ multiply-adds, once per splice.
const std::int64_t d = static_cast<std::int64_t>(delay);
// An up-jump may only relocate into valid history. The deepest slot the search (plus the
@@ -247,13 +275,13 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
std::int64_t ia = iA;
std::int64_t ic = ((iA - jump + lag) % ringLen_ + ringLen_) % ringLen_;
double s = 0.0, ec = 0.0;
for (std::int64_t k = 0; k < corr; ++k) {
for (std::int64_t k = 0; k < corr; k += stride_) {
const double a = static_cast<double>(ring_[static_cast<std::size_t>(ia)]);
const double c = static_cast<double>(ring_[static_cast<std::size_t>(ic)]);
s += a * c;
ec += c * c;
if (++ia >= ringLen_) ia = 0;
if (++ic >= ringLen_) ic = 0;
if ((ia += stride_) >= ringLen_) ia -= ringLen_;
if ((ic += stride_) >= ringLen_) ic -= ringLen_;
}
// Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
// so on a decaying tail every up-splice would prefer the loudest candidate over the
@@ -264,7 +292,7 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
std::int64_t bestLag = 0;
double bestScore = -std::numeric_limits<double>::infinity();
for (std::int64_t lag = -maxLag_; lag <= maxLag_; lag += 4) {
for (std::int64_t lag = -maxLag_; lag <= maxLag_; lag += 4 * stride_) {
const double s = scoreAt(lag);
if (s > bestScore) {
bestScore = s;
@@ -272,7 +300,7 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
}
}
const std::int64_t coarse = bestLag;
for (std::int64_t lag = coarse - 3; lag <= coarse + 3; ++lag) {
for (std::int64_t lag = coarse - 3 * stride_; lag <= coarse + 3 * stride_; lag += stride_) {
if (lag == coarse || lag < -maxLag_ || lag > maxLag_) continue;
const double s = scoreAt(lag);
if (s > bestScore) {
@@ -280,6 +308,16 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
bestLag = lag;
}
}
// Empty at a stride of 1, which is what keeps the matched-rate search unchanged.
const std::int64_t strided = bestLag;
for (std::int64_t lag = strided - (stride_ - 1); lag <= strided + (stride_ - 1); ++lag) {
if (lag == strided || lag < -maxLag_ || lag > maxLag_) continue;
const double s = scoreAt(lag);
if (s > bestScore) {
bestScore = s;
bestLag = lag;
}
}
// Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
// sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
@@ -305,26 +343,33 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
while (p < 0.0) p += len;
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 - 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.
// Drift-scaled fade length. The outgoing tap's delay behind the writer moves by
// (feedRate - ratio) source frames per output frame, and the fade lasts fadeFrames_ OUTPUT
// frames, so it moves the tap drift x fadeFrames_ — a quarter window at |drift| == 1 at a
// matched rate. Past that it runs out of ring in whichever direction it drifts:
// - drain (ratio > feedRate; up-shifts, a slow feed): toward the writer, where it would
// read stale read-ahead data (e.g. +24 st = ratio 4, or a half-speed feed under any
// up-shift);
// - recede (ratio < feedRate; down-shifts, a fast feed): toward the ring's far end, where
// it would read audio the writer has already replaced (e.g. -24 st at Rate 200 %).
// Cap the live fade at the headroom actually left in that direction — a quarter window
// either side of the band — minus the one frame's trigger overshoot and 2 (the integer
// feed's jitter + interpolator read-ahead margin).
//
// 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_ 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_ - feedRate_);
if (drainRate > 0.0) {
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
if (drainRate != 0.0) {
const double rate = std::fabs(drainRate);
const double room = drainRate > 0.0 ? static_cast<double>(dLow_)
: static_cast<double>(ringLen_ - dHigh_);
const double headroom = room - rate - 2.0;
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
// at very high sample rates (where headroom/drainRate could overflow int64).
// at very high sample rates (where headroom/rate could overflow int64).
const double safeDbl = headroom > 0.0
? std::min(headroom / drainRate, static_cast<double>(fadeFrames_))
? std::min(headroom / rate, static_cast<double>(fadeFrames_))
: 1.0;
fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl));
}
+28 -10
View File
@@ -118,6 +118,15 @@ public:
// Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
void setFeedRate(double rate);
// Source frames per output frame at unity (SampleData::sourceRateRatio). The ring and every
// splice distance are SOURCE frames, but a crossfade is counted per OUTPUT frame, so the
// fade is window/4 source frames' worth of output time — the same wall-clock length and the
// same share of the splice cadence at any ratio. It also strides the correlation search by
// the nearest whole ratio, so a splice's cost and its wall-clock reach are a matched rate's
// at any file rate. Values <= 0 are ignored; exactly 1.0 is the matched geometry bit for
// bit. Reset to 1.0 by configure()/reset(), like the feed rate.
void setSourceRateRatio(double ratio);
// The period of the source being fed, in SOURCE frames, making every splice jump a whole
// number of it — <= 0 means "unknown" (see periodAlignedJump for the exact fallback); the
// default, so a caller that never calls this sees no change.
@@ -195,6 +204,9 @@ private:
// `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);
// Re-derives the three quantities sourceRatio_ scales (fadeFrames_, stride_, corrFrames_)
// from the window's fixed geometry.
void applySourceRatio();
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
@@ -204,11 +216,13 @@ private:
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 fadeFrames_ = 0; // NOMINAL crossfade length in OUTPUT frames: window_/4
// source frames at sourceRatio_ (window_/4 at 1.0)
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)
// drift-scaled at splice time so the outgoing tap can
// never reach the writer, or lap the ring's far end,
// mid-fade
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4 source frames)
double period_ = 0.0; // source period in frames, 0 = unknown (fixed-window)
std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ retunes it
std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY
@@ -216,15 +230,19 @@ private:
// period-sized jump can never land back on a trigger and
// thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_). At
// jump_==jumpMax_ a DOWN-splice's correlation read comes
// within ~41 frames of the write head (measured: the
// exact ring/lag/corrFrames_ geometry at the product
// window, worst case over every lag the search reaches) —
// within dLow_+2-corrFrames_ frames of the write head at
// the worst lag the search reaches (41 at a 44.1k 50 ms
// window; never under 3, since corrFrames_ <= dLow_-1) —
// real margin, not zero, but tight enough that widening
// maxLag_, corrFrames_ or jumpMax_ without re-deriving
// this bound risks reading unwritten ring content.
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 corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512
// SAMPLED frames — 512*stride_ — so the reference read
// forward from the tap stays behind the writer by
// construction at an up-splice)
std::int64_t stride_ = 1; // correlation sample + coarse-lag stride: the nearest whole
// sourceRatio_, floored at 1
double sourceRatio_ = 1.0; // source frames per output frame at unity
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; splice()
+19 -7
View File
@@ -104,8 +104,8 @@ enum class PitchEngine { Varispeed, Preserve };
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// OLA window for the Preserve PitchShifter, in ms of SOURCE (preserveWindowFrames); larger =
// smoother on big transpositions. Onset latency is zero — start() primes the ring with the first window
// of real source, so output frame 0 is source frame 0 regardless of window size.
// smoother on big transpositions. Onset latency is zero — start() primes the ring with the first
// window of real source, so output frame 0 is source frame 0 regardless of window size.
inline constexpr double kPreserveWindowMs = 50.0;
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 0 ->
@@ -324,15 +324,27 @@ struct SampleData {
}
};
// The largest sourceRateRatio a load accepts. The Preserve feed consumes that many times the
// stretch rate in source frames per output frame, so the ratio bounds per-frame work on the
// audio thread; a header claiming more than this is treated as corrupt (buildSampleData).
// The largest sourceRateRatio a load accepts. It is the one per-frame cost the ratio scales: a
// Preserve voice writes up to ceil(kStretchRateMax x ratio) source frames into its ring per
// output frame (StretchCursor::due). The splice search does NOT scale with it — pitch_shift
// strides it to a matched rate's cost. A header claiming more than this is treated as corrupt
// (buildSampleData).
inline constexpr double kMaxSourceRateRatio = 32.0;
// THE refusal predicate for that bound: whether a file at `sourceRate` may play in a host
// emitting `renderRate` (<= 0: not reported, so the file plays at its own rate). Named apart
// from the build so a shell can tell the user WHY a load is silent — the file decoded fine.
inline bool sourceRateSupported(int sourceRate, int renderRate) {
if (sourceRate <= 0 || renderRate <= 0) return true;
return static_cast<double>(sourceRate) / static_cast<double>(renderRate) <=
kMaxSourceRateRatio;
}
// The Preserve ring's window in SOURCE frames. The ring holds source and its feed and tap both
// carry sourceRateRatio, so a window sized at the source rate keeps every splice interval,
// search reach and alignable period in time_stretch.h fixed in Hz at any host rate. Floored at 2
// so a valid window is always a real ring.
// search reach and alignable period in time_stretch.h fixed in Hz at any host rate; the
// shifter's crossfade and search stride are what it converts to output frames
// (PitchShifter::setSourceRateRatio). Floored at 2 so a valid window is always a real ring.
inline std::int64_t preserveWindowFrames(const SampleData& s) {
const std::int64_t w = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(s.sampleRate) / 1000.0 + 0.5);
+16 -13
View File
@@ -11,8 +11,9 @@
namespace reasampler::instrument::engine {
// The playback rates the Preserve DSP is measured over, and therefore the only ones it
// accepts. The ceiling also bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame
// source frames) — the RT-safety argument for feeding a variable count at all.
// accepts. The ceiling, times the load-bounded source/host ratio, also bounds a voice's
// per-output-frame feed loop (StretchCursor::due) — the RT-safety argument for feeding a
// variable count at all.
//
// This range NARROWS the splice-cadence failure onto the source fundamental; it does not
// eliminate it. A splice recurs every `pitch_shift.h`'s spliceJump() / |rate - shift| output
@@ -23,14 +24,16 @@ namespace reasampler::instrument::engine {
// 0.25 (-24 st), fixed-window jump (2205): interval 2205/3.75 ~= 588 vs period ~4*P ~= 785
// frames (P ~= 196) — matches the originally observed 539-vs-785 failure. This range's ceiling
// (2.0, not 4.0) raises the safe floor, it does not remove it: at rate 2.0, shift 0.25, interval
// = 2205/1.75 = 1260 still produces measurable splice debris for any source period P > 315
// frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -24 st is reachable from the
// Pitch knob alone. pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter)
// asserts this corner directly at P=500/600/700: energy outside the fundamental runs 7-21% there
// against ~0% on an aligned control at the same rate/shift — zero-crossing period is NOT what it
// checks, since splice debris fools that estimator into reading the wrong period on a render
// whose fundamental is provably correct. (The pre-stretch rate-1.0 engine's floor by the same
// inequality is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.)
// = 2205/1.75 = 1260 puts any source period P > 315 frames (~140 Hz at 44.1k) inside the
// inequality — bass/low-vocal material, and -24 st is reachable from the Pitch knob alone.
// pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) measures this
// corner directly at P=500/600/700 — by energy outside the fundamental, NOT zero-crossing
// period, since splice debris fools that estimator into reading the wrong period on a render
// whose fundamental is provably correct. Its raw 7-21% readings are the metric's own floor, and
// the 0.2-0.5% excess once measured over that floor was not the cadence at all: it was this
// corner's down-shift crossfade lapping the ring's far end, which splice()'s fade cap closed —
// the excess now reads 0.00%. (The pre-stretch rate-1.0 engine's floor by the same inequality
// is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.)
//
// The above derives the floor with jump == window(), which is only the FIXED-WINDOW half of
// the story. Once a source period is known, spliceJump() is periodAlignedJump's answer instead
@@ -83,7 +86,6 @@ namespace reasampler::instrument::engine {
// holds in wall-clock terms at any ratio (preserveWindowFrames, play_params.h, owns why).
inline constexpr double kStretchRateMin = 0.5;
inline constexpr double kStretchRateMax = 2.0;
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) at a matched source rate
// Non-positive and NaN fold to unity rather than to the minimum: an unusable rate should leave
// playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to
@@ -102,8 +104,9 @@ class StretchCursor {
public:
// `frame` is where the ring prime stopped; the per-frame feed continues there.
// `sourceRateRatio` is source frames per output frame at rate 1.0 (SampleData's own) —
// exactly 1.0 at a matched rate, where the multiply below is the identity.
void start(std::int64_t frame, double sourceRateRatio = 1.0) {
// exactly 1.0 at a matched rate, where the multiply below is the identity. Required, so no
// caller can drop the source/host conversion from the feed by omission.
void start(std::int64_t frame, double sourceRateRatio) {
frame_ = frame;
debt_ = 0.0;
sourceRatio_ = sourceRateRatio > 0.0 ? sourceRateRatio : 1.0;
+2
View File
@@ -248,6 +248,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
stretch_.start(p, sourceRatio_);
shiftL_.setFeedRate(feedRate_);
shiftR_.setFeedRate(feedRate_);
shiftL_.setSourceRateRatio(sourceRatio_);
shiftR_.setSourceRateRatio(sourceRatio_);
// Pitch-synchronous splices: the period was detected once at load (period_detect,
// which this library deliberately does not link — the loader hands the answer down on
// SampleData). 0 restores the fixed-window geometry, so a capture with no single
+4 -3
View File
@@ -556,9 +556,10 @@ private:
shiftL_.setShiftRatio(shift);
if (stereoOut) shiftR_.setShiftRatio(shift);
// 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are
// written without producing output; the last rides the ordinary 1-in-1-out
// process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged.
// 0..ceil(kStretchRateMax x sourceRatio_) source frames fall due this output frame
// (StretchCursor::due). All but the LAST are written without producing output; the
// last rides the ordinary 1-in-1-out process(), so a feed of exactly 1.0 walks the
// pre-stretch code path unchanged.
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const std::int64_t due = stretch_.due(stretchRate_);
+3 -3
View File
@@ -316,11 +316,11 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded,
assert(decoded.sampleRate > 0 &&
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
data.sampleRate = decoded.sampleRate;
data.renderRate = renderRate > 0 ? renderRate : 0;
// A header rate this far above the host's is corrupt, not a capture — and playing it would
// put that ratio's worth of feed work on the audio thread per frame (kMaxSourceRateRatio).
if (data.sourceRateRatio() > kMaxSourceRateRatio) return SampleData{};
if (!sourceRateSupported(decoded.sampleRate, renderRate)) return data;
data.sampleRate = decoded.sampleRate;
data.renderRate = renderRate > 0 ? renderRate : 0;
data.frames = std::move(decoded.monoFrames);
// Carry the second channel only when it length-matches channel 0 (channelCount()
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).