Preserve's period detection: probes are placed by position, and a sustain loop is the span analysed

This commit is contained in:
2026-08-02 03:21:06 -04:00
parent 334022c0f1
commit 9228e93750
6 changed files with 299 additions and 22 deletions
+46 -6
View File
@@ -6,6 +6,7 @@
// 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 <cstddef>
#include <cstdint>
#include <vector>
@@ -19,9 +20,12 @@ using audio::AudioSample;
// authored and never persisted — this is a cache, not state.
struct PeriodEstimate {
double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise)
// 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic only today: the
// accept decision is `valid()` alone, and the loader takes `.frames` without reading this —
// do not assume it is load-bearing without checking who reads it.
// 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic: the accept decision
// is `valid()` alone and the loader takes `.frames` without reading this — its consumers are
// the tests and the measurement harness. It is deliberately NOT a second accept gate: every
// accepted probe already cleared kPeriodDetectThreshold, so confidence > 0.88 holds by
// construction and any gate below that is a no-op while any gate above it is a tuned number
// with nothing to derive it from.
double confidence = 0.0;
bool valid() const { return frames > 0.0; }
@@ -43,15 +47,51 @@ 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.
//
// Probes are placed by POSITION and may overlap: what the rule needs is estimates from
// different places in the source, and two blocks a full longest-lag apart already differ by a
// whole cycle of the lowest frequency in the band, so neither can be a trivially shifted copy
// of the other at any period searched. Requiring DISJOINT blocks instead left every source
// under ~4x the longest lag with a single probe and so with no agreement to check at all.
// One probe survives as an irreducible case below `block + longest lag` frames and is accepted
// on the absolute threshold alone — see detectPeriod's contract.
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().
// Estimates the fundamental period of `pcm[from, from+count)` at `sampleRate`. Cost is bounded
// by the constants above, not by the span length: at most kPeriodDetectProbes blocks of ~2 x
// the longest searched lag are analysed however long the span is. Allocates; never call from
// process(). An out-of-range span estimates nothing and returns none.
//
// 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.
//
// Two probes or more must reach a STRICT MAJORITY agreement. A lone probe — which only happens
// on a span too short to host a second probe position — is accepted on the absolute threshold
// alone, because there is no second estimate for a majority rule to rule on and refusing would
// deny every short one-shot a period.
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
std::size_t from, std::size_t count);
// The whole source.
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate);
// The frames detection should analyse for a capture that carries a sustain loop, and the reason
// the answer is not simply "all of them": under Gate the loop region is asymptotically ALL the
// splicer plays, so a phrase whose head is pitched differently from its sustain would otherwise
// disagree its way to none over the whole source. `[loopStart, loopEnd)` is used only when it
// is at least `2 * (sampleRate / kPeriodDetectMinHz)` frames — the span below which detectPeriod
// starts shortening its own search band — so choosing the narrower span never costs search-band
// width and so can never lose a low fundamental that the whole source would have found.
// Anything else (no loop, an out-of-range span, a short one) yields the whole source.
//
// The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is
// on a cache input, not a second validity rule, and it refuses rather than repairs the same way.
struct AnalysisSpan {
std::size_t from = 0;
std::size_t count = 0;
};
AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart,
std::int64_t loopEnd, bool hasLoop, int sampleRate);
} // namespace reasampler::instrument::engine