Period detection: silence is not dissent but an absent period is — the agreement denominator is the probes that carried signal

This commit is contained in:
2026-08-02 04:19:59 -04:00
parent cc4967d21d
commit 91bd6f51a2
4 changed files with 195 additions and 58 deletions
+27 -20
View File
@@ -125,7 +125,7 @@ PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
if (sampleRate <= 0 || spanCount == 0) return {};
if (spanFrom > pcm.size() || spanCount > pcm.size() - spanFrom) return {};
const double rate = static_cast<double>(sampleRate);
std::size_t lagHi = static_cast<std::size_t>(rate / kPeriodDetectMinHz);
std::size_t lagHi = longestLagFrames(sampleRate);
const std::size_t lagLo = static_cast<std::size_t>(rate / kPeriodDetectMaxHz);
if (lagLo < 2) return {}; // a rate so low the whole search band collapses
@@ -145,10 +145,14 @@ PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
std::vector<double> periods;
std::vector<double> confidences;
// Probes that carried signal — the agreement denominator. A silent block is no evidence
// either way and is excluded; every other outcome, a period found or not, is evidence.
std::size_t evidence = 0;
for (std::size_t p = 0; p < probes; ++p) {
// (probes - 1) * stride <= room by construction, so the last block always fits.
const std::size_t from = spanFrom + p * stride;
if (from + block > spanFrom + spanCount) break;
if (blockRms(pcm, from, block) < kSilenceRms) continue;
++evidence;
const std::vector<double> small = decimate(pcm, from, block);
const std::size_t smallHi = lagHi / kDecimate;
@@ -177,16 +181,17 @@ PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
if (periods.empty()) return {};
// ONE surviving probe: the span could not host a second probe position, so there is no
// second estimate for the majority rule below to rule on — it would be deciding on an
// empty comparison. The accept rests on pickPeriod's absolute threshold, which is a real
// test and not an absence of one: the block genuinely repeats at this lag across its whole
// analysis window. Refusing instead would deny every short one-shot a period, and a period
// that turns out wrong costs a mis-centred correlation search at the splice, not an
// unrefined one (pitch_shift.cpp's splice searches +/- maxLag around whichever jump it is
// handed). Do not "unify" this back into the majority test — at size 1 that test accepts
// unconditionally, which is the same behaviour with none of the reasoning.
if (periods.size() == 1) {
// ONE piece of evidence in the whole span — either it hosted a single probe position, or
// every other probe was silent. Nothing can rule against this estimate, so the accept rests
// on pickPeriod's absolute threshold, which is a real test and not an absence of one: the
// block genuinely repeats at this lag across its whole analysis window. Refusing instead
// would deny every short one-shot a period, and a period that turns out wrong costs a
// mis-centred correlation search at the splice, not an unrefined one (pitch_shift.cpp's
// splice searches +/- maxLag around whichever jump it is handed). Do not "unify" this back
// into the majority test — at one piece of evidence that test accepts unconditionally, which
// is the same behaviour with none of the reasoning. Nor key it on how many probes SURVIVED:
// one survivor out of four that all carried signal is not this case at all.
if (evidence == 1) {
PeriodEstimate lone;
lone.frames = periods[0];
lone.confidence = confidences[0];
@@ -208,12 +213,15 @@ PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
confSum += confidences[i];
++agree;
}
// A STRICT MAJORITY of the valid probes must agree, not merely two of them: a source whose
// first half is one period and second half another gives two probes each way, and taking
// either as "the" period would misalign every splice in the other half. Refusing is the
// right answer there — the fixed-window fallback is what a source with no ONE period gets.
// Reached only with two or more probes; the lone-probe case returned above.
if (agree * 2 <= periods.size()) return {};
// A STRICT MAJORITY of the probes that carried signal must agree, not merely two of them: a
// source whose first half is one period and second half another gives two probes each way,
// and taking either as "the" period would misalign every splice in the other half. Refusing
// is the right answer there — the fixed-window fallback is what a source with no ONE period
// gets. The denominator is `evidence` and not `periods.size()` because once probes overlap
// a straddling block finds no period at all rather than a third one, and counting only the
// survivors turned that two-and-two split into a two-of-three accept.
// Reached only with two or more pieces of evidence; the lone case returned above.
if (agree * 2 <= evidence) return {};
PeriodEstimate est;
est.frames = sum / static_cast<double>(agree);
@@ -235,8 +243,7 @@ AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart,
const std::size_t length = static_cast<std::size_t>(loopEnd - loopStart);
// One full probe block. Below it detectPeriod shortens lagHi to fit, which raises the
// lowest findable fundamental — the one thing the narrower span may never cost.
const std::size_t minimum =
2 * static_cast<std::size_t>(static_cast<double>(sampleRate) / kPeriodDetectMinHz);
const std::size_t minimum = 2 * longestLagFrames(sampleRate);
if (length < minimum) return whole;
return AnalysisSpan{static_cast<std::size_t>(loopStart), length};
}
+37 -12
View File
@@ -21,8 +21,8 @@ using audio::AudioSample;
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 consumers are
// the tests and the measurement harness. It is deliberately NOT a second accept gate: every
// 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.
@@ -44,6 +44,14 @@ inline constexpr double kPeriodDetectMaxHz = 2000.0;
// 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<std::size_t>(static_cast<double>(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.
@@ -53,8 +61,6 @@ inline constexpr double kPeriodDetectThreshold = 0.12;
// 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
@@ -66,10 +72,21 @@ inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median
// 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.
// 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<AudioSample>& pcm, int sampleRate,
std::size_t from, std::size_t count);
@@ -79,12 +96,20 @@ 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.
// 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 {