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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user