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

The splice crossfade now counts output frames and stops before the ring's far end (this also fixes a lap at matched rate). The search strides by the rate ratio. The editor takes its time base from its own decode. Same-rate output is pinned to hashes from before the change.
This commit is contained in:
2026-09-25 14:17:11 -04:00
parent ec60cf78c6
commit 64f314a6a4
24 changed files with 592 additions and 183 deletions
+12 -3
View File
@@ -178,15 +178,24 @@ voice reads SOURCE frames and emits OUTPUT frames, and `SampleData::sourceRateRa
the shifter's tap at note-on. the shifter's tap at note-on.
- **Source frames:** the PCM, the loop and its crossfade, the start point, the Trigger span, the - **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 - **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 `SampleData::renderRate`), the filter solve, the live ramps, the declick, and the shifter's
envelope converts through `stageFitRate`, which carries the ratio. 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 - **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 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 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 chosen shape is that a mismatched capture's ROOT note is interpolated like any other repitched
note already is. 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) ### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30)
+75 -30
View File
@@ -60,6 +60,8 @@ void PitchShifter::configure(std::int64_t windowFrames) {
filled_ = 0; filled_ = 0;
ratio_ = 1.0; ratio_ = 1.0;
feedRate_ = 1.0; feedRate_ = 1.0;
sourceRatio_ = 1.0;
stride_ = 1;
tailFrozen_ = false; tailFrozen_ = false;
lastSplice_ = SpliceEvent{}; lastSplice_ = SpliceEvent{};
return; 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. // 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
ringLen_ = 2 * window_; ringLen_ = 2 * window_;
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f); ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
// Geometry (all quarters of the window): // Geometry (all quarters of the window, in SOURCE frames):
// - 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.
// - maxLag_: alignment search half-range — one window/4 covers a full period of any tone // - 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). // 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). // - 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 // fadeFrames_ and corrFrames_ also scale with the source ratio — applySourceRatio.
// 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);
maxLag_ = window_ / 4; maxLag_ = window_ / 4;
dLow_ = window_ / 4; dLow_ = window_ / 4;
dHigh_ = ringLen_ - 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 // 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. // 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); jumpMax_ = std::max<std::int64_t>(1, dHigh_ - dLow_ - maxLag_ - 1);
fadeLen_ = 0; 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() { void PitchShifter::reset() {
@@ -111,6 +130,8 @@ void PitchShifter::reset() {
filled_ = 0; filled_ = 0;
ratio_ = 1.0; ratio_ = 1.0;
feedRate_ = 1.0; feedRate_ = 1.0;
sourceRatio_ = 1.0;
applySourceRatio();
period_ = 0.0; period_ = 0.0;
jump_ = window_ > 1 ? window_ : 0; jump_ = window_ > 1 ? window_ : 0;
tailFrozen_ = false; tailFrozen_ = false;
@@ -195,6 +216,12 @@ void PitchShifter::setFeedRate(double rate) {
if (rate > 0.0) feedRate_ = 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 { double PitchShifter::readTap(double pos) const {
// Fractional linear interpolation with ring wrap. // Fractional linear interpolation with ring wrap.
double p = pos; 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 // 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 // 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 // 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 // (step 4*stride_ over +/- maxLag_) then fine (+/- 3 strides around the coarse best, +/-
// sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once // stride_-1 single frames around that, then a parabolic sub-sample peak), each score
// per splice. // 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); 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 // 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 ia = iA;
std::int64_t ic = ((iA - jump + lag) % ringLen_ + ringLen_) % ringLen_; std::int64_t ic = ((iA - jump + lag) % ringLen_ + ringLen_) % ringLen_;
double s = 0.0, ec = 0.0; 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 a = static_cast<double>(ring_[static_cast<std::size_t>(ia)]);
const double c = static_cast<double>(ring_[static_cast<std::size_t>(ic)]); const double c = static_cast<double>(ring_[static_cast<std::size_t>(ic)]);
s += a * c; s += a * c;
ec += c * c; ec += c * c;
if (++ia >= ringLen_) ia = 0; if ((ia += stride_) >= ringLen_) ia -= ringLen_;
if (++ic >= ringLen_) ic = 0; if ((ic += stride_) >= ringLen_) ic -= ringLen_;
} }
// Normalized cross-correlation: a raw dot product biases toward the higher-energy lag, // 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 // 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; std::int64_t bestLag = 0;
double bestScore = -std::numeric_limits<double>::infinity(); 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); const double s = scoreAt(lag);
if (s > bestScore) { if (s > bestScore) {
bestScore = s; bestScore = s;
@@ -272,7 +300,7 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
} }
} }
const std::int64_t coarse = bestLag; 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; if (lag == coarse || lag < -maxLag_ || lag > maxLag_) continue;
const double s = scoreAt(lag); const double s = scoreAt(lag);
if (s > bestScore) { if (s > bestScore) {
@@ -280,6 +308,16 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
bestLag = lag; 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 // 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 // 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 < 0.0) p += len;
while (p >= len) p -= len; while (p >= len) p -= len;
posA_ = p; posA_ = p;
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the // Drift-scaled fade length. The outgoing tap's delay behind the writer moves by
// writer at (ratio - feedRate) per frame; the nominal window/4 fade only keeps it behind // (feedRate - ratio) source frames per output frame, and the fade lasts fadeFrames_ OUTPUT
// the writer while that rate stays under ~1 — beyond that (e.g. +24 st = ratio 4, or a // frames, so it moves the tap drift x fadeFrames_ — a quarter window at |drift| == 1 at a
// half-speed feed under any up-shift) it would cross mid-fade and play stale read-ahead // matched rate. Past that it runs out of ring in whichever direction it drifts:
// data. Cap the live fade at the drain headroom actually available, minus 2 (trigger // - drain (ratio > feedRate; up-shifts, a slow feed): toward the writer, where it would
// undershoot + interpolator read-ahead margin). A drain rate at or below zero (down-shifts, // read stale read-ahead data (e.g. +24 st = ratio 4, or a half-speed feed under any
// and up-shifts the feed outruns) can't reach the ring end within window/4 frames, so those // up-shift);
// always keep the full fade. // - 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 // 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 // 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). // at every ratio (including unity, since delay now drains at unity too).
fadeLen_ = fadeFrames_; fadeLen_ = fadeFrames_;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - feedRate_); const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - feedRate_);
if (drainRate > 0.0) { if (drainRate != 0.0) {
const double headroom = static_cast<double>(dLow_) - drainRate - 2.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 // 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 const double safeDbl = headroom > 0.0
? std::min(headroom / drainRate, static_cast<double>(fadeFrames_)) ? std::min(headroom / rate, static_cast<double>(fadeFrames_))
: 1.0; : 1.0;
fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl)); fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl));
} }
+28 -10
View File
@@ -118,6 +118,15 @@ public:
// Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit. // Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
void setFeedRate(double rate); 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 // 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 // number of it — <= 0 means "unknown" (see periodAlignedJump for the exact fallback); the
// default, so a caller that never calls this sees no change. // 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 // `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). // input and hold the write head (the same shape tail-freezing already takes).
AudioSample processImpl(AudioSample in, const SpliceEvent* linked, bool write); 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::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through 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 double posB_ = 0.0; // outgoing tap during a splice crossfade
bool fading_ = false; // a splice crossfade is in flight bool fading_ = false; // a splice crossfade is in flight
std::int64_t fadePos_ = 0; // crossfade progress, [0, fadeLen_) 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 — std::int64_t fadeLen_ = 0; // LIVE crossfade length for the in-flight splice —
// ratio-scaled at splice time so an up-shift's outgoing // drift-scaled at splice time so the outgoing tap can
// tap can never drain into the writer mid-fade // never reach the writer, or lap the ring's far end,
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4) // 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) 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 jump_ = 0; // nominal splice jump; window_ unless period_ retunes it
std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY 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 // period-sized jump can never land back on a trigger and
// thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_). At // thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_). At
// jump_==jumpMax_ a DOWN-splice's correlation read comes // jump_==jumpMax_ a DOWN-splice's correlation read comes
// within ~41 frames of the write head (measured: the // within dLow_+2-corrFrames_ frames of the write head at
// exact ring/lag/corrFrames_ geometry at the product // the worst lag the search reaches (41 at a 44.1k 50 ms
// window, worst case over every lag the search reaches) — // window; never under 3, since corrFrames_ <= dLow_-1) —
// real margin, not zero, but tight enough that widening // real margin, not zero, but tight enough that widening
// maxLag_, corrFrames_ or jumpMax_ without re-deriving // maxLag_, corrFrames_ or jumpMax_ without re-deriving
// this bound risks reading unwritten ring content. // this bound risks reading unwritten ring content.
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512
// the reference read forward from the tap stays behind // SAMPLED frames — 512*stride_ — so the reference read
// the writer by construction at an up-splice) // 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 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 dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
std::int64_t filled_ = 0; // frames of valid history behind the writer; splice() std::int64_t filled_ = 0; // frames of valid history behind the writer; splice()
+19 -7
View File
@@ -104,8 +104,8 @@ enum class PitchEngine { Varispeed, Preserve };
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// OLA window for the Preserve PitchShifter, in ms of SOURCE (preserveWindowFrames); larger = // 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 // smoother on big transpositions. Onset latency is zero — start() primes the ring with the first
// of real source, so output frame 0 is source frame 0 regardless of window size. // window of real source, so output frame 0 is source frame 0 regardless of window size.
inline constexpr double kPreserveWindowMs = 50.0; inline constexpr double kPreserveWindowMs = 50.0;
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 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 // The largest sourceRateRatio a load accepts. It is the one per-frame cost the ratio scales: a
// stretch rate in source frames per output frame, so the ratio bounds per-frame work on the // Preserve voice writes up to ceil(kStretchRateMax x ratio) source frames into its ring per
// audio thread; a header claiming more than this is treated as corrupt (buildSampleData). // 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; 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 // 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, // 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 // search reach and alignable period in time_stretch.h fixed in Hz at any host rate; the
// so a valid window is always a real ring. // 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) { inline std::int64_t preserveWindowFrames(const SampleData& s) {
const std::int64_t w = static_cast<std::int64_t>( const std::int64_t w = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(s.sampleRate) / 1000.0 + 0.5); kPreserveWindowMs * static_cast<double>(s.sampleRate) / 1000.0 + 0.5);
+16 -13
View File
@@ -11,8 +11,9 @@
namespace reasampler::instrument::engine { namespace reasampler::instrument::engine {
// The playback rates the Preserve DSP is measured over, and therefore the only ones it // 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 // accepts. The ceiling, times the load-bounded source/host ratio, also bounds a voice's
// source frames) — the RT-safety argument for feeding a variable count at all. // 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 // 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 // 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 // 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 // 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 // (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 // = 2205/1.75 = 1260 puts any source period P > 315 frames (~140 Hz at 44.1k) inside the
// frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -24 st is reachable from the // inequality — bass/low-vocal material, and -24 st is reachable from the Pitch knob alone.
// Pitch knob alone. pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) // pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) measures this
// asserts this corner directly at P=500/600/700: energy outside the fundamental runs 7-21% there // corner directly at P=500/600/700 — by energy outside the fundamental, NOT zero-crossing
// against ~0% on an aligned control at the same rate/shift — zero-crossing period is NOT what it // period, since splice debris fools that estimator into reading the wrong period on a render
// checks, 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
// whose fundamental is provably correct. (The pre-stretch rate-1.0 engine's floor by the same // the 0.2-0.5% excess once measured over that floor was not the cadence at all: it was this
// inequality is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.) // 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 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 // 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). // holds in wall-clock terms at any ratio (preserveWindowFrames, play_params.h, owns why).
inline constexpr double kStretchRateMin = 0.5; inline constexpr double kStretchRateMin = 0.5;
inline constexpr double kStretchRateMax = 2.0; 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 // 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 // playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to
@@ -102,8 +104,9 @@ class StretchCursor {
public: public:
// `frame` is where the ring prime stopped; the per-frame feed continues there. // `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) — // `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. // exactly 1.0 at a matched rate, where the multiply below is the identity. Required, so no
void start(std::int64_t frame, double sourceRateRatio = 1.0) { // caller can drop the source/host conversion from the feed by omission.
void start(std::int64_t frame, double sourceRateRatio) {
frame_ = frame; frame_ = frame;
debt_ = 0.0; debt_ = 0.0;
sourceRatio_ = sourceRateRatio > 0.0 ? sourceRateRatio : 1.0; sourceRatio_ = sourceRateRatio > 0.0 ? sourceRateRatio : 1.0;
+2
View File
@@ -248,6 +248,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
stretch_.start(p, sourceRatio_); stretch_.start(p, sourceRatio_);
shiftL_.setFeedRate(feedRate_); shiftL_.setFeedRate(feedRate_);
shiftR_.setFeedRate(feedRate_); shiftR_.setFeedRate(feedRate_);
shiftL_.setSourceRateRatio(sourceRatio_);
shiftR_.setSourceRateRatio(sourceRatio_);
// Pitch-synchronous splices: the period was detected once at load (period_detect, // 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 // 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 // SampleData). 0 restores the fixed-window geometry, so a capture with no single
+4 -3
View File
@@ -556,9 +556,10 @@ private:
shiftL_.setShiftRatio(shift); shiftL_.setShiftRatio(shift);
if (stereoOut) shiftR_.setShiftRatio(shift); if (stereoOut) shiftR_.setShiftRatio(shift);
// 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are // 0..ceil(kStretchRateMax x sourceRatio_) source frames fall due this output frame
// written without producing output; the last rides the ordinary 1-in-1-out // (StretchCursor::due). All but the LAST are written without producing output; the
// process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged. // 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, // Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output. // shift the output.
const std::int64_t due = stretch_.due(stretchRate_); const std::int64_t due = stretch_.due(stretchRate_);
+3 -3
View File
@@ -316,11 +316,11 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded,
assert(decoded.sampleRate > 0 && assert(decoded.sampleRate > 0 &&
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)"); "buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first 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 // 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). // 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); data.frames = std::move(decoded.monoFrames);
// Carry the second channel only when it length-matches channel 0 (channelCount() // 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). // enforces the same rule; a mismatched pair falls back to mono rather than half-play).
+1 -1
View File
@@ -214,7 +214,7 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
## Modules ## 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`. - `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_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). - `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. - `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.
+3 -2
View File
@@ -100,8 +100,9 @@ void ReaSamplerEditor::resetParamControl(int id) {
resetDeckParam(static_cast<instrument::ui::DeckParam>(id), params_.play); resetDeckParam(static_cast<instrument::ui::DeckParam>(id), params_.play);
} }
double ReaSamplerEditor::liveSampleRate() const { double ReaSamplerEditor::selectedSourceRate() const {
return processor_ ? static_cast<double>(processor_->loadedSourceRate()) : 0.0; const auto it = pcmCache_.find(selectedId_);
return it != pcmCache_.end() ? static_cast<double>(it->second.sampleRate) : 0.0;
} }
double ReaSamplerEditor::previewVelocity01() const { 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), // (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 // 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. // 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 = const bool nodesLive =
overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates); overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates);
NodeHit envNodeHit; 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 // set. The StageEnvelope was snapshotted at grab (dragStartEnv_) so the delta is
// absolute. // absolute.
const std::int64_t frames = dragSampleFrames_; const std::int64_t frames = dragSampleFrames_;
const double rate = liveSampleRate(); const double rate = selectedSourceRate();
if (frames <= 0 || rate <= 0.0) return; if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate; const double totalSeconds = static_cast<double>(frames) / rate;
const DragModifiers mods = dragModifiers(); const DragModifiers mods = dragModifiers();
+1 -1
View File
@@ -63,7 +63,7 @@ StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds
std::int64_t frames, std::int64_t frames,
std::int64_t startFrame) const { std::int64_t startFrame) const {
StageEnvelope env; StageEnvelope env;
const double rate = liveSampleRate(); const double rate = selectedSourceRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0; 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 // 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. // post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
+8 -2
View File
@@ -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 // 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 // source, not the instrument's identity) — a self-contained instance names its sound
// (refs displayName fallback) even when the bank snapshot is empty. // (refs displayName fallback) even when the bank snapshot is empty.
if (!selectedId_.empty()) if (!selectedId_.empty()) {
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]"; 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 if (samples_.empty()) title += " [bank empty]";
else title += " [pick a capture]"; else title += " [pick a capture]";
} else { } else {
@@ -388,7 +388,7 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
paintSplineOverlay(bmp, waveArea); paintSplineOverlay(bmp, waveArea);
return; return;
} }
const double rate = liveSampleRate(); const double rate = selectedSourceRate();
if (rate <= 0.0) return; if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate; const double totalSeconds = static_cast<double>(frames) / rate;
const std::int64_t startFrame = params_.startPoint.value_or(0); const std::int64_t startFrame = params_.startPoint.value_or(0);
+6 -5
View File
@@ -434,12 +434,12 @@ const ReaSamplerEditor::ChannelPcm& ReaSamplerEditor::channelPcmFor(
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
auto it = pcmCache_.find(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 // every paint. Keyed by id (width-independent) — the thumbnail bins this at whatever
// width, the snap scans it directly. // width, the snap scans it directly.
std::vector<AudioSample> mono; MonoPcm mono;
const std::string relativePath = samplePathFor(sampleId); const std::string relativePath = samplePathFor(sampleId);
if (!relativePath.empty()) { if (!relativePath.empty()) {
const std::string projectDir = processor_->bridge().activeProjectDir(); const std::string projectDir = processor_->bridge().activeProjectDir();
@@ -449,11 +449,12 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
if (layout.valid) { if (layout.valid) {
std::vector<AudioSample> interleaved = std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount()); 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)); 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) { const Envelope& ReaSamplerEditor::heroEnvelope(std::size_t bins, int laneCount) {
+6 -4
View File
@@ -128,14 +128,16 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
// The snapshot comes first: the default program's window is derived from the sound it // 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. // carries (a Gate release, a Trigger play span), not from a constant.
std::optional<SampleData> snapshot = processor.bakeSnapshot(sampleRate); ReaSamplerProcessor::BuiltCapture built = processor.bakeSnapshot(sampleRate);
if (!snapshot) return fail("the loaded capture could not be decoded for the render"); 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 // 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. // are live, so a sound dialed at 127 does not bake as one dialed at 40.
const Velocity velocity = Velocity::of(processor.previewVelocity()); const Velocity velocity = Velocity::of(processor.previewVelocity());
const instrument::bake::PlannedBake planned = planBake( const instrument::bake::PlannedBake planned = planBake(
resolveNote(defaultBakeProgram(*snapshot, sampleRate, dialed.bakeHold, velocity), resolveNote(defaultBakeProgram(snapshot, sampleRate, dialed.bakeHold, velocity),
*tempo), *tempo),
sampleRate, rootNote); sampleRate, rootNote);
if (!planned.plan) { if (!planned.plan) {
@@ -146,7 +148,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
const instrument::bake::BakePlan& plan = *planned.plan; const instrument::bake::BakePlan& plan = *planned.plan;
const instrument::bake::BakeAudio audio = const instrument::bake::BakeAudio audio =
renderBake(std::move(*snapshot), plan, processor.masterGainLinear(), renderBake(std::move(snapshot), plan, processor.masterGainLinear(),
dialed.limiterEnabled); dialed.limiterEnabled);
if (audio.empty()) return fail("the offline pass produced no audio"); if (audio.empty()) return fail("the offline pass produced no audio");
+50 -19
View File
@@ -13,6 +13,7 @@
#include <mutex> #include <mutex>
#include <optional> #include <optional>
#include <random> #include <random>
#include <string>
#include <utility> #include <utility>
#include <vector> #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 // 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. // 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 InstrumentParams& params,
const std::string& projectDir, ChannelMode mode, const std::string& projectDir,
int renderRate) { ChannelMode mode, int renderRate) {
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode); 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 = SampleData sample =
buildSampleData(resolveCapture(sel, params), std::move(*pcm), renderRate); buildSampleData(resolveCapture(sel, params), std::move(*pcm), renderRate);
if (!sample.playable()) return std::nullopt; if (!sample.playable()) return {};
return sample; return {std::move(sample), false};
} }
} // namespace } // namespace
std::string sourceRateRefusalText() {
return "sample rate over " + std::to_string(static_cast<int>(kMaxSourceRateRatio)) +
"x the host's";
}
std::string ReaSamplerProcessor::reloadInstrument() { std::string ReaSamplerProcessor::reloadInstrument() {
// OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the // OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the
// retired-slot free is single-writer; never taken on the audio thread. // 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; std::unique_ptr<LoadedInstrument> built;
SampleData sample; SampleData sample;
bool havePlayable = false; bool havePlayable = false;
bool rateRefused = false;
// 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard // 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 // repitched from its effective root. No first-sample fallback: an empty selection
@@ -144,9 +152,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
channelModeExplicit_); channelModeExplicit_);
mode = channelMode_; mode = channelMode_;
} }
if (std::optional<SampleData> decoded = BuiltCapture decoded = buildFromRef(*sel, params, projectDir, mode, hostRenderRate());
buildFromRef(*sel, params, projectDir, mode, hostRenderRate())) { rateRefused = decoded.rateRefused;
sample = std::move(*decoded); if (decoded.sample) {
sample = std::move(*decoded.sample);
havePlayable = true; havePlayable = true;
// Point the built snapshot at the instance's ONE engine-facing block and seed the // 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 // 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 // build's rate — or, on the first-ever build, against 0, where secondsToFrames
// collapses every automated envelope stage to zero frames. // collapses every automated envelope stage to zero frames.
builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed); 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; // reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting. // 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)); if (havePlayable) built = buildInstrumentLocked(std::move(sample));
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the // 3. Publish: atomically install the new instrument via the drain-slot swap (see the
@@ -192,9 +201,9 @@ std::string ReaSamplerProcessor::usageInstanceGuid() {
return instanceGuid_; return instanceGuid_;
} }
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot(int renderRate) { ReaSamplerProcessor::BuiltCapture ReaSamplerProcessor::bakeSnapshot(int renderRate) {
const std::string selId = selectedSampleId(); const std::string selId = selectedSampleId();
if (selId.empty()) return std::nullopt; if (selId.empty()) return {};
const InstrumentParams params = instrumentParams(); const InstrumentParams params = instrumentParams();
SampleRefs refs; SampleRefs refs;
{ {
@@ -202,7 +211,7 @@ std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot(int renderRate) {
refs = sampleRefs_; refs = sampleRefs_;
} }
const SelectedSample* sel = findRef(refs, selId); 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 // 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. // every knob has already written, rather than whatever the audio thread is observing.
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode(), renderRate); 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. // voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is.
std::lock_guard<std::mutex> lock(reloadMutex_); std::lock_guard<std::mutex> lock(reloadMutex_);
// Every frame count in a built sample — and in the live block published beside it — was // 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 // resolved for the host rate of its build, and setupProcessing may have changed that rate
// inactive (or a setState load ran before the host reported one), and neither a park nor a // while inactive (or a setState load ran before the host reported one). Only the resolve
// publish can be re-used across it: the full reload re-resolves both. // 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(); const int renderRate = hostRenderRate();
// A publish that landed while inactive (setState's reload, a bake's adopt) IS the // 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 // 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)) { 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: // 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 // 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 // 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. // the voices play another. Hand back to the full reload, which decodes the new one.
if (sourceMoved) return false; 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 // 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 // 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; 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() { void ReaSamplerProcessor::retireIdleDrain() {
// Cheap early-out before the lock: 0 means "no drain, or it still sounds". // 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); const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
+13 -5
View File
@@ -409,10 +409,13 @@ private:
// The effective root: params_.rootOverride, else the bank intrinsic, else middle C. // The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
int effectiveRoot() const; int effectiveRoot() const;
// The loaded capture's OWN rate — the time base that maps its waveform frames to 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 has loaded (caller guards). Not 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. // 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]). // Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
double previewVelocity01() const; double previewVelocity01() const;
// The bake Hold division as a 0..1 knob position, via the pure bake_hold map. // 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_; std::unordered_map<std::string, Envelope> thumbCache_;
// Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope // Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope
// binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh. // binning + zero-crossing snap, and carries the file's own rate beside the frames it is the
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_; // 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 // The single-slot interleaved-PCM cache behind channelPcmFor (see its note on why this
// is not keyed into pcmCache_). Cleared alongside pcmCache_ on refresh. // is not keyed into pcmCache_). Cleared alongside pcmCache_ on refresh.
+33 -14
View File
@@ -37,6 +37,10 @@ using instrument::map::kPreviewVelocityDefault;
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface) 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 { class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
public: public:
ReaSamplerProcessor() = default; ReaSamplerProcessor() = default;
@@ -150,8 +154,15 @@ public:
// the rate the bake's plan renders at, passed in so the two cannot disagree. Rebuilt // 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 // 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. // 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). // Empty when nothing is loaded, the WAV is unreadable, or its rate is refused (then
std::optional<SampleData> bakeSnapshot(int renderRate); // `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 // 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 // selection, the parameter set and master gain go neutral, and a single reload
@@ -186,9 +197,10 @@ public:
int hostRenderRate() const { int hostRenderRate() const {
return sampleRate_ > 0.0 ? static_cast<int>(sampleRate_ + 0.5) : 0; 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 // True while the selected capture is silent because the last reload refused its file's
// editor's staged-envelope overlay maps seconds onto. 0 before anything has loaded. // rate (BuiltCapture::rateRefused). The editor's title band names it
int loadedSourceRate() const { return loadedSourceRate_.load(std::memory_order_relaxed); } // (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). // The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
// Empty id -> silence, no first-sample fallback. // Empty id -> silence, no first-sample fallback.
std::string selectedSampleId(); std::string selectedSampleId();
@@ -337,13 +349,20 @@ private:
// The reactivation half of the activation/decode lifetime split (see dormantSample_): // 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 // 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 // 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 // when a publish landed while inactive, which needs no rebuild. A park or publish resolved
// to activate from, when the park or that publish was resolved for a host rate that has // for a host rate that has since changed is re-stamped in place (restampHostRateLocked)
// since changed, or when that fold moved what the park was decoded from — the caller then // rather than re-decoded. False when there is nothing to activate from, when the new host
// falls back to a full reload, which decodes the new file and is also where the pre-v10 // rate refuses the file's, or when that fold moved what the park was decoded from — the
// legacy lift lives. Off the audio thread only. // 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(); 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 // 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 // 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. // 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 // 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 // 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 // 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}; std::atomic<int> builtSampleRate_{0};
// The loaded capture's own decode rate; see loadedSourceRate(). // See sourceRateRefused(). Written by reloadInstrument only.
std::atomic<int> loadedSourceRate_{0}; std::atomic<bool> sourceRateRefused_{false};
// --- The audio-thread handoff (drain slot) --- // --- The audio-thread handoff (drain slot) ---
// process() atomically loads live_ + draining_ at block start (two acquires, no lock). // 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 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 // 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; double sampleRate_ = 0.0;
Steinberg::int32 maxBlockSize_ = 4096; Steinberg::int32 maxBlockSize_ = 4096;
+80 -18
View File
@@ -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 // 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 // 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 // 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, // 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 // 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 // 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 // (same rate/shift/source, this file's own metric parameters) at 7.08% / 13.90% / 20.92% for
// P=500/600/700, against 0.10% on an alignable control (P=200, below the safe floor) at the // 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. // 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. // **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, // They are the metric's own floor: an ideal tone at the same want-period, measured
// measured identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because // identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because a long
// a long period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6% // period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6% band.
// band. The corner's real EXCESS over that floor is 0.23% / 0.51% / 0.30% — small, real, and // The corner's EXCESS over that floor once measured 0.23% / 0.51% / 0.30% (control 0.10%); that
// nothing like the headline numbers. The alignable control's 0.10% is genuinely near-zero only // residue was the down-shift crossfade (recede 1.75 frames/frame) lapping the ring's far end,
// because its want-period is short enough to have almost no floor. The bounds below are kept // and splice()'s far-end fade cap took it to 0.00%. The bounds below are kept as a stable
// as a stable regression tripwire on the raw readings; read the excess, not the reading. // 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 // 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 // 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; const std::size_t from = 20000, len = 32768;
// Below the safe floor (P > 315 frames): per the header's CORRECTED note, this raw reading // 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 // is the metric's own mainlobe-leakage floor, not cadence damage (the excess is asserted in
// ~0.2-0.5%, asserted in testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds // testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
// below are a RAW-READING STABILITY TRIPWIRE, not a discriminator — they catch a large swing // 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 // 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. // 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 // 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 // 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 // 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, // test's reading (see its comment) shrank the corner from a 7-21% headline to no measurable
// which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each splice // excess, which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each
// land in phase, and these splices already did; what it cannot do is make them less frequent. // splice land in phase, and these splices already did; what it cannot do is make them less
// So this asserts NO REGRESSION, not an improvement, and says so rather than claiming one. // frequent. So this asserts NO REGRESSION, not an improvement, and says so rather than
// claiming one.
static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() { static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
using reasampler::test_support::energyOutsideFundamentalPercent; using reasampler::test_support::energyOutsideFundamentalPercent;
const std::int64_t w = 2205; 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 // 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), // 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 // 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 // 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 // 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); "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 CHECK(pctOn - floor < 1.0); // measured 0.00%: phase-perfect at n=1, not merely aligned
// Vacuity guard (shape of testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown's :961): the // 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. // silently did nothing would render both arms identically and pass the bound above by luck.
CHECK(pctOff - floor > pctOn - floor + 1.0); CHECK(pctOff - floor > pctOn - floor + 1.0);
} }
@@ -1099,6 +1160,7 @@ int main() {
testStereoLinkedLagSharedSchedule(); testStereoLinkedLagSharedSchedule();
testStretchMovesDurationNotPitch(); testStretchMovesDurationNotPitch();
testStretchAndShiftComposeSafely(); testStretchAndShiftComposeSafely();
testDownSpliceFadeNeverLapsTheRingsFarEnd();
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter(); testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound(); testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound();
testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry(); testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry();
+1 -1
View File
@@ -270,7 +270,7 @@ static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int6
ps.setFeedRate(rate); ps.setFeedRate(rate);
ps.setSourcePeriod(sourcePeriod); ps.setSourcePeriod(sourcePeriod);
StretchCursor cur; StretchCursor cur;
cur.start(window); cur.start(window, 1.0);
loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through
SpliceStats st; SpliceStats st;
+7
View File
@@ -953,6 +953,13 @@ static void testBuildSampleDataRefusesARateNoCaptureCouldHave() {
CHECK(!corrupt.playable()); CHECK(!corrupt.playable());
// Far BELOW the host costs nothing per frame, so it plays. // Far BELOW the host costs nothing per frame, so it plays.
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, 1000, {}}, host).playable()); 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() { static void testBuildSampleDataCarriesTheSecondChannel() {
+203 -25
View File
@@ -4,16 +4,20 @@
// directions and at a large ratio, and asserts in wall-clock / Hz terms — output frames at the // directions and at a large ratio, and asserts in wall-clock / Hz terms — output frames at the
// host rate — never in source frames. // host rate — never in source frames.
#include "../src/core/instrument/engine/voice.h" #include "../src/core/instrument/engine/voice_engine.h"
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstring>
#include <vector> #include <vector>
#include "tone_metrics.h" #include "tone_metrics.h"
using namespace reasampler; using namespace reasampler;
using instrument::engine::LiveParams;
using instrument::engine::LiveValues;
using instrument::engine::foldLive;
namespace flt = reasampler::instrument::engine::filter; namespace flt = reasampler::instrument::engine::filter;
using reasampler::test_support::autocorrelationPeriod; using reasampler::test_support::autocorrelationPeriod;
@@ -59,23 +63,24 @@ SampleData dcSample(const RateCase& c, std::size_t frames) {
return s; 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)); 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; Voice v;
startVoice(v, s, note); startVoice(v, s, note, stretch);
std::vector<double> out(static_cast<std::size_t>(frames), 0.0); std::vector<double> out(static_cast<std::size_t>(frames), 0.0);
for (auto& x : out) x = v.renderFrame(); for (auto& x : out) x = v.renderFrame();
return out; return out;
} }
// Output frames the voice renders before it goes idle, capped. // 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; Voice v;
startVoice(v, s, note); startVoice(v, s, note, stretch);
std::int64_t n = 0; std::int64_t n = 0;
while (v.active() && n < cap) { while (v.active() && n < cap) {
v.renderFrame(); 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. // 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 = &block;
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;
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 // 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 // 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. // 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; return out;
} }
// renderRate 0 is how every other core test builds its SampleData, so those suites' exact-value // FNV-1a 64 over the float bit patterns, in render order.
// assertions pin that path; the loader always states the host rate. The two must agree bit for static std::uint64_t bitHash(const std::vector<float>& v) {
// bit at a matched rate, or a same-rate capture would sound different in the plugin than in the std::uint64_t h = 14695981039346656037ull;
// suites that vouch for it. for (float x : v) {
static void testStatingAMatchedHostRateChangesNoBit() { std::uint32_t bits;
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) { std::memcpy(&bits, &x, sizeof bits);
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { for (int b = 0; b < 4; ++b) {
for (int note : {57, 64, 45}) { h ^= (bits >> (8 * b)) & 0xFFu;
const SampleData unstated = richSample(engine, mode); // renderRate 0 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; SampleData stated = unstated;
stated.renderRate = stated.sampleRate; stated.renderRate = stated.sampleRate;
const std::vector<float> a = renderStereoNote(unstated, note); const std::uint64_t a = bitHash(renderStereoNote(unstated, g.note));
const std::vector<float> b = renderStereoNote(stated, note); const std::uint64_t b = bitHash(renderStereoNote(stated, g.note));
bool identical = a.size() == b.size(); CHECK(a == b);
for (std::size_t i = 0; identical && i < a.size(); ++i) { #if defined(_MSC_VER) && defined(_M_X64)
if (a[i] != b[i]) identical = false; if (b != g.hash) {
} std::printf(" pre-factor mismatch: engine %d mode %d note %d: 0x%016llx\n",
CHECK(identical); 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(); testVarispeedRootNotePlaysAtItsOwnPitchAndDuration();
testPreserveRootAndOctaveHoldPitchAndWallClockDuration(); testPreserveRootAndOctaveHoldPitchAndWallClockDuration();
testTriggerPlayPercentageIsAFractionOfTheWallClockLength(); testTriggerPlayPercentageIsAFractionOfTheWallClockLength();
testPreserveDownShiftAtAHighRatioSplicesAsCleanlyAsAMatchedRate();
testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines(); testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines();
testGateAdsrAttackTakesItsWallClockTime(); testGateAdsrAttackTakesItsWallClockTime();
testRateAndPitchOffsetAtAMismatchedRateKeepWallClockAndPitch();
testLiveStageTimesLandInWallClockAtAMismatchedRate();
testPitchEnvelopeSpanIsTheSourcesWallClockLength(); testPitchEnvelopeSpanIsTheSourcesWallClockLength();
testSingleCycleLoopSustainsAtThePitchOfItsCycle(); testSingleCycleLoopSustainsAtThePitchOfItsCycle();
testLoopCrossfadeTakesItsWallClockTime(); testLoopCrossfadeTakesItsWallClockTime();
testFilterCornerLandsAtItsHertzAtAMismatchedRate(); testFilterCornerLandsAtItsHertzAtAMismatchedRate();
testStatingAMatchedHostRateChangesNoBit(); testAMatchedRateRendersBitIdenticalToThePreFactorEngine();
if (g_fail == 0) std::printf("source_rate: all tests passed\n"); if (g_fail == 0) std::printf("source_rate: all tests passed\n");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
} }
+15 -11
View File
@@ -6,8 +6,8 @@
// the mechanism behind the "unity is bit-identical to the shipped Preserve read" gate. // 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 // 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. // 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 // 3. the per-output-frame feed count never exceeds ceil(kStretchRateMax) at a matched rate —
// a variable-length feed loop RT-safe. // 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 // 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). // silent stall or a quarter-speed surprise).
// 4b. the source/host rate ratio multiplies the CLAMPED rate, unclipped by the clamp itself. // 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{}; } 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) { static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) {
ResolvedLoop lp; ResolvedLoop lp;
lp.active = true; 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. --- // --- 1. Unity is exactly one frame per output frame, with no drifting residue. ---
static void testUnityRateFeedsExactlyOneFramePerOutputFrame() { static void testUnityRateFeedsExactlyOneFramePerOutputFrame() {
StretchCursor c; StretchCursor c;
c.start(100); c.start(100, 1.0);
const ResolvedLoop lp = noLoop(); const ResolvedLoop lp = noLoop();
for (std::int64_t i = 0; i < 200000; ++i) { for (std::int64_t i = 0; i < 200000; ++i) {
CHECK(c.due(1.0) == 1); CHECK(c.due(1.0) == 1);
@@ -60,7 +64,7 @@ static void testTotalConsumedTracksTheRate() {
// would drift without bound rather than carrying the residue. // 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}) { for (double rate : {0.5, 0.75, 1.0, 1.3333333333333333, 2.0, 1.0 / 3.0 + 1.0}) {
StretchCursor c; StretchCursor c;
c.start(0); c.start(0, 1.0);
const std::int64_t outFrames = 100000; const std::int64_t outFrames = 100000;
for (std::int64_t i = 0; i < outFrames; ++i) { for (std::int64_t i = 0; i < outFrames; ++i) {
const std::int64_t due = c.due(rate); const std::int64_t due = c.due(rate);
@@ -78,14 +82,14 @@ static void testFeedPerOutputFrameIsBounded() {
// for as many iterations as the rate names. // for as many iterations as the rate names.
for (double rate : {kStretchRateMax, kStretchRateMax * 100.0, 3.99, 2.5}) { for (double rate : {kStretchRateMax, kStretchRateMax * 100.0, 3.99, 2.5}) {
StretchCursor c; StretchCursor c;
c.start(0); c.start(0, 1.0);
std::int64_t worst = 0; std::int64_t worst = 0;
for (std::int64_t i = 0; i < 20000; ++i) { for (std::int64_t i = 0; i < 20000; ++i) {
const std::int64_t due = c.due(rate); const std::int64_t due = c.due(rate);
if (due > worst) worst = due; if (due > worst) worst = due;
for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp); 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 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); CHECK(clampStretchRate(std::nan("")) == 1.0);
// ...and the cursor honours it rather than looping on the raw value. // ...and the cursor honours it rather than looping on the raw value.
StretchCursor c; StretchCursor c;
c.start(0); c.start(0, 1.0);
CHECK(c.due(-5.0) == 1); // folded to unity CHECK(c.due(-5.0) == 1); // folded to unity
StretchCursor d; StretchCursor d;
d.start(0); d.start(0, 1.0);
CHECK(d.due(50.0) <= kMaxFeedPerFrame); CHECK(d.due(50.0) <= kMatchedFeedBound);
} }
// --- 4b. The source/host rate ratio scales the feed AFTER the musical clamp. --- // --- 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 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}) { for (double rate : {0.5, 1.0, 2.0, 4.0}) {
StretchCursor c; StretchCursor c;
c.start(1000); c.start(1000, 1.0);
std::int64_t lowest = 1 << 30, highest = -1; std::int64_t lowest = 1 << 30, highest = -1;
for (std::int64_t i = 0; i < 50000; ++i) { for (std::int64_t i = 0; i < 50000; ++i) {
const std::int64_t due = c.due(rate); 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 // 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. // its first take rather than reading off the end.
StretchCursor c; StretchCursor c;
c.start(5000); c.start(5000, 1.0);
const std::int64_t q = c.next(lp); const std::int64_t q = c.next(lp);
CHECK(q >= lp.start && q < lp.end); CHECK(q >= lp.start && q < lp.end);
} }