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
+127 -24
View File
@@ -10,7 +10,8 @@
// 3. graceful degradation — noise, silence, and a source whose period changes mid-sample all
// return NONE. That is the contract the shifter's fixed-window fallback rests on: an
// estimate that is merely wrong would misalign every splice, which is worse than none.
// 4. the band edges and the short-sample path, including the lone-probe accept.
// 4. the band edges and the short-sample path, including the lone-probe accept, the length
// sweep across every probe-count step, and what counts as evidence against a period.
// 5. the analysis span: a sustain loop stands in for the whole source, but never at the cost
// of search-band width.
// 6. what the load pays, and that it does not grow with the sample length.
@@ -221,32 +222,118 @@ static void testAShortSourceShortensTheSearchRatherThanRefusing() {
CHECK(!detectPeriod(sineOfPeriod(40, 20.0), 44100).valid());
}
// A lone probe is the one case the strict-majority rule cannot rule on, so pin BOTH halves of
// the carve-out: which sources land in it, and that they are accepted rather than refused.
// 30 Hz is first-class material here, and a short low-frequency source is exactly where the
// blunt "require two probes" fix would have silently stopped detecting.
// A lone piece of evidence is the one case the strict-majority rule cannot rule on, so pin both
// halves of the carve-out: which sources land in it, and that they are accepted rather than
// refused. 30 Hz is first-class material here, and a short low-frequency source is exactly where
// the blunt "require two probes" fix would have silently stopped detecting.
static void testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe() {
const int rate = 44100;
const std::size_t lagHi = static_cast<std::size_t>(rate / kPeriodDetectMinHz);
const std::size_t block = 2 * lagHi;
// Derive the frame count from the public constants rather than hardcoding one, so this test
// keeps naming the lone-probe case if the geometry ever moves. One probe fits while the
// span leaves less than lagHi of room after the first block.
const std::size_t frames = block + lagHi - 1; // 8819 at 44.1k -> exactly one probe
CHECK(1 + (frames - block) / lagHi == 1);
const std::size_t block = 2 * longestLagFrames(rate);
// Exactly one probe BLOCK leaves zero room to place a second probe anywhere, whatever
// separation the placement uses — so this names the lone-probe case independently of the
// formula, where a length derived from that formula would only re-assert it.
const double p = static_cast<double>(rate) / 30.0; // 1470 frames
const PeriodEstimate est = detectPeriod(sineOfPeriod(frames, p), rate);
std::printf(" lone probe, %zu frames @ 30 Hz -> %s (%.3f, want %.3f)\n", frames,
est.valid() ? "detected" : "NONE", est.frames, p);
CHECK(est.valid());
if (est.valid()) CHECK(std::fabs(est.frames - p) < 1.0);
const PeriodEstimate lone = detectPeriod(sineOfPeriod(block, p), rate);
std::printf(" lone probe, %zu frames @ 30 Hz -> %s (%.3f, want %.3f)\n", block,
lone.valid() ? "detected" : "NONE", lone.frames, p);
CHECK(lone.valid());
if (lone.valid()) CHECK(std::fabs(lone.frames - p) < 1.0);
// One frame more buys a second probe position; the answer must not change character.
const PeriodEstimate two = detectPeriod(sineOfPeriod(frames + 1, p), rate);
CHECK(1 + (frames + 1 - block) / lagHi == 2);
CHECK(two.valid());
if (two.valid()) CHECK(std::fabs(two.frames - p) < 1.0);
// Growing the span past the lone case must not turn the answer off, and must not change it:
// a probe-count boundary is not allowed to be a discontinuity in what a stationary source
// reports. Asserted over the answer rather than over the count, so it survives the placement
// rule moving.
for (std::size_t extra : {std::size_t{1}, block / 4, block / 2, block, 2 * block}) {
const PeriodEstimate more = detectPeriod(sineOfPeriod(block + extra, p), rate);
CHECK(more.valid());
if (more.valid()) CHECK(std::fabs(more.frames - p) < 1.0);
}
}
// The acceptance property behind the position-placement change: a STATIONARY source must not
// lose detection at any length. The probe count steps at 3x, 4x, 5x and 6x the longest lag and
// the agreement bar steps with it, so a length sweep is the only thing that pins the whole
// band — a single 160 000-frame case sits above every step and cannot see them.
static void testAStationarySourceDetectsAtEveryLengthAcrossTheProbeCountSteps() {
const int rate = 44100;
const std::size_t lagHi = longestLagFrames(rate);
int refused = 0;
for (double hz : {30.0, 29.0, 55.0}) {
const double p = static_cast<double>(rate) / hz;
for (std::size_t n = 2 * lagHi; n <= 12 * lagHi; n += lagHi / 4) {
const PeriodEstimate est = detectPeriod(sineOfPeriod(n, p), rate);
if (!est.valid() || std::fabs(est.frames - p) > 1.0) {
++refused;
std::printf(" %.0f Hz at %zu frames (%.2fx lagHi): %s (%.3f)\n", hz, n,
static_cast<double>(n) / static_cast<double>(lagHi),
est.valid() ? "wrong" : "NONE", est.frames);
}
}
}
CHECK(refused == 0);
}
// The regime the position-placement change actually moved: probes overlap only while the stride
// is under one block, i.e. below 8x the longest lag. A straddling block finds NO period rather
// than a third one, so counting only the probes that survived turned a genuine two-and-two split
// into a two-of-three accept — a source with two periods reported as having the first one, which
// is the outcome the module calls worse than none.
static void testAPeriodChangeIsRefusedWhereTheProbesOverlapToo() {
const int rate = 44100;
const std::size_t lagHi = longestLagFrames(rate);
for (double mult : {3.0, 4.0, 5.0, 6.0, 7.0}) {
const std::size_t n = static_cast<std::size_t>(mult * static_cast<double>(lagHi));
std::vector<AudioSample> src(n);
double phase = 0.0;
for (std::size_t i = 0; i < n; ++i) {
phase += 2.0 * kPi / (i < n / 2 ? 300.0 : 700.0);
src[i] = static_cast<AudioSample>(std::sin(phase));
}
const PeriodEstimate est = detectPeriod(src, rate);
std::printf(" period change over %.0fx lagHi (%zu frames, %.2f s) -> %s (%.3f)\n", mult,
n, static_cast<double>(n) / rate, est.valid() ? "DETECTED" : "none",
est.frames);
CHECK(!est.valid());
}
}
// Silence and an absent period are NOT the same evidence, and the agreement denominator has to
// tell them apart: a capture with a silent head still detects, while a source that is mostly
// aperiodic with one pitched burst must not be accepted on that burst alone.
static void testSilenceIsNotDissentButAnAbsentPeriodIs() {
const int rate = 44100;
const std::size_t lagHi = longestLagFrames(rate);
const std::size_t n = 10 * lagHi;
const double p = static_cast<double>(rate) / 30.0;
for (std::size_t lead : {lagHi, 2 * lagHi, 4 * lagHi}) {
std::vector<AudioSample> src(n, 0.0f);
for (std::size_t i = lead; i < n; ++i) {
src[i] = static_cast<AudioSample>(
std::sin(2.0 * kPi * static_cast<double>(i - lead) / p));
}
const PeriodEstimate est = detectPeriod(src, rate);
std::printf(" %zu frames of leading silence -> %s (%.3f)\n", lead,
est.valid() ? "detected" : "NONE", est.frames);
CHECK(est.valid());
if (est.valid()) CHECK(std::fabs(est.frames - p) < 1.0);
}
// Noise everywhere but the opening: three probes carry signal and find no period, one finds
// one. Counting only the survivor accepted this on a single unopposed estimate.
std::vector<AudioSample> burst(n);
std::uint32_t rng = 4242u;
for (auto& x : burst) {
rng = rng * 1664525u + 1013904223u;
x = static_cast<AudioSample>((static_cast<double>(rng >> 8) / 8388608.0) - 1.0);
}
for (std::size_t i = 0; i < 2 * lagHi; ++i) {
burst[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * static_cast<double>(i) / p));
}
const PeriodEstimate est = detectPeriod(burst, rate);
std::printf(" noise with one pitched burst -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none",
est.frames);
CHECK(!est.valid());
}
static void testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected() {
@@ -270,7 +357,20 @@ static void testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand() {
const std::size_t frames = 120000;
// One full probe block — the span below which detectPeriod starts shortening its own
// longest lag, which is the only thing the narrower span may never cost.
const std::size_t minimum = 2 * static_cast<std::size_t>(rate / kPeriodDetectMinHz);
const std::size_t minimum = 2 * longestLagFrames(rate);
// An unusable rate and an empty capture take the same refuse-rather-than-repair path the
// loop-bounds branches do; nothing downstream may see a span it cannot analyse.
const AnalysisSpan noRate = periodAnalysisSpan(frames, 0, 90000, true, 0);
CHECK(noRate.from == 0 && noRate.count == frames);
const AnalysisSpan negRate = periodAnalysisSpan(frames, 0, 90000, true, -44100);
CHECK(negRate.from == 0 && negRate.count == frames);
const AnalysisSpan empty = periodAnalysisSpan(0, 0, 0, true, rate);
CHECK(empty.from == 0 && empty.count == 0);
// An empty capture with a loop still reaching past it refuses to the (empty) whole source
// rather than handing detection a span past the end of the PCM.
const AnalysisSpan emptyLooped = periodAnalysisSpan(0, 0, 44100, true, rate);
CHECK(emptyLooped.from == 0 && emptyLooped.count == 0);
// No loop, an inverted span, and a span reaching past the PCM all yield the whole source.
for (const auto& [lo, hi, has] : {std::tuple<std::int64_t, std::int64_t, bool>{0, 0, false},
@@ -397,6 +497,9 @@ int main() {
testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple();
testAShortSourceShortensTheSearchRatherThanRefusing();
testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe();
testAStationarySourceDetectsAtEveryLengthAcrossTheProbeCountSteps();
testAPeriodChangeIsRefusedWhereTheProbesOverlapToo();
testSilenceIsNotDissentButAnAbsentPeriodIs();
testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected();
testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand();
testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop();