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).
|
||||
|
||||
@@ -214,7 +214,7 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
|
||||
## Modules
|
||||
|
||||
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source — or when the host rate is not the one the park (or a publish that landed while inactive) was resolved for, since every stage time in it and in the live block is in that rate's frames. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. A host-rate change while inactive does NOT re-decode: every stage time in the park (or in a publish that landed while inactive) and in the live block is in that rate's frames, so the resume re-stamps those in place (`restampHostRateLocked`) and falls back to the full reload only when the new rate refuses the file's (`sourceRateSupported`). Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
|
||||
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
||||
|
||||
@@ -100,8 +100,9 @@ void ReaSamplerEditor::resetParamControl(int id) {
|
||||
resetDeckParam(static_cast<instrument::ui::DeckParam>(id), params_.play);
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::liveSampleRate() const {
|
||||
return processor_ ? static_cast<double>(processor_->loadedSourceRate()) : 0.0;
|
||||
double ReaSamplerEditor::selectedSourceRate() const {
|
||||
const auto it = pcmCache_.find(selectedId_);
|
||||
return it != pcmCache_.end() ? static_cast<double>(it->second.sampleRate) : 0.0;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::previewVelocity01() const {
|
||||
|
||||
@@ -42,7 +42,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
||||
// (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode),
|
||||
// so at most one of the two hit-tests below is ever live for the same click — both feed the
|
||||
// SAME arbitration slot below rather than either one getting its own check-order return.
|
||||
const double rate = liveSampleRate();
|
||||
const double rate = selectedSourceRate();
|
||||
const bool nodesLive =
|
||||
overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates);
|
||||
NodeHit envNodeHit;
|
||||
@@ -245,7 +245,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
|
||||
// set. The StageEnvelope was snapshotted at grab (dragStartEnv_) so the delta is
|
||||
// absolute.
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
const double rate = liveSampleRate();
|
||||
const double rate = selectedSourceRate();
|
||||
if (frames <= 0 || rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const DragModifiers mods = dragModifiers();
|
||||
|
||||
@@ -63,7 +63,7 @@ StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds
|
||||
std::int64_t frames,
|
||||
std::int64_t startFrame) const {
|
||||
StageEnvelope env;
|
||||
const double rate = liveSampleRate();
|
||||
const double rate = selectedSourceRate();
|
||||
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
|
||||
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
|
||||
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
|
||||
|
||||
@@ -107,8 +107,14 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
|
||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||
// (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
if (!selectedId_.empty()) {
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_);
|
||||
// The waveform still draws (the editor decodes on its own), so without this a
|
||||
// refused capture would look loaded and simply never sound.
|
||||
if (processor_->sourceRateRefused())
|
||||
title += " -- " + sourceRateRefusalText() + ", not played";
|
||||
title += "]";
|
||||
}
|
||||
else if (samples_.empty()) title += " [bank empty]";
|
||||
else title += " [pick a capture]";
|
||||
} else {
|
||||
|
||||
@@ -388,7 +388,7 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
|
||||
paintSplineOverlay(bmp, waveArea);
|
||||
return;
|
||||
}
|
||||
const double rate = liveSampleRate();
|
||||
const double rate = selectedSourceRate();
|
||||
if (rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
||||
|
||||
@@ -434,12 +434,12 @@ const ReaSamplerEditor::ChannelPcm& ReaSamplerEditor::channelPcmFor(
|
||||
|
||||
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
|
||||
auto it = pcmCache_.find(sampleId);
|
||||
if (it != pcmCache_.end()) return it->second;
|
||||
if (it != pcmCache_.end()) return it->second.frames;
|
||||
|
||||
// Every failure path caches an empty vector so a broken/missing file is not re-decoded on
|
||||
// Every failure path caches an empty entry so a broken/missing file is not re-decoded on
|
||||
// every paint. Keyed by id (width-independent) — the thumbnail bins this at whatever
|
||||
// width, the snap scans it directly.
|
||||
std::vector<AudioSample> mono;
|
||||
MonoPcm mono;
|
||||
const std::string relativePath = samplePathFor(sampleId);
|
||||
if (!relativePath.empty()) {
|
||||
const std::string projectDir = processor_->bridge().activeProjectDir();
|
||||
@@ -449,11 +449,12 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
if (layout.valid) {
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
||||
mono = downmixToMono(interleaved, layout.channelCount);
|
||||
mono.frames = downmixToMono(interleaved, layout.channelCount);
|
||||
mono.sampleRate = static_cast<int>(layout.sampleRate);
|
||||
}
|
||||
}
|
||||
auto ins = pcmCache_.emplace(sampleId, std::move(mono));
|
||||
return ins.first->second;
|
||||
return ins.first->second.frames;
|
||||
}
|
||||
|
||||
const Envelope& ReaSamplerEditor::heroEnvelope(std::size_t bins, int laneCount) {
|
||||
|
||||
@@ -128,14 +128,16 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
||||
|
||||
// The snapshot comes first: the default program's window is derived from the sound it
|
||||
// carries (a Gate release, a Trigger play span), not from a constant.
|
||||
std::optional<SampleData> snapshot = processor.bakeSnapshot(sampleRate);
|
||||
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
|
||||
ReaSamplerProcessor::BuiltCapture built = processor.bakeSnapshot(sampleRate);
|
||||
if (built.rateRefused) return fail("cannot render: " + sourceRateRefusalText());
|
||||
if (!built.sample) return fail("the loaded capture could not be decoded for the render");
|
||||
SampleData& snapshot = *built.sample;
|
||||
|
||||
// The note fires at the velocity the user has been auditioning at: three velocity curves
|
||||
// are live, so a sound dialed at 127 does not bake as one dialed at 40.
|
||||
const Velocity velocity = Velocity::of(processor.previewVelocity());
|
||||
const instrument::bake::PlannedBake planned = planBake(
|
||||
resolveNote(defaultBakeProgram(*snapshot, sampleRate, dialed.bakeHold, velocity),
|
||||
resolveNote(defaultBakeProgram(snapshot, sampleRate, dialed.bakeHold, velocity),
|
||||
*tempo),
|
||||
sampleRate, rootNote);
|
||||
if (!planned.plan) {
|
||||
@@ -146,7 +148,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
||||
const instrument::bake::BakePlan& plan = *planned.plan;
|
||||
|
||||
const instrument::bake::BakeAudio audio =
|
||||
renderBake(std::move(*snapshot), plan, processor.masterGainLinear(),
|
||||
renderBake(std::move(snapshot), plan, processor.masterGainLinear(),
|
||||
dialed.limiterEnabled);
|
||||
if (audio.empty()) return fail("the offline pass produced no audio");
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -79,20 +80,26 @@ std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
|
||||
|
||||
// The ONE decode + build the reload and the bake snapshot share, so a baked render can
|
||||
// never be built from a different fold than the one the user is hearing.
|
||||
std::optional<SampleData> buildFromRef(const SelectedSample& sel,
|
||||
ReaSamplerProcessor::BuiltCapture buildFromRef(const SelectedSample& sel,
|
||||
const InstrumentParams& params,
|
||||
const std::string& projectDir, ChannelMode mode,
|
||||
int renderRate) {
|
||||
const std::string& projectDir,
|
||||
ChannelMode mode, int renderRate) {
|
||||
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode);
|
||||
if (!pcm) return std::nullopt;
|
||||
if (!pcm) return {};
|
||||
if (!sourceRateSupported(pcm->sampleRate, renderRate)) return {std::nullopt, true};
|
||||
SampleData sample =
|
||||
buildSampleData(resolveCapture(sel, params), std::move(*pcm), renderRate);
|
||||
if (!sample.playable()) return std::nullopt;
|
||||
return sample;
|
||||
if (!sample.playable()) return {};
|
||||
return {std::move(sample), false};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string sourceRateRefusalText() {
|
||||
return "sample rate over " + std::to_string(static_cast<int>(kMaxSourceRateRatio)) +
|
||||
"x the host's";
|
||||
}
|
||||
|
||||
std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the
|
||||
// retired-slot free is single-writer; never taken on the audio thread.
|
||||
@@ -127,6 +134,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
std::unique_ptr<LoadedInstrument> built;
|
||||
SampleData sample;
|
||||
bool havePlayable = false;
|
||||
bool rateRefused = false;
|
||||
|
||||
// 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard
|
||||
// repitched from its effective root. No first-sample fallback: an empty selection
|
||||
@@ -144,9 +152,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
channelModeExplicit_);
|
||||
mode = channelMode_;
|
||||
}
|
||||
if (std::optional<SampleData> decoded =
|
||||
buildFromRef(*sel, params, projectDir, mode, hostRenderRate())) {
|
||||
sample = std::move(*decoded);
|
||||
BuiltCapture decoded = buildFromRef(*sel, params, projectDir, mode, hostRenderRate());
|
||||
rateRefused = decoded.rateRefused;
|
||||
if (decoded.sample) {
|
||||
sample = std::move(*decoded.sample);
|
||||
havePlayable = true;
|
||||
// Point the built snapshot at the instance's ONE engine-facing block and seed the
|
||||
// MODEL block from the very PlayParams the voices latch, so an untouched knob folds
|
||||
@@ -161,7 +170,6 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// build's rate — or, on the first-ever build, against 0, where secondsToFrames
|
||||
// collapses every automated envelope stage to zero frames.
|
||||
builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed);
|
||||
loadedSourceRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
||||
{
|
||||
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
|
||||
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
|
||||
@@ -172,6 +180,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
}
|
||||
}
|
||||
|
||||
sourceRateRefused_.store(rateRefused, std::memory_order_relaxed);
|
||||
if (havePlayable) built = buildInstrumentLocked(std::move(sample));
|
||||
|
||||
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
|
||||
@@ -192,9 +201,9 @@ std::string ReaSamplerProcessor::usageInstanceGuid() {
|
||||
return instanceGuid_;
|
||||
}
|
||||
|
||||
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot(int renderRate) {
|
||||
ReaSamplerProcessor::BuiltCapture ReaSamplerProcessor::bakeSnapshot(int renderRate) {
|
||||
const std::string selId = selectedSampleId();
|
||||
if (selId.empty()) return std::nullopt;
|
||||
if (selId.empty()) return {};
|
||||
const InstrumentParams params = instrumentParams();
|
||||
SampleRefs refs;
|
||||
{
|
||||
@@ -202,7 +211,7 @@ std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot(int renderRate) {
|
||||
refs = sampleRefs_;
|
||||
}
|
||||
const SelectedSample* sel = findRef(refs, selId);
|
||||
if (!sel) return std::nullopt;
|
||||
if (!sel) return {};
|
||||
// No live block is attached: the bake renders the stored parameter set, which is what
|
||||
// every knob has already written, rather than whatever the audio thread is observing.
|
||||
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode(), renderRate);
|
||||
@@ -357,17 +366,24 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
|
||||
// voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
// Every frame count in a built sample — and in the live block published beside it — was
|
||||
// resolved for the host rate of its build. setupProcessing may have changed that rate while
|
||||
// inactive (or a setState load ran before the host reported one), and neither a park nor a
|
||||
// publish can be re-used across it: the full reload re-resolves both.
|
||||
// resolved for the host rate of its build, and setupProcessing may have changed that rate
|
||||
// while inactive (or a setState load ran before the host reported one). Only the resolve
|
||||
// depends on it, never the decode, so both paths below re-stamp rather than re-read a file
|
||||
// that may since have gone missing.
|
||||
const int renderRate = hostRenderRate();
|
||||
// A publish that landed while inactive (setState's reload, a bake's adopt) IS the
|
||||
// activation state — its voices have never rendered, and it has already superseded the
|
||||
// park. Rebuilding here would displace a correct instrument into the drain slot.
|
||||
// park. Rebuilding here at an unchanged rate would displace a correct instrument into the
|
||||
// drain slot; at a changed one the rebuild is the re-stamp, and there is nothing sounding
|
||||
// to displace.
|
||||
if (const LoadedInstrument* cur = live_.load(std::memory_order_acquire)) {
|
||||
return cur->sample.renderRate == renderRate;
|
||||
if (cur->sample.renderRate == renderRate) return true;
|
||||
SampleData restamped = cur->sample;
|
||||
if (!restampHostRateLocked(restamped, renderRate)) return false;
|
||||
publishBuiltLocked(buildInstrumentLocked(std::move(restamped)));
|
||||
return true;
|
||||
}
|
||||
if (!dormantSample_ || dormantSample_->renderRate != renderRate) return false;
|
||||
if (!dormantSample_) return false;
|
||||
|
||||
// The activation is still where a bank change made with NO EDITOR OPEN is picked up:
|
||||
// pollBankSync, the only other route to either of the two calls below, runs off the
|
||||
@@ -396,6 +412,7 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
|
||||
// and refreshing the refs without re-decoding would leave the table naming one file while
|
||||
// the voices play another. Hand back to the full reload, which decodes the new one.
|
||||
if (sourceMoved) return false;
|
||||
if (!restampHostRateLocked(*dormantSample_, renderRate)) return false;
|
||||
|
||||
// MOVED, not copied: the park exists for this one handoff, and keeping it would hold a
|
||||
// second copy of the PCM for the whole active lifetime. Disengaged BEFORE the build so a
|
||||
@@ -410,6 +427,20 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::restampHostRateLocked(SampleData& sample, int renderRate) {
|
||||
if (sample.renderRate == renderRate) return true;
|
||||
if (!sourceRateSupported(sample.sampleRate, renderRate)) return false;
|
||||
sample.renderRate = renderRate;
|
||||
// The model's stored seconds, which is exactly what reloadInstrument resolves (via
|
||||
// resolveCapture) — so the re-stamp and a disk reload land on the same frames.
|
||||
sample.play = resolvePlay(instrumentParams().play, sample.outputRate());
|
||||
// BEFORE the caller's publish, for the reason reloadInstrument gives at the same store.
|
||||
builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed);
|
||||
std::lock_guard<std::mutex> lp(livePublishMutex_);
|
||||
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::retireIdleDrain() {
|
||||
// Cheap early-out before the lock: 0 means "no drain, or it still sounds".
|
||||
const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
|
||||
|
||||
@@ -409,10 +409,13 @@ private:
|
||||
|
||||
// The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
|
||||
int effectiveRoot() const;
|
||||
// The loaded capture's OWN rate — the time base that maps its waveform frames to the
|
||||
// seconds a staged envelope stores — or 0 when nothing has loaded (caller guards). Not the
|
||||
// The selected capture's OWN rate — the time base that maps its waveform frames to the
|
||||
// seconds a staged envelope stores — or 0 when nothing is decoded (caller guards). Not the
|
||||
// host's: the voice plays a source frame at the source's rate whatever the host runs at.
|
||||
double liveSampleRate() const;
|
||||
// Read from the same decode the frames came from (monoPcmFor, which every caller has already
|
||||
// run for its frame count), so the two cannot disagree — the processor's own build can
|
||||
// fail while the waveform still draws.
|
||||
double selectedSourceRate() const;
|
||||
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
||||
double previewVelocity01() const;
|
||||
// The bake Hold division as a 0..1 knob position, via the pure bake_hold map.
|
||||
@@ -591,8 +594,13 @@ private:
|
||||
std::unordered_map<std::string, Envelope> thumbCache_;
|
||||
|
||||
// Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope
|
||||
// binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh.
|
||||
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;
|
||||
// binning + zero-crossing snap, and carries the file's own rate beside the frames it is the
|
||||
// time base of (selectedSourceRate). Cleared alongside thumbCache_ on refresh.
|
||||
struct MonoPcm {
|
||||
std::vector<AudioSample> frames;
|
||||
int sampleRate = 0; // 0 = nothing decoded
|
||||
};
|
||||
std::unordered_map<std::string, MonoPcm> pcmCache_;
|
||||
|
||||
// The single-slot interleaved-PCM cache behind channelPcmFor (see its note on why this
|
||||
// is not keyed into pcmCache_). Cleared alongside pcmCache_ on refresh.
|
||||
|
||||
@@ -37,6 +37,10 @@ using instrument::map::kPreviewVelocityDefault;
|
||||
|
||||
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
|
||||
// What the editor and the bake print for a refused file rate (sourceRateSupported), so the
|
||||
// limit is spelled in one place.
|
||||
std::string sourceRateRefusalText();
|
||||
|
||||
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
|
||||
public:
|
||||
ReaSamplerProcessor() = default;
|
||||
@@ -150,8 +154,15 @@ public:
|
||||
// the rate the bake's plan renders at, passed in so the two cannot disagree. Rebuilt
|
||||
// rather than copied off the live snapshot because a tier-3 live edit leaves that
|
||||
// snapshot's own play params stale on purpose — copying it would bake the pre-drag values.
|
||||
// nullopt when nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O).
|
||||
std::optional<SampleData> bakeSnapshot(int renderRate);
|
||||
// Empty when nothing is loaded, the WAV is unreadable, or its rate is refused (then
|
||||
// `rateRefused` says so). Off the audio thread (file I/O).
|
||||
struct BuiltCapture {
|
||||
std::optional<SampleData> sample;
|
||||
// The file decoded, but sourceRateSupported (play_params.h) refuses its rate against
|
||||
// the host's — a distinct failure the user is told about, not an unreadable file.
|
||||
bool rateRefused = false;
|
||||
};
|
||||
BuiltCapture bakeSnapshot(int renderRate);
|
||||
|
||||
// Adopt a landed bake in ONE act: the new capture becomes this instance's ref and
|
||||
// selection, the parameter set and master gain go neutral, and a single reload
|
||||
@@ -186,9 +197,10 @@ public:
|
||||
int hostRenderRate() const {
|
||||
return sampleRate_ > 0.0 ? static_cast<int>(sampleRate_ + 0.5) : 0;
|
||||
}
|
||||
// The loaded capture's OWN rate — the time base of its waveform frames, which is what the
|
||||
// editor's staged-envelope overlay maps seconds onto. 0 before anything has loaded.
|
||||
int loadedSourceRate() const { return loadedSourceRate_.load(std::memory_order_relaxed); }
|
||||
// True while the selected capture is silent because the last reload refused its file's
|
||||
// rate (BuiltCapture::rateRefused). The editor's title band names it
|
||||
// (sourceRateRefusalText).
|
||||
bool sourceRateRefused() const { return sourceRateRefused_.load(std::memory_order_relaxed); }
|
||||
// The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
|
||||
// Empty id -> silence, no first-sample fallback.
|
||||
std::string selectedSampleId();
|
||||
@@ -337,13 +349,20 @@ private:
|
||||
// The reactivation half of the activation/decode lifetime split (see dormantSample_):
|
||||
// folds the live bank blob into the refs, rebuilds the voice state around the parked
|
||||
// sample, publishes it through the same drain-slot swap, and republishes usage. True also
|
||||
// when a publish landed while inactive, which needs no rebuild. False when there is nothing
|
||||
// to activate from, when the park or that publish was resolved for a host rate that has
|
||||
// since changed, or when that fold moved what the park was decoded from — the caller then
|
||||
// falls back to a full reload, which decodes the new file and is also where the pre-v10
|
||||
// legacy lift lives. Off the audio thread only.
|
||||
// when a publish landed while inactive, which needs no rebuild. A park or publish resolved
|
||||
// for a host rate that has since changed is re-stamped in place (restampHostRateLocked)
|
||||
// rather than re-decoded. False when there is nothing to activate from, when the new host
|
||||
// rate refuses the file's, or when that fold moved what the park was decoded from — the
|
||||
// caller then falls back to a full reload, which decodes the new file and is also where the
|
||||
// pre-v10 legacy lift lives. Off the audio thread only.
|
||||
bool resumeDormantInstrument();
|
||||
|
||||
// Re-resolves `sample` for a new host rate without touching its PCM: the render rate, the
|
||||
// stage times (from the model's stored seconds, exactly as a reload resolves them) and the
|
||||
// live seed published beside it. False, leaving `sample` untouched, when the new rate
|
||||
// refuses the file's. Requires reloadMutex_ held.
|
||||
bool restampHostRateLocked(SampleData& sample, int renderRate);
|
||||
|
||||
// Builds a playable snapshot around `sample` at the current voice-system parameters and
|
||||
// host rate, stamped with a fresh generation. Requires reloadMutex_ held; the ONE
|
||||
// construction site shared by the reload, the voice-param rebuild and the reactivation.
|
||||
@@ -425,10 +444,10 @@ private:
|
||||
// ONE BLOCK, ONE RATE: one block sits above every snapshot, so a drain voice reads frame
|
||||
// counts resolved at whatever this last became — harmless while it is the HOST rate for
|
||||
// every capture. The one residue is a build made before the host reported a rate, which
|
||||
// resolves at the capture's own and is replaced by the activation's reload.
|
||||
// resolves at the capture's own and is re-stamped by the activation.
|
||||
std::atomic<int> builtSampleRate_{0};
|
||||
// The loaded capture's own decode rate; see loadedSourceRate().
|
||||
std::atomic<int> loadedSourceRate_{0};
|
||||
// See sourceRateRefused(). Written by reloadInstrument only.
|
||||
std::atomic<bool> sourceRateRefused_{false};
|
||||
|
||||
// --- The audio-thread handoff (drain slot) ---
|
||||
// process() atomically loads live_ + draining_ at block start (two acquires, no lock).
|
||||
@@ -563,7 +582,7 @@ private:
|
||||
std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired
|
||||
|
||||
// Latched from setupProcessing; 0.0 = not reported yet (a build then renders at the
|
||||
// capture's own rate until resumeDormantInstrument's rate check sends it back to a reload).
|
||||
// capture's own rate until resumeDormantInstrument re-stamps it for the reported one).
|
||||
double sampleRate_ = 0.0;
|
||||
Steinberg::int32 maxBlockSize_ = 4096;
|
||||
|
||||
|
||||
+80
-18
@@ -748,6 +748,65 @@ static void testStretchAndShiftComposeSafely() {
|
||||
}
|
||||
}
|
||||
|
||||
// A down-splice's crossfade walks the OUTGOING tap away from the writer at (feedRate - shift)
|
||||
// source frames per output frame, toward the ring's far end — a quarter window past the
|
||||
// trigger. The live fade must end before that walk laps the ring, both at a matched rate (a fast
|
||||
// feed under a down-shift) and at a high source ratio, where the fade is counted in output
|
||||
// frames while the tap moves in source frames. The first case per ratio is also checked for
|
||||
// its nominal length: window/4 source frames' worth of output time.
|
||||
static void testDownSpliceFadeNeverLapsTheRingsFarEnd() {
|
||||
const std::int64_t w = 2205;
|
||||
const double ringLen = 2.0 * static_cast<double>(w);
|
||||
const double dHigh = ringLen - static_cast<double>(w / 4);
|
||||
struct Case { double feed, shift, sourceRatio; std::int64_t nominalFade; };
|
||||
const Case cases[] = {
|
||||
{1.0, 0.5, 1.0, w / 4}, // -12 st, matched: room to spare, the full fade
|
||||
{2.0, 1.0, 1.0, -1}, // Rate 200 %, no shift: recedes one frame per frame
|
||||
{2.0, 0.25, 1.0, -1}, // Rate 200 %, -24 st
|
||||
{4.0, 2.0, 4.0, 138}, // -12 st at a 4x source ratio: round(551 / 4)
|
||||
{4.0, 1.0, 4.0, -1}, // -24 st at a 4x source ratio
|
||||
};
|
||||
std::vector<AudioSample> src(400000);
|
||||
for (std::size_t i = 0; i < src.size(); ++i) {
|
||||
src[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * static_cast<double>(i) / 196.37));
|
||||
}
|
||||
for (const Case& c : cases) {
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
ps.prime(src.data(), w);
|
||||
ps.setShiftRatio(c.shift);
|
||||
ps.setFeedRate(c.feed);
|
||||
ps.setSourceRateRatio(c.sourceRatio);
|
||||
std::size_t pos = static_cast<std::size_t>(w);
|
||||
double debt = 0.0;
|
||||
SpliceEvent first;
|
||||
for (int i = 0; i < 40000 && !first.fired; ++i) {
|
||||
debt += c.feed;
|
||||
const int due = static_cast<int>(debt);
|
||||
debt -= static_cast<double>(due);
|
||||
for (int k = 0; k + 1 < due; ++k) ps.writeFrame(src[pos++]);
|
||||
if (due > 0) ps.process(src[pos++]);
|
||||
else ps.processNoInput();
|
||||
first = ps.lastSplice();
|
||||
}
|
||||
CHECK(first.fired);
|
||||
CHECK(first.jump < 0); // a DOWN-splice: the tap jumped toward the writer
|
||||
const double recede = c.feed - c.shift;
|
||||
// Worst case: the trigger overshot dHigh by one frame's drift, and the integer feed runs
|
||||
// up to one frame ahead of its average. One frame short of the ring is the interpolator's
|
||||
// read-ahead.
|
||||
const double worstDelay =
|
||||
dHigh + recede + recede * static_cast<double>(first.fadeLen) + 1.0;
|
||||
if (!(worstDelay < ringLen)) {
|
||||
std::printf(" feed %.2f shift %.2f ratio %.1f: fade %lld reaches delay %.1f of %.0f\n",
|
||||
c.feed, c.shift, c.sourceRatio, static_cast<long long>(first.fadeLen),
|
||||
worstDelay, ringLen);
|
||||
}
|
||||
CHECK(worstDelay < ringLen);
|
||||
if (c.nominalFade > 0) CHECK(first.fadeLen == c.nominalFade);
|
||||
}
|
||||
}
|
||||
|
||||
// The [0.5, 2.0] rate bound (time_stretch.h) narrows the splice-cadence failure onto the
|
||||
// source fundamental rather than eliminating it. At rate 2.0, shift 0.25 (-24 st) — both
|
||||
// inside the shipped range — the header's own derivation puts the safe-source floor at a
|
||||
@@ -757,18 +816,18 @@ static void testStretchAndShiftComposeSafely() {
|
||||
// render's FUNDAMENTAL within 0.03% of target by autocorrelation and spectral peak alike,
|
||||
// while the zero-crossing estimator read 23% flat — splice debris adds spurious crossings
|
||||
// the count cannot tell from a real detune. Energy outside the fundamental is measured here
|
||||
// (same rate/shift/source, this file's own metric parameters) at 7.31% / 14.41% / 21.22% for
|
||||
// P=500/600/700, against 0.10% on an alignable control (P=200, below the safe floor) at the
|
||||
// (same rate/shift/source, this file's own metric parameters) at 7.08% / 13.90% / 20.92% for
|
||||
// P=500/600/700, against 0.04% on an alignable control (P=200, below the safe floor) at the
|
||||
// same rate and shift. Those readings are stable and are what the bounds below hold.
|
||||
//
|
||||
// **CORRECTED — what those three readings MEAN.** They were once read as the corner's damage.
|
||||
// They are almost entirely the metric's own floor: an ideal tone at the same want-period,
|
||||
// measured identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because
|
||||
// a long period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6%
|
||||
// band. The corner's real EXCESS over that floor is 0.23% / 0.51% / 0.30% — small, real, and
|
||||
// nothing like the headline numbers. The alignable control's 0.10% is genuinely near-zero only
|
||||
// because its want-period is short enough to have almost no floor. The bounds below are kept
|
||||
// as a stable regression tripwire on the raw readings; read the excess, not the reading.
|
||||
// They are the metric's own floor: an ideal tone at the same want-period, measured
|
||||
// identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because a long
|
||||
// period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6% band.
|
||||
// The corner's EXCESS over that floor once measured 0.23% / 0.51% / 0.30% (control 0.10%); that
|
||||
// residue was the down-shift crossfade (recede 1.75 frames/frame) lapping the ring's far end,
|
||||
// and splice()'s far-end fade cap took it to 0.00%. The bounds below are kept as a stable
|
||||
// regression tripwire on the raw readings; read the excess, not the reading.
|
||||
//
|
||||
// This measures the FIXED-WINDOW path — no source period is set, which is what a capture with
|
||||
// no single period (percussive, polyphonic, noise) gets. What the same corner does once the
|
||||
@@ -782,8 +841,8 @@ static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() {
|
||||
const std::size_t from = 20000, len = 32768;
|
||||
|
||||
// Below the safe floor (P > 315 frames): per the header's CORRECTED note, this raw reading
|
||||
// is mostly the metric's own mainlobe-leakage floor, not cadence damage (the real excess is
|
||||
// ~0.2-0.5%, asserted in testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
|
||||
// is the metric's own mainlobe-leakage floor, not cadence damage (the excess is asserted in
|
||||
// testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
|
||||
// below are a RAW-READING STABILITY TRIPWIRE, not a discriminator — they catch a large swing
|
||||
// in the raw measurement; the ceiling (30%) is a generous margin above the highest measured
|
||||
// value, there to catch a much worse regression rather than to chase today's exact number.
|
||||
@@ -1001,13 +1060,15 @@ static void testTwentyNineHertzAtRateTwoKeepsItsPitch() {
|
||||
|
||||
// The cadence corner (rate 2.0, -24 st, source periods above the 315-frame safe floor) is the
|
||||
// OTHER mechanism — a splice landing inside a single perceived cycle. Measured against the
|
||||
// metric's own floor, PSOLA moves it by nothing: 0.23/0.51/0.30% excess becomes 0.24/0.49/0.30%.
|
||||
// metric's own floor, PSOLA moves it by nothing: 0.00% excess either way (it was 0.23/0.51/0.30%
|
||||
// both ways until the far-end fade cap removed the lapped crossfade behind it).
|
||||
//
|
||||
// That is not a shortfall, it is what the corner turned out to be. Correcting the previous
|
||||
// test's reading (see its comment) shrank the corner from a 7-21% headline to a sub-1% excess,
|
||||
// which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each splice
|
||||
// land in phase, and these splices already did; what it cannot do is make them less frequent.
|
||||
// So this asserts NO REGRESSION, not an improvement, and says so rather than claiming one.
|
||||
// test's reading (see its comment) shrank the corner from a 7-21% headline to no measurable
|
||||
// excess, which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each
|
||||
// splice land in phase, and these splices already did; what it cannot do is make them less
|
||||
// frequent. So this asserts NO REGRESSION, not an improvement, and says so rather than
|
||||
// claiming one.
|
||||
static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
|
||||
using reasampler::test_support::energyOutsideFundamentalPercent;
|
||||
const std::int64_t w = 2205;
|
||||
@@ -1045,7 +1106,7 @@ static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
|
||||
//
|
||||
// MEASURED (Debug, this machine): metric floor 55.86% (P=1470's want-period of 5880 fr under a
|
||||
// 32768-frame segment leaks a lot of mainlobe, same effect as the P=500-700 rows, just larger),
|
||||
// fixed-window excess 18.52%, pitch-synchronous excess 0.00%. The faster cadence does NOT
|
||||
// fixed-window excess 18.61%, pitch-synchronous excess 0.00%. The faster cadence does NOT
|
||||
// regress this corner — every splice at n=1 lands exactly one source period away, so despite
|
||||
// firing 1.5x as often each one is phase-perfect rather than merely aligned-on-average, and the
|
||||
// corner clears rather than worsens. Recorded as read, not tuned: if a future change moves
|
||||
@@ -1073,7 +1134,7 @@ static void testCadenceCollapseBandAtThirtyHertzUnderPitchSynchronousSplice() {
|
||||
"excess %.2f%% -> %.2f%%\n", period, want, floor, pctOff - floor, pctOn - floor);
|
||||
CHECK(pctOn - floor < 1.0); // measured 0.00%: phase-perfect at n=1, not merely aligned
|
||||
// Vacuity guard (shape of testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown's :961): the
|
||||
// FIXED-WINDOW arm is asserted too (measured 18.52% excess), so a setSourcePeriod that
|
||||
// FIXED-WINDOW arm is asserted too (measured 18.61% excess), so a setSourcePeriod that
|
||||
// silently did nothing would render both arms identically and pass the bound above by luck.
|
||||
CHECK(pctOff - floor > pctOn - floor + 1.0);
|
||||
}
|
||||
@@ -1099,6 +1160,7 @@ int main() {
|
||||
testStereoLinkedLagSharedSchedule();
|
||||
testStretchMovesDurationNotPitch();
|
||||
testStretchAndShiftComposeSafely();
|
||||
testDownSpliceFadeNeverLapsTheRingsFarEnd();
|
||||
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
|
||||
testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound();
|
||||
testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry();
|
||||
|
||||
@@ -270,7 +270,7 @@ static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int6
|
||||
ps.setFeedRate(rate);
|
||||
ps.setSourcePeriod(sourcePeriod);
|
||||
StretchCursor cur;
|
||||
cur.start(window);
|
||||
cur.start(window, 1.0);
|
||||
loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through
|
||||
|
||||
SpliceStats st;
|
||||
|
||||
@@ -953,6 +953,13 @@ static void testBuildSampleDataRefusesARateNoCaptureCouldHave() {
|
||||
CHECK(!corrupt.playable());
|
||||
// Far BELOW the host costs nothing per frame, so it plays.
|
||||
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, 1000, {}}, host).playable());
|
||||
// The shell tells the user WHY through the predicate the build applies, so the message and
|
||||
// the silence share one boundary.
|
||||
CHECK(sourceRateSupported(atCeiling, host));
|
||||
CHECK(!sourceRateSupported(atCeiling + 1, host));
|
||||
// No host rate reported yet: the file plays at its own rate, whatever that is.
|
||||
CHECK(sourceRateSupported(atCeiling + 1, 0));
|
||||
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling + 1, {}}, 0).playable());
|
||||
}
|
||||
|
||||
static void testBuildSampleDataCarriesTheSecondChannel() {
|
||||
|
||||
+203
-25
@@ -4,16 +4,20 @@
|
||||
// directions and at a large ratio, and asserts in wall-clock / Hz terms — output frames at the
|
||||
// host rate — never in source frames.
|
||||
|
||||
#include "../src/core/instrument/engine/voice.h"
|
||||
#include "../src/core/instrument/engine/voice_engine.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "tone_metrics.h"
|
||||
|
||||
using namespace reasampler;
|
||||
using instrument::engine::LiveParams;
|
||||
using instrument::engine::LiveValues;
|
||||
using instrument::engine::foldLive;
|
||||
namespace flt = reasampler::instrument::engine::filter;
|
||||
using reasampler::test_support::autocorrelationPeriod;
|
||||
|
||||
@@ -59,23 +63,24 @@ SampleData dcSample(const RateCase& c, std::size_t frames) {
|
||||
return s;
|
||||
}
|
||||
|
||||
void startVoice(Voice& v, const SampleData& s, int note) {
|
||||
// `stretch` is the Rate a VoiceEngine would hand start() from the published block.
|
||||
void startVoice(Voice& v, const SampleData& s, int note, double stretch = 1.0) {
|
||||
if (s.play.pitchEngine == PitchEngine::Preserve) v.presizePreserveShifters(preserveWindowFrames(s));
|
||||
v.start(note, 100, s);
|
||||
v.start(note, 100, s, /*declickTakeover=*/false, stretch);
|
||||
}
|
||||
|
||||
std::vector<double> render(const SampleData& s, int note, int frames) {
|
||||
std::vector<double> render(const SampleData& s, int note, int frames, double stretch = 1.0) {
|
||||
Voice v;
|
||||
startVoice(v, s, note);
|
||||
startVoice(v, s, note, stretch);
|
||||
std::vector<double> out(static_cast<std::size_t>(frames), 0.0);
|
||||
for (auto& x : out) x = v.renderFrame();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Output frames the voice renders before it goes idle, capped.
|
||||
std::int64_t activeFrames(const SampleData& s, int note, std::int64_t cap) {
|
||||
std::int64_t activeFrames(const SampleData& s, int note, std::int64_t cap, double stretch = 1.0) {
|
||||
Voice v;
|
||||
startVoice(v, s, note);
|
||||
startVoice(v, s, note, stretch);
|
||||
std::int64_t n = 0;
|
||||
while (v.active() && n < cap) {
|
||||
v.renderFrame();
|
||||
@@ -159,6 +164,39 @@ static void testTriggerPlayPercentageIsAFractionOfTheWallClockLength() {
|
||||
}
|
||||
}
|
||||
|
||||
// A down-shift's splice crossfade walks the outgoing tap AWAY from the writer, toward the ring's
|
||||
// far end. The fade is counted in output frames while the tap moves in source frames, so at a
|
||||
// high ratio a fade sized in source frames outlasts the ring and reads audio the writer has
|
||||
// already replaced — splice debris a matched-rate render of the same note does not have.
|
||||
static void testPreserveDownShiftAtAHighRatioSplicesAsCleanlyAsAMatchedRate() {
|
||||
using reasampler::test_support::energyOutsideFundamentalPercent;
|
||||
constexpr double kHz = 441.0;
|
||||
constexpr std::size_t kFrom = 6000, kLen = 16384;
|
||||
for (const RateCase& c : {RateCase{192000, 48000}, RateCase{96000, 44100}}) {
|
||||
for (int note : {48, 36}) {
|
||||
const double wantPeriod =
|
||||
static_cast<double>(c.host) / (kHz * std::pow(2.0, (note - 60) / 12.0));
|
||||
const auto debris = [&](const RateCase& rc) {
|
||||
SampleData s = sineSample(rc, kHz, 1.0);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
return energyOutsideFundamentalPercent(render(s, note, 24000), kFrom, kLen,
|
||||
wantPeriod);
|
||||
};
|
||||
const double got = debris(c);
|
||||
const double ref = debris(RateCase{c.host, c.host});
|
||||
// Measured with a source-frame fade and no far-end cap: 0.398 / 2.631 / 0.227 %
|
||||
// against 0.001 / 0.031 / 0.012 % matched (the 96k -12 st case recedes too little to
|
||||
// show). After, every case reads its matched reference to the third decimal.
|
||||
if (!(got < ref + 0.05)) {
|
||||
std::printf(" debris %d/%d note %d: %.3f%% vs matched %.3f%%\n", c.file,
|
||||
c.host, note, got, ref);
|
||||
}
|
||||
CHECK(got < ref + 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Envelope stage times are wall-clock: counted in OUTPUT frames at the host rate.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -194,6 +232,112 @@ static void testGateAdsrAttackTakesItsWallClockTime() {
|
||||
}
|
||||
}
|
||||
|
||||
// Rate and the Pitch offset are the two factors stageFitRate compensates, and the source/host
|
||||
// ratio rides inside the same product. All three at once, under both engines: the Trigger AHD's
|
||||
// attack still takes 20 ms, the note lasts the source's wall-clock length over the read's own
|
||||
// rate, and the pitch is the offset's (Preserve) or the offset's times Rate's (Varispeed).
|
||||
static void testRateAndPitchOffsetAtAMismatchedRateKeepWallClockAndPitch() {
|
||||
const RateCase c{96000, 44100};
|
||||
constexpr double kStretch = 1.5, kOffset = -5.0, kHz = 441.0;
|
||||
const double offsetRatio = std::pow(2.0, kOffset / 12.0);
|
||||
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
const bool vari = engine == PitchEngine::Varispeed;
|
||||
const auto dial = [&](SampleData& s) {
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = engine;
|
||||
s.play.pitchOffsetSemitones = kOffset;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
};
|
||||
|
||||
SampleData dc = dcSample(c, static_cast<std::size_t>(c.file));
|
||||
dial(dc);
|
||||
const std::int64_t attack = c.host / 50; // 20 ms, resolved at the host rate
|
||||
dc.play.trigAhd.attackFrames = attack;
|
||||
const std::vector<double> ramp = render(dc, 60, static_cast<int>(attack * 2), kStretch);
|
||||
CHECK(std::fabs(ramp[static_cast<std::size_t>(attack / 2)] - 0.5) < 0.01);
|
||||
CHECK(ramp[static_cast<std::size_t>(attack + 4)] > 0.99);
|
||||
|
||||
SampleData tone = sineSample(c, kHz, 1.0);
|
||||
dial(tone);
|
||||
const double wantPeriod =
|
||||
static_cast<double>(c.host) / (kHz * offsetRatio * (vari ? kStretch : 1.0));
|
||||
const std::vector<double> out = render(tone, 60, 12000, kStretch);
|
||||
CHECK(within(measuredPeriod(out, 2000, 6000, wantPeriod), wantPeriod, 0.005));
|
||||
|
||||
const double readRate = kStretch * (vari ? offsetRatio : 1.0);
|
||||
const double span = static_cast<double>(tone.frames.size()) / ratioOf(c) / readRate;
|
||||
const auto got = static_cast<double>(activeFrames(tone, 60, 400000, kStretch));
|
||||
if (vari) {
|
||||
CHECK(std::fabs(got - (span + 1.0)) <= 1.5); // + the frame that detects the end
|
||||
} else {
|
||||
CHECK(got >= span - 1.0);
|
||||
CHECK(got <= span + static_cast<double>(kDeclickFrames) + 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A live block reaches a fresh voice through snapLive and a sounding one through applyLive, and
|
||||
// both convert the new stage frames through stageFitRate — so both must land in wall-clock time
|
||||
// at a mismatched rate too.
|
||||
static void testLiveStageTimesLandInWallClockAtAMismatchedRate() {
|
||||
const RateCase c{96000, 44100};
|
||||
constexpr std::size_t kBlock = 441; // 10 ms at the host rate
|
||||
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
const auto oneSecondAttack = [&]() {
|
||||
SampleData s = dcSample(c, static_cast<std::size_t>(c.file) * 2);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = engine;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
s.play.trigAhd.attackFrames = c.host;
|
||||
return s;
|
||||
};
|
||||
|
||||
// Fresh: the snapshot says 1 s, a block published before the note says 20 ms.
|
||||
{
|
||||
SampleData s = oneSecondAttack();
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
LiveValues v = foldLive(s.play, s.keyTrack);
|
||||
const std::int64_t attack = c.host / 50;
|
||||
v.ampAhd.attackFrames = attack;
|
||||
block.publish(v);
|
||||
VoiceEngine eng(1, s, 0, preserveWindowFrames(s));
|
||||
eng.noteOn(60, 100);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, static_cast<std::size_t>(attack * 2));
|
||||
CHECK(std::fabs(out[static_cast<std::size_t>(attack / 2)] - 0.5) < 0.01);
|
||||
CHECK(out[static_cast<std::size_t>(attack + 4)] > 0.99);
|
||||
}
|
||||
|
||||
// Sounding: the 1 s attack redialled to 200 ms, 100 ms in. The AHD is positional, so the
|
||||
// level re-lands on the new shape at the current read position (the step smoothed out
|
||||
// within ~5 ms): three quarters up at 150 ms, full by 200 ms.
|
||||
{
|
||||
SampleData s = oneSecondAttack();
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
block.publish(foldLive(s.play, s.keyTrack));
|
||||
VoiceEngine eng(1, s, 0, preserveWindowFrames(s));
|
||||
eng.noteOn(60, 100);
|
||||
std::vector<AudioSample> out;
|
||||
for (int b = 0; b < 10; ++b) eng.render(out, kBlock);
|
||||
LiveValues v = foldLive(s.play, s.keyTrack);
|
||||
v.ampAhd.attackFrames = c.host / 5;
|
||||
block.publish(v);
|
||||
for (int b = 0; b < 12; ++b) eng.render(out, kBlock);
|
||||
const auto at = [&](double seconds) {
|
||||
return static_cast<double>(
|
||||
out[static_cast<std::size_t>(seconds * static_cast<double>(c.host))]);
|
||||
};
|
||||
CHECK(std::fabs(at(0.05) - 0.05) < 0.01); // the 1 s attack, before the redial
|
||||
CHECK(std::fabs(at(0.15) - 0.75) < 0.01);
|
||||
CHECK(at(0.205) > 0.99);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pitch AHD's Hold fraction is taken against the playable span in OUTPUT frames. Under
|
||||
// Preserve the read runs at the source rate exactly, so a half-span hold must end half way
|
||||
// through the sample's wall-clock length — not half way through its SOURCE frame count.
|
||||
@@ -353,26 +497,57 @@ static std::vector<float> renderStereoNote(const SampleData& s, int note) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// renderRate 0 is how every other core test builds its SampleData, so those suites' exact-value
|
||||
// assertions pin that path; the loader always states the host rate. The two must agree bit for
|
||||
// bit at a matched rate, or a same-rate capture would sound different in the plugin than in the
|
||||
// suites that vouch for it.
|
||||
static void testStatingAMatchedHostRateChangesNoBit() {
|
||||
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
for (int note : {57, 64, 45}) {
|
||||
const SampleData unstated = richSample(engine, mode); // renderRate 0
|
||||
// FNV-1a 64 over the float bit patterns, in render order.
|
||||
static std::uint64_t bitHash(const std::vector<float>& v) {
|
||||
std::uint64_t h = 14695981039346656037ull;
|
||||
for (float x : v) {
|
||||
std::uint32_t bits;
|
||||
std::memcpy(&bits, &x, sizeof bits);
|
||||
for (int b = 0; b < 4; ++b) {
|
||||
h ^= (bits >> (8 * b)) & 0xFFu;
|
||||
h *= 1099511628211ull;
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// These exact renders on the engine BEFORE the source/host factor existed (9ff34b0), hashed by
|
||||
// building richSample + renderStereoNote against that tree — MSVC x64, identical in Debug and
|
||||
// Release. A matched rate must reproduce them bit for bit whether the host rate is stated (as
|
||||
// the loader always builds) or left 0 (as every other core suite does). The inputs come from
|
||||
// libm sin/pow, so another toolchain's libm can move them with no engine change; the pin is
|
||||
// therefore MSVC-only, and stated == unstated is checked everywhere.
|
||||
static void testAMatchedRateRendersBitIdenticalToThePreFactorEngine() {
|
||||
struct Golden { PitchEngine engine; PlayMode mode; int note; std::uint64_t hash; };
|
||||
const Golden golden[] = {
|
||||
{PitchEngine::Varispeed, PlayMode::Gate, 57, 0x3e684c7e820ad514ull},
|
||||
{PitchEngine::Varispeed, PlayMode::Gate, 64, 0xcdd495f5475d63adull},
|
||||
{PitchEngine::Varispeed, PlayMode::Gate, 45, 0x9dda14cb03b2b989ull},
|
||||
{PitchEngine::Varispeed, PlayMode::Trigger, 57, 0x270baad4faaba382ull},
|
||||
{PitchEngine::Varispeed, PlayMode::Trigger, 64, 0xa636d603e07b39daull},
|
||||
{PitchEngine::Varispeed, PlayMode::Trigger, 45, 0xbc1444e4548cf811ull},
|
||||
{PitchEngine::Preserve, PlayMode::Gate, 57, 0x8b56731872c91234ull},
|
||||
{PitchEngine::Preserve, PlayMode::Gate, 64, 0x51b52f2f814d684bull},
|
||||
{PitchEngine::Preserve, PlayMode::Gate, 45, 0x8b62423be1525216ull},
|
||||
{PitchEngine::Preserve, PlayMode::Trigger, 57, 0xf0d55633547c325full},
|
||||
{PitchEngine::Preserve, PlayMode::Trigger, 64, 0x73ddee69c67183c3ull},
|
||||
{PitchEngine::Preserve, PlayMode::Trigger, 45, 0xaa9f43fa7c64c2dfull},
|
||||
};
|
||||
for (const Golden& g : golden) {
|
||||
const SampleData unstated = richSample(g.engine, g.mode); // renderRate 0
|
||||
SampleData stated = unstated;
|
||||
stated.renderRate = stated.sampleRate;
|
||||
const std::vector<float> a = renderStereoNote(unstated, note);
|
||||
const std::vector<float> b = renderStereoNote(stated, note);
|
||||
bool identical = a.size() == b.size();
|
||||
for (std::size_t i = 0; identical && i < a.size(); ++i) {
|
||||
if (a[i] != b[i]) identical = false;
|
||||
}
|
||||
CHECK(identical);
|
||||
}
|
||||
const std::uint64_t a = bitHash(renderStereoNote(unstated, g.note));
|
||||
const std::uint64_t b = bitHash(renderStereoNote(stated, g.note));
|
||||
CHECK(a == b);
|
||||
#if defined(_MSC_VER) && defined(_M_X64)
|
||||
if (b != g.hash) {
|
||||
std::printf(" pre-factor mismatch: engine %d mode %d note %d: 0x%016llx\n",
|
||||
static_cast<int>(g.engine), static_cast<int>(g.mode), g.note,
|
||||
static_cast<unsigned long long>(b));
|
||||
}
|
||||
CHECK(b == g.hash);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,13 +555,16 @@ int main() {
|
||||
testVarispeedRootNotePlaysAtItsOwnPitchAndDuration();
|
||||
testPreserveRootAndOctaveHoldPitchAndWallClockDuration();
|
||||
testTriggerPlayPercentageIsAFractionOfTheWallClockLength();
|
||||
testPreserveDownShiftAtAHighRatioSplicesAsCleanlyAsAMatchedRate();
|
||||
testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines();
|
||||
testGateAdsrAttackTakesItsWallClockTime();
|
||||
testRateAndPitchOffsetAtAMismatchedRateKeepWallClockAndPitch();
|
||||
testLiveStageTimesLandInWallClockAtAMismatchedRate();
|
||||
testPitchEnvelopeSpanIsTheSourcesWallClockLength();
|
||||
testSingleCycleLoopSustainsAtThePitchOfItsCycle();
|
||||
testLoopCrossfadeTakesItsWallClockTime();
|
||||
testFilterCornerLandsAtItsHertzAtAMismatchedRate();
|
||||
testStatingAMatchedHostRateChangesNoBit();
|
||||
testAMatchedRateRendersBitIdenticalToThePreFactorEngine();
|
||||
if (g_fail == 0) std::printf("source_rate: all tests passed\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
+15
-11
@@ -6,8 +6,8 @@
|
||||
// the mechanism behind the "unity is bit-identical to the shipped Preserve read" gate.
|
||||
// 2. the schedule tracks the rate: over N output frames the cursor consumes N*rate source
|
||||
// frames to within one, at rates either side of unity and at irrational ones.
|
||||
// 3. the per-output-frame feed count never exceeds kMaxFeedPerFrame — the bound that makes
|
||||
// a variable-length feed loop RT-safe.
|
||||
// 3. the per-output-frame feed count never exceeds ceil(kStretchRateMax) at a matched rate —
|
||||
// the bound that makes a variable-length feed loop RT-safe.
|
||||
// 4. the clamp: out-of-range folds to the bounds, unusable input folds to unity (never to a
|
||||
// silent stall or a quarter-speed surprise).
|
||||
// 4b. the source/host rate ratio multiplies the CLAMPED rate, unclipped by the clamp itself.
|
||||
@@ -29,6 +29,10 @@ static int g_fail = 0;
|
||||
|
||||
static ResolvedLoop noLoop() { return ResolvedLoop{}; }
|
||||
|
||||
// The most source frames one output frame can make due at a matched rate.
|
||||
static const std::int64_t kMatchedFeedBound =
|
||||
static_cast<std::int64_t>(std::ceil(kStretchRateMax));
|
||||
|
||||
static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) {
|
||||
ResolvedLoop lp;
|
||||
lp.active = true;
|
||||
@@ -41,7 +45,7 @@ static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) {
|
||||
// --- 1. Unity is exactly one frame per output frame, with no drifting residue. ---
|
||||
static void testUnityRateFeedsExactlyOneFramePerOutputFrame() {
|
||||
StretchCursor c;
|
||||
c.start(100);
|
||||
c.start(100, 1.0);
|
||||
const ResolvedLoop lp = noLoop();
|
||||
for (std::int64_t i = 0; i < 200000; ++i) {
|
||||
CHECK(c.due(1.0) == 1);
|
||||
@@ -60,7 +64,7 @@ static void testTotalConsumedTracksTheRate() {
|
||||
// would drift without bound rather than carrying the residue.
|
||||
for (double rate : {0.5, 0.75, 1.0, 1.3333333333333333, 2.0, 1.0 / 3.0 + 1.0}) {
|
||||
StretchCursor c;
|
||||
c.start(0);
|
||||
c.start(0, 1.0);
|
||||
const std::int64_t outFrames = 100000;
|
||||
for (std::int64_t i = 0; i < outFrames; ++i) {
|
||||
const std::int64_t due = c.due(rate);
|
||||
@@ -78,14 +82,14 @@ static void testFeedPerOutputFrameIsBounded() {
|
||||
// for as many iterations as the rate names.
|
||||
for (double rate : {kStretchRateMax, kStretchRateMax * 100.0, 3.99, 2.5}) {
|
||||
StretchCursor c;
|
||||
c.start(0);
|
||||
c.start(0, 1.0);
|
||||
std::int64_t worst = 0;
|
||||
for (std::int64_t i = 0; i < 20000; ++i) {
|
||||
const std::int64_t due = c.due(rate);
|
||||
if (due > worst) worst = due;
|
||||
for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp);
|
||||
}
|
||||
CHECK(worst <= kMaxFeedPerFrame);
|
||||
CHECK(worst <= kMatchedFeedBound);
|
||||
CHECK(worst >= 1); // and the bound is not vacuous — frames genuinely fell due
|
||||
}
|
||||
}
|
||||
@@ -103,11 +107,11 @@ static void testRateClamp() {
|
||||
CHECK(clampStretchRate(std::nan("")) == 1.0);
|
||||
// ...and the cursor honours it rather than looping on the raw value.
|
||||
StretchCursor c;
|
||||
c.start(0);
|
||||
c.start(0, 1.0);
|
||||
CHECK(c.due(-5.0) == 1); // folded to unity
|
||||
StretchCursor d;
|
||||
d.start(0);
|
||||
CHECK(d.due(50.0) <= kMaxFeedPerFrame);
|
||||
d.start(0, 1.0);
|
||||
CHECK(d.due(50.0) <= kMatchedFeedBound);
|
||||
}
|
||||
|
||||
// --- 4b. The source/host rate ratio scales the feed AFTER the musical clamp. ---
|
||||
@@ -143,7 +147,7 @@ static void testCursorStaysInsideTheLoopSpan() {
|
||||
const ResolvedLoop lp = loopSpan(1000, 1040); // a 40-frame loop: rate 4 steps 10% of it
|
||||
for (double rate : {0.5, 1.0, 2.0, 4.0}) {
|
||||
StretchCursor c;
|
||||
c.start(1000);
|
||||
c.start(1000, 1.0);
|
||||
std::int64_t lowest = 1 << 30, highest = -1;
|
||||
for (std::int64_t i = 0; i < 50000; ++i) {
|
||||
const std::int64_t due = c.due(rate);
|
||||
@@ -163,7 +167,7 @@ static void testCursorStaysInsideTheLoopSpan() {
|
||||
// A cursor started BEYOND the loop end (the start-point-past-the-loop case) is pulled in on
|
||||
// its first take rather than reading off the end.
|
||||
StretchCursor c;
|
||||
c.start(5000);
|
||||
c.start(5000, 1.0);
|
||||
const std::int64_t q = c.next(lp);
|
||||
CHECK(q >= lp.start && q < lp.end);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user