f1168e16eb
New cadence-collapse-band test at P=1470 shows PSOLA eliminates the corner rather than regressing it (18.52% -> 0.00%).
133 lines
8.4 KiB
C++
133 lines
8.4 KiB
C++
#pragma once
|
|
// time_stretch — the Preserve engine's TIME half: how fast the source is consumed, given a
|
|
// playback rate. It pairs with pitch_shift's PITCH half (how fast the ring's read tap runs);
|
|
// the two rates are independent over one delay ring, and only their difference reaches the
|
|
// splice machinery. Header-inline: every member sits on the per-voice-per-sample feed.
|
|
|
|
#include <cstdint>
|
|
|
|
#include "core/instrument/engine/loop/loop_span.h"
|
|
|
|
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.
|
|
//
|
|
// 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
|
|
// frames (the tap's delay drifts across one nominal jump at that per-frame rate); the shifted
|
|
// tone's own period is `sourcePeriod / shift` output frames. Whenever the recurrence interval
|
|
// is shorter than that period, a splice lands inside a single perceived cycle and the
|
|
// correlation search has less than one period to align against. Measured at rate 4.0, shift
|
|
// 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.)
|
|
//
|
|
// 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
|
|
// (pitch_shift.h), and that answer can land NARROWER than window() — as low as ~0.63*window for
|
|
// some periods — which SHRINKS the interval and moves the failure threshold EARLIER, not later.
|
|
// There is no single closed-form floor for this case (the jump is itself a function of P), so
|
|
// read it at the concrete corner instead: at P=1470 (30 Hz at 44.1k) the same rate 2.0/shift
|
|
// 0.25 corner's jump narrows from window (2205) to 1470, and its interval from 1260 to
|
|
// 1470/1.75 = 840. Independently, at the plain (no time-stretch) rate 1.0 case, solving this
|
|
// same inequality for shift at P=1470 puts the failure threshold at shift = P/(jump+P): 0.4
|
|
// (-16 st) at the fixed-window jump (2205), 0.5 (-12 st) at the pitch-synchronous jump (1470) —
|
|
// the geometry fix that lets 30 Hz align AT ALL moves this unrelated cadence inequality's own
|
|
// trip point from roughly -16 st to roughly -12 st for the same source. Do NOT read this as a
|
|
// proven regression: the inequality above was calibrated for RANDOM-PHASE (unaligned) splices,
|
|
// and a pitch-synchronous splice is waveform-aligned by construction, which the inequality does
|
|
// not model — whether the shorter interval still produces audible debris once every splice
|
|
// lands in phase is what pitch_shift_tests' own P=1470 cadence-collapse-band measurement
|
|
// answers, not this derivation. Do not narrow kStretchRateMin/kStretchRateMax in response to
|
|
// this: sub-50 Hz sine material is first-class product material, not an edge case, and a
|
|
// narrower range does not fix a floor it does not reach.
|
|
//
|
|
// A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now
|
|
// CLOSED for any source whose period is detected, but the geometry is worth keeping because it
|
|
// is what the fixed-window fallback still lives under. A splice relocated the tap by the
|
|
// nominal window refined by a search over +/- window/4, so the reachable relocation distances
|
|
// were exactly [0.75, 1.25] * window; a phase-aligned splice needs a WHOLE NUMBER of source
|
|
// periods inside that interval. The interval is 0.5*window wide, so any period <= window/2
|
|
// always has a multiple in it — but above that, coverage breaks into disjoint bands (n=1 covers
|
|
// periods [0.75, 1.25]*window, n=2 covers [0.375, 0.625]*window) and the gap between them was
|
|
// reachable by nothing. Because both the interval and the period scale with the sample rate,
|
|
// that unalignable set is fixed in Hz by the window's MILLISECONDS: at 50 ms, f < 16 Hz and
|
|
// 26.7 Hz < f < 32 Hz. Measured there (Release, 44.1k and 48k) at 30 Hz: the rendered pitch
|
|
// stayed correct, but energy outside the fundamental was 3.6% at +2 st / rate 1.0 and 15.5% at
|
|
// rate 2.0, against 0.00% at 34 Hz under identical conditions; at 29 Hz / rate 2.0 the tone
|
|
// itself landed 7.4% flat (-133 cents).
|
|
//
|
|
// The fix is not a wider window: it is a nominal jump that is a whole number of the source's
|
|
// own periods, so an aligned landing point exists by construction (pitch_shift.h's
|
|
// periodAlignedJump, fed by period_detect at load). The same measurements then read 0.00% and
|
|
// 0.00%, and 29 Hz renders at +0.0 cents — all from `preserve_low_frequency_tests` (Release,
|
|
// hand-run; it is not in the gated ctest set), the same harness/config as the 3.6%/15.5%/-133
|
|
// cents readings above. The gated suite's own number for this is the floor-relative excess in
|
|
// pitch_shift_tests' testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown, a different
|
|
// quantity from the raw percentages here. What survives: a period longer than the reachable
|
|
// jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no
|
|
// single period falls back to it by design (periodAlignedJump, pitch_shift.h).
|
|
inline constexpr double kStretchRateMin = 0.5;
|
|
inline constexpr double kStretchRateMax = 2.0;
|
|
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax)
|
|
|
|
// 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
|
|
// run the tap backward). 1.0 in gives exactly 1.0 out, which is what keeps the unity read
|
|
// bit-identical.
|
|
inline double clampStretchRate(double rate) {
|
|
if (!(rate > 0.0)) return 1.0;
|
|
if (rate < kStretchRateMin) return kStretchRateMin;
|
|
return rate > kStretchRateMax ? kStretchRateMax : rate;
|
|
}
|
|
|
|
// One Preserve voice's source-feed schedule: a fractional source cursor answering, per OUTPUT
|
|
// frame, which whole source frames fall due. At rate 1.0 that is exactly one frame per output
|
|
// frame with no residue carried — bit for bit the pre-stretch feed.
|
|
class StretchCursor {
|
|
public:
|
|
// `frame` is where the ring prime stopped; the per-frame feed continues there.
|
|
void start(std::int64_t frame) {
|
|
frame_ = frame;
|
|
debt_ = 0.0;
|
|
}
|
|
|
|
// Adds one output frame's worth of source at `rate` and returns how many whole source
|
|
// frames are now due, in [0, kMaxFeedPerFrame]. Take each of them with next(). The clamp
|
|
// lives here rather than at the caller because this return value is the loop bound.
|
|
std::int64_t due(double rate) {
|
|
debt_ += clampStretchRate(rate);
|
|
const std::int64_t whole = static_cast<std::int64_t>(debt_); // debt_ >= 0: trunc = floor
|
|
debt_ -= static_cast<double>(whole);
|
|
return whole;
|
|
}
|
|
|
|
// The next due source frame, wrapped into the sustain loop, advancing the cursor past it.
|
|
// Advances even past the playable span — the caller freezes the shifter's writer there, and
|
|
// a cursor that stalled instead would re-feed one frame forever.
|
|
std::int64_t next(const loop::ResolvedLoop& lp) {
|
|
if (lp.active) {
|
|
while (frame_ >= lp.end) frame_ -= lp.length;
|
|
}
|
|
return frame_++;
|
|
}
|
|
|
|
std::int64_t frame() const { return frame_; }
|
|
|
|
private:
|
|
std::int64_t frame_ = 0;
|
|
double debt_ = 0.0; // fractional source frames carried into the next output frame
|
|
};
|
|
|
|
} // namespace reasampler::instrument::engine
|