Play cross-rate captures at their own pitch and length

The voice now converts the file's rate to the host's at note-on instead of assuming they match. Stage times resolve at the host rate, the filter solves at it, and the bake follows. Same-rate renders are unchanged bit for bit.
This commit is contained in:
2026-09-25 13:44:39 -04:00
parent 9ff34b0a9e
commit ec60cf78c6
25 changed files with 749 additions and 137 deletions
+23 -15
View File
@@ -207,24 +207,27 @@ private:
// THE fold of every pitch factor that is constant for the note into one number, so
// advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has
// always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset,
// and the Rate ratio — which start() zeroes out of this product when the note is running the
// the Rate ratio — which start() zeroes out of this product when the note is running the
// Preserve read, since Rate feeds stretch_ (duration) there and must never reach the
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
// shifter's transpose — and the source/host rate conversion, which belongs in BOTH reads:
// under Preserve the shifter's ring holds source frames, so its tap needs the same factor
// to land the root on the root. Cold: note-on, legato retune, and a live block.
void recomputeBaseRatio() {
if (sample_ == nullptr) return;
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_ * sourceRatio_;
}
// The rate the read head consumes SOURCE at, counting only the factors whose stage-time
// coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset
// coupling is compensated. Under Preserve that is the feed rate alone — the Pitch offset
// transposes inside the shifter and never touches the read. Under Varispeed both Rate and
// Pitch are factors of the read increment and both are compensated: they are two views of one
// multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the
// velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped
// sounds, and compensating them would move every note off the root.
// multiply, so the "30 ms is 30 ms" rule binds them identically. The source/host conversion
// (inside feedRate_) is compensated under both, because it is a change of units, not a
// sound. Key-tracking and the velocity->pitch transpose are deliberately LEFT OUT — those
// predate Rate, are shipped sounds, and compensating them would move every note off the root.
double stageFitRate() const {
return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_;
return preserveRead_ ? feedRate_ : feedRate_ * pitchOffsetRatio_;
}
// A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the
@@ -258,7 +261,7 @@ private:
const double postStart = static_cast<double>(
static_cast<std::int64_t>(sample_->frames.size()) - startFrame_);
const double readRate =
preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
preserveRead_ ? feedRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
const double span = (readRate > 0.0) ? postStart / readRate : postStart;
return static_cast<std::int64_t>(span + 0.5);
}
@@ -529,8 +532,8 @@ private:
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// The two rates the shifter takes (pitch_shift.h owns why they are independent):
// the source is FED at stretchRate_, and the tap is SHIFTED by
// 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount,
// the source is FED at feedRate_, and the tap is SHIFTED by baseRatio_ x
// 2^(pitchEnvSemis/12) — the pitch envelope adds to the shift amount,
// never to the read rate. The feed runs one window ahead of readPos_ (the rings were
// primed with that window at start()), under the same sustain-loop wrap rule,
// reading integer source frames into the ring — no RATE-DEPENDENT interpolation
@@ -607,13 +610,13 @@ private:
outRlocal = shiftedL;
}
}
// Preserve advances the read head at the STRETCH rate — the one duration control.
// Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
// therefore stays a source-frame fact and scales by construction.
// Preserve advances the read head at the FEED rate — the one duration control, in
// source frames. Everything downstream of it (the loop wrap, the Trigger span, the
// spline phase) therefore stays a source-frame fact and scales by construction.
//
// The two sustain-less envelopes are evaluated at sourceOffset(), which advances at
// this rate — rateFittedAhd is what keeps their stage times wall-clock anyway.
ratio_ = stretchRate_;
ratio_ = feedRate_;
} else {
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
@@ -816,6 +819,11 @@ private:
PitchShifter shiftR_;
instrument::engine::StretchCursor stretch_;
double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on
// SampleData::sourceRateRatio, latched at note-on: source frames per output frame at unity.
double sourceRatio_ = 1.0;
// stretchRate_ in source frames per output frame (x sourceRatio_): the Preserve read head's
// and feed's exact rate, and the Rate factor every stage-time fit converts by.
double feedRate_ = 1.0;
std::vector<AudioSample> primeBuf_;
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()