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
+37 -5
View File
@@ -103,8 +103,8 @@ enum class PitchEngine { Varispeed, Preserve };
// still halves duration with no params set).
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
// on big transpositions. Onset latency is zero — start() primes the ring with the first window
// OLA window for the Preserve PitchShifter, in ms of SOURCE (preserveWindowFrames); larger =
// smoother on big transpositions. Onset latency is zero — start() primes the ring with the first window
// of real source, so output frame 0 is source frame 0 regardless of window size.
inline constexpr double kPreserveWindowMs = 50.0;
@@ -253,9 +253,13 @@ struct SampleLoop {
struct SampleData {
std::vector<AudioSample> frames;
std::vector<AudioSample> framesR; // empty for a mono sample
int sampleRate = 0; // ratio math is note-relative, so rate cancels for
// repitch; still, 0 is invalid — every consumer must
// receive a real rate before use.
// The SOURCE's own rate: the PCM, the loop, the start, the crossfade and the source period
// are all frames at this rate.
int sampleRate = 0;
// The rate the voice EMITS frames at (the host's), and so the rate every frame count in
// `play` was resolved against. 0 = not supplied, which renders the source at its own rate —
// a hand-built SampleData, or a load before the host reported a rate.
int renderRate = 0;
int rootNote = 60;
SampleLoop loop;
@@ -305,6 +309,34 @@ struct SampleData {
// Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a
// voice on an empty read span.
bool playable() const { return !frames.empty(); }
int outputRate() const { return renderRate > 0 ? renderRate : sampleRate; }
// Source frames consumed per output frame at unity pitch — THE conversion between the two
// frame domains, folded into the read and the Preserve feed at note-on. Exactly 1.0 when the
// rates agree or either is unknown, which is what keeps a same-rate render bit-identical to
// the engine before the factor existed.
double sourceRateRatio() const {
const int out = outputRate();
return (sampleRate > 0 && out > 0 && sampleRate != out)
? static_cast<double>(sampleRate) / static_cast<double>(out)
: 1.0;
}
};
// The largest sourceRateRatio a load accepts. The Preserve feed consumes that many times the
// stretch rate in source frames per output frame, so the ratio bounds per-frame work on the
// audio thread; a header claiming more than this is treated as corrupt (buildSampleData).
inline constexpr double kMaxSourceRateRatio = 32.0;
// The Preserve ring's window in SOURCE frames. The ring holds source and its feed and tap both
// carry sourceRateRatio, so a window sized at the source rate keeps every splice interval,
// search reach and alignable period in time_stretch.h fixed in Hz at any host rate. Floored at 2
// so a valid window is always a real ring.
inline std::int64_t preserveWindowFrames(const SampleData& s) {
const std::int64_t w = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(s.sampleRate) / 1000.0 + 0.5);
return w < 2 ? 2 : w;
}
} // namespace reasampler