Files
reasampler/src/core/instrument/engine/time_stretch.h
T
daniel abace156a5 Fix inverted splice-cadence test: assert artifact energy, not zero-crossing period
Zero-crossing counting was anti-correlated with the real defect (splice debris
fools it). Now asserts energy outside the fundamental, with an alignable control,
matching test_preserve_low_frequency.cpp's approach.
2026-08-02 13:47:19 -04:00

102 lines
5.8 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 `window / |rate - shift|` output frames (the tap's
// delay drifts across one window 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):
// 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
// actually fine. (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.)
//
// A SECOND, INDEPENDENT limit binds the same material, and no rate bound touches it. A splice
// relocates the tap by the nominal window refined by a search over +/- window/4, so the
// reachable relocation distances are exactly [0.75, 1.25] * window; a phase-aligned splice
// needs a WHOLE NUMBER of source periods inside that one 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 is reachable by nothing. Because both the
// interval and the period scale with the sample rate, the unalignable set is fixed in Hz by
// the window's MILLISECONDS: at 50 ms that is f < 16 Hz and 26.7 Hz < f < 32 Hz. Measured
// (Release, 44.1k and 48k) at 30 Hz: the rendered pitch stays correct, but energy outside the
// fundamental is 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 lands 7.4% flat. Unlike the
// cadence inequality above, this one is not about how OFTEN a splice fires — a window of at
// least two source periods removes it outright, and nothing else does.
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