#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 #include #include #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) // 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 only reader is // tests/test_period_detect.cpp. 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; } }; // 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; // The longest lag searched, in frames — THE one derivation of it. A probe block is twice this, // and `periodAnalysisSpan`'s minimum is one block; both read this rather than re-deriving the // same expression, so "choosing the loop never narrows the search band" is a fact and not a // coincidence between two literals. inline std::size_t longestLagFrames(int sampleRate) { return static_cast(static_cast(sampleRate) / kPeriodDetectMinHz); } // 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. inline constexpr int kPeriodDetectProbes = 4; inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median // 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. // // A STRICT MAJORITY of the probes that CARRIED SIGNAL must agree. Silence is excluded from that // denominator and a failure to find a period is not: a silent block is no evidence either way, // whereas a block that carries signal and repeats at no lag is evidence against a single period. // A capture with a silent head or tail therefore still detects, while a mostly-noise source with // one pitched burst is refused rather than accepted on that burst alone. A LONE piece of // evidence — the whole span too short for a second probe position, or every other probe silent — // is accepted on the absolute threshold alone, because there is nothing to rule against it and // refusing would deny every short one-shot a period. // // The answer is NOT monotone in span length, and cannot be made so: no rule that refuses a // two-and-two split at four probes can also accept a lone probe unconditionally, and the probe // count steps at 3x, 4x, 5x and 6x the longest lag before saturating. What IS pinned, by a // length sweep in the tests, is that a STATIONARY source detects at every length — a source // whose period varies by more than kPeriodDetectAgreeTolerance is the only class that moves // with the count, and refusing it is this contract's own answer. PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, std::size_t from, std::size_t count); // The whole source. PeriodEstimate detectPeriod(const std::vector& 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 one full probe block — `2 * longestLagFrames(sampleRate)`, the span below which // detectPeriod starts shortening its own search band — so choosing the narrower span never costs // search-band WIDTH. It can still change the ANSWER: the agreement rule rules on content, so a // source periodic over most of its length whose loop region is noisy detects whole and refuses // over the loop. That is the intent — the loop is what a Gate voice plays. // Anything else (no loop, an out-of-range span, a short one) yields the whole source. // // It takes NO play mode, deliberately, even though loop_span's resolveLoop does and refuses the // loop outright under Trigger. A loop edit is structurally reload-bound — it moves the PCM span // this cache was derived from — whereas play mode's exclusion from live delivery is a listed, // reversible decision (deck_groups' isLiveDeckParam). Keying a load-time cache on it would work // today and silently serve a stale period the day that decision is revisited. // // 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