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
+19 -7
View File
@@ -104,8 +104,8 @@ enum class PitchEngine { Varispeed, Preserve };
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// OLA window for the Preserve PitchShifter, in ms of SOURCE (preserveWindowFrames); larger =
// smoother on big transpositions. Onset latency is zero — start() primes the ring with the first window
// of real source, so output frame 0 is source frame 0 regardless of window size.
// smoother on big transpositions. Onset latency is zero — start() primes the ring with the first
// window of real source, so output frame 0 is source frame 0 regardless of window size.
inline constexpr double kPreserveWindowMs = 50.0;
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 0 ->
@@ -324,15 +324,27 @@ struct SampleData {
}
};
// The largest sourceRateRatio a load accepts. The Preserve feed consumes that many times the
// stretch rate in source frames per output frame, so the ratio bounds per-frame work on the
// audio thread; a header claiming more than this is treated as corrupt (buildSampleData).
// The largest sourceRateRatio a load accepts. It is the one per-frame cost the ratio scales: a
// Preserve voice writes up to ceil(kStretchRateMax x ratio) source frames into its ring per
// output frame (StretchCursor::due). The splice search does NOT scale with it — pitch_shift
// strides it to a matched rate's cost. A header claiming more than this is treated as corrupt
// (buildSampleData).
inline constexpr double kMaxSourceRateRatio = 32.0;
// THE refusal predicate for that bound: whether a file at `sourceRate` may play in a host
// emitting `renderRate` (<= 0: not reported, so the file plays at its own rate). Named apart
// from the build so a shell can tell the user WHY a load is silent — the file decoded fine.
inline bool sourceRateSupported(int sourceRate, int renderRate) {
if (sourceRate <= 0 || renderRate <= 0) return true;
return static_cast<double>(sourceRate) / static_cast<double>(renderRate) <=
kMaxSourceRateRatio;
}
// The Preserve ring's window in SOURCE frames. The ring holds source and its feed and tap both
// carry sourceRateRatio, so a window sized at the source rate keeps every splice interval,
// search reach and alignable period in time_stretch.h fixed in Hz at any host rate. Floored at 2
// so a valid window is always a real ring.
// search reach and alignable period in time_stretch.h fixed in Hz at any host rate; the
// shifter's crossfade and search stride are what it converts to output frames
// (PitchShifter::setSourceRateRatio). Floored at 2 so a valid window is always a real ring.
inline std::int64_t preserveWindowFrames(const SampleData& s) {
const std::int64_t w = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(s.sampleRate) / 1000.0 + 0.5);