Γ-W1-T7: make Preserve's splices pitch-synchronous — the jump is a whole number of the source's own period, detected once at load

30 Hz out-of-band energy 15.45% -> 0.00%; the 29 Hz rate-2.0 detune -133 -> +0 cents.
An unknown period keeps the fixed-window geometry bit for bit. The detector cannot
reach process(): sampler_core does not link it.
This commit is contained in:
2026-08-01 23:19:16 -04:00
parent 163ab11e05
commit 93230208ff
17 changed files with 1085 additions and 137 deletions
@@ -0,0 +1,54 @@
#pragma once
// period_detect — the source's own fundamental period, estimated ONCE per load from decoded
// PCM, for the Preserve splice's pitch-synchronous jump (pitch_shift.h's periodAlignedJump).
//
// Runs off the audio thread BY LINK GRAPH: sampler_core does not link this module, so no
// translation unit on the render path can name detectPeriod. A sampler's source is fixed and
// fully known at load, which is the whole reason a detector is affordable here at all.
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::instrument::engine {
using audio::AudioSample;
// The period the source repeats at, in SOURCE frames, or none. Derived from the audio, never
// authored and never persisted — this is a cache, not state.
struct PeriodEstimate {
double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise)
double confidence = 0.0; // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0
bool valid() const { return frames > 0.0; }
};
// Fundamental bounds the search runs over. The LOW bound is the load-bearing one: a period
// only buys anything while it fits the splice's reachable jump (~1.25 windows, i.e. ~16 Hz at
// the product's 50 ms window), so searching below it would return periods the shifter must
// reject anyway. The high bound is generous — a period that short already has dozens of
// aligned landing points inside the search interval, so alignment was never in question there.
inline constexpr double kPeriodDetectMinHz = 15.0;
inline constexpr double kPeriodDetectMaxHz = 2000.0;
// YIN's absolute threshold: the first dissimilarity dip below this IS the period. A source
// that never dips below it has no single period, and detection returns none rather than the
// global minimum — the difference between "quiet but real" and "the least bad of nothing".
inline constexpr double kPeriodDetectThreshold = 0.12;
// How many blocks across the sample are estimated independently, and how far apart two of them
// may land and still be called the same period. Agreement is what separates a genuinely
// periodic source from one whose opening happens to look periodic.
inline constexpr int kPeriodDetectProbes = 4;
inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median
// Estimates `pcm`'s fundamental period at `sampleRate`. Cost is bounded by the constants above,
// not by the sample length: at most kPeriodDetectProbes blocks of ~2 x the longest searched lag
// are analysed however long the source is. Allocates; never call from process().
//
// Returns an invalid estimate (frames == 0) for silence, noise, and anything whose probes
// disagree — the caller's documented fallback is the fixed-window splice geometry.
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate);
} // namespace reasampler::instrument::engine