// Standalone tests for reasampler::instrument::engine::detectPeriod — the offline source-period // estimate behind Preserve's pitch-synchronous splices. No VST3, no REAPER, no test framework. // // Covers: // 1. accuracy on pure tones across the searched band, at 44.1k and 48k, including the // non-integer periods every real capture actually has — the splice jump is n periods, so // a fractional-frame error lands multiplied by n. // 2. the fundamental, not a harmonic: a sawtooth and a missing-fundamental stack must both // report the repeat period, which is what a splice has to align on. // 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, 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. #include "../src/core/instrument/engine/period_detect.h" #include #include #include #include #include #include #include using namespace reasampler; using namespace reasampler::instrument::engine; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) constexpr double kPi = 3.14159265358979323846; static std::vector sineOfPeriod(std::size_t frames, double period, double phase = 0.0) { std::vector s(frames); for (std::size_t i = 0; i < frames; ++i) { s[i] = static_cast( std::sin(2.0 * kPi * static_cast(i) / period + phase)); } return s; } // --- 1. Accuracy on pure tones ------------------------------------------------------------- static void testPureTonePeriodIsFoundToBetterThanATenthOfAFrame() { // Deliberately non-integer periods: an integer-only estimator passes an integer-period // sweep and still misaligns every real capture. const double periods[] = {23.7, 50.0, 100.25, 200.45, 441.0, 999.9, 1470.0, 2000.3, 2756.0}; for (double p : periods) { const std::vector src = sineOfPeriod(120000, p); const PeriodEstimate est = detectPeriod(src, 44100); CHECK(est.valid()); if (!est.valid()) { std::printf(" period %.2f: NOT DETECTED\n", p); continue; } const double errFrames = std::fabs(est.frames - p); std::printf(" period %8.2f -> %8.4f (err %.4f fr, conf %.3f)\n", p, est.frames, errFrames, est.confidence); CHECK(errFrames < 0.1); CHECK(est.confidence > 0.8); } } static void testTheEstimateIsInSourceFramesSoTheRateOnlyMovesTheBand() { // The same 30 Hz tone at two rates: the answer is frames, so it must track the rate. This // is what lets the shifter compare it against a window that is also in frames. for (int rate : {44100, 48000}) { const double p = static_cast(rate) / 30.0; const std::vector src = sineOfPeriod(160000, p); const PeriodEstimate est = detectPeriod(src, rate); CHECK(est.valid()); if (est.valid()) { std::printf(" 30 Hz @ %d: %.3f fr (want %.3f)\n", rate, est.frames, p); CHECK(std::fabs(est.frames - p) < 0.5); } } } // --- 2. The fundamental, not a harmonic ---------------------------------------------------- static void testHarmonicRichSourceReportsTheRepeatPeriodNotAPartial() { // A sawtooth's strongest correlation dips at EVERY multiple of its period; a global-minimum // estimator picks 2P or 3P about as often as P. YIN's first-dip rule is what makes this // pass, and a jump quantized to 2P would splice a whole cycle out of phase half the time. const double p = 512.0; std::vector src(120000); for (std::size_t i = 0; i < src.size(); ++i) { double v = 0.0; for (int h = 1; h <= 12; ++h) { v += std::sin(2.0 * kPi * h * static_cast(i) / p) / h; } src[i] = static_cast(0.5 * v); } const PeriodEstimate est = detectPeriod(src, 44100); CHECK(est.valid()); if (est.valid()) { std::printf(" sawtooth P=512 -> %.3f\n", est.frames); CHECK(std::fabs(est.frames - p) < 1.0); } } static void testMissingFundamentalStillReportsTheRepeatPeriod() { // Partials 2..6 of a 700-frame period: there is no energy AT the fundamental, but the // waveform still repeats every 700 frames — and repetition, not spectral content, is what // a splice has to land on. const double p = 700.0; std::vector src(120000); for (std::size_t i = 0; i < src.size(); ++i) { double v = 0.0; for (int h = 2; h <= 6; ++h) { v += std::sin(2.0 * kPi * h * static_cast(i) / p); } src[i] = static_cast(0.2 * v); } const PeriodEstimate est = detectPeriod(src, 44100); CHECK(est.valid()); if (est.valid()) { std::printf(" missing fundamental P=700 -> %.3f\n", est.frames); CHECK(std::fabs(est.frames - p) < 2.0); } } // --- 3. Graceful degradation --------------------------------------------------------------- static void testNoiseSilenceAndAPeriodChangeAllReportNone() { // White noise: no dip below the absolute threshold anywhere. { std::vector src(120000); std::uint32_t rng = 22222u; for (auto& x : src) { rng = rng * 1664525u + 1013904223u; x = static_cast((static_cast(rng >> 8) / 8388608.0) - 1.0); } const PeriodEstimate est = detectPeriod(src, 44100); std::printf(" white noise -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none", est.frames); CHECK(!est.valid()); } // Digital silence: the difference function is degenerate, not merely inconclusive. { const std::vector src(120000, 0.0f); CHECK(!detectPeriod(src, 44100).valid()); } // Two halves at genuinely different periods: the probes disagree, so there is no ONE // period, and reporting either half's would misalign every splice in the other half. { std::vector src(160000); double phase = 0.0; for (std::size_t i = 0; i < src.size(); ++i) { phase += 2.0 * kPi / (i < 80000 ? 300.0 : 700.0); src[i] = static_cast(std::sin(phase)); } const PeriodEstimate est = detectPeriod(src, 44100); std::printf(" period change 300->700 -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none", est.frames); CHECK(!est.valid()); } // Degenerate inputs. CHECK(!detectPeriod({}, 44100).valid()); CHECK(!detectPeriod(sineOfPeriod(120000, 441.0), 0).valid()); } static void testAPercussiveDecayIsNotForcedIntoAPeriod() { // Filtered noise with a fast decay — the shape of a one-shot drum hit. Nothing repeats, so // the answer must be none rather than whatever the envelope's own length looks like. std::vector src(120000); std::uint32_t rng = 909090u; double lp = 0.0; for (std::size_t i = 0; i < src.size(); ++i) { rng = rng * 1664525u + 1013904223u; const double n = (static_cast(rng >> 8) / 8388608.0) - 1.0; lp += 0.25 * (n - lp); const double env = std::exp(-static_cast(i % 22050) / 2000.0); src[i] = static_cast(lp * env); } const PeriodEstimate est = detectPeriod(src, 44100); std::printf(" percussive decay -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none", est.frames); CHECK(!est.valid()); } // --- 4. Band edges and short sources ------------------------------------------------------- static void testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple() { // Below kPeriodDetectMinHz: none. This is the load-bearing edge — such a period cannot fit // the splice jump anyway, so an answer here would only be one the shifter must reject. const std::vector low = sineOfPeriod(200000, 44100.0 / 8.0); // 8 Hz CHECK(!detectPeriod(low, 44100).valid()); // Above kPeriodDetectMaxHz the search floor sits well above the true period, so what comes // back is a WHOLE MULTIPLE of it — which is still an exactly aligned splice target, since // every multiple of a period is a period. That is why the high edge needs no special // handling: being outside the band costs nothing, because alignment was never in question // for a tone this short-period. const double p = 44100.0 / 6000.0; // 7.35 frames const PeriodEstimate high = detectPeriod(sineOfPeriod(120000, p), 44100); std::printf(" 6 kHz (P=%.3f) -> %s (%.3f, = %.3f periods)\n", p, high.valid() ? "detected" : "none", high.frames, high.frames / p); if (high.valid()) { const double n = high.frames / p; CHECK(std::fabs(n - std::floor(n + 0.5)) < 0.02); } } static void testAShortSourceShortensTheSearchRatherThanRefusing() { // A 12000-frame one-shot cannot host a full-band probe; the search band shortens to fit and // a 200-frame period is still found. Below that the answer is none, not a guess. const std::vector shortSrc = sineOfPeriod(12000, 200.0); const PeriodEstimate est = detectPeriod(shortSrc, 44100); std::printf(" 12000-frame source, P=200 -> %s (%.3f)\n", est.valid() ? "detected" : "none", est.frames); CHECK(est.valid()); if (est.valid()) CHECK(std::fabs(est.frames - 200.0) < 0.5); // Too short for even the minimum lag: none. CHECK(!detectPeriod(sineOfPeriod(40, 20.0), 44100).valid()); } // 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 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(rate) / 30.0; // 1470 frames 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); // 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(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(n) / static_cast(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(mult * static_cast(lagHi)); std::vector 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(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(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(rate) / 30.0; for (std::size_t lead : {lagHi, 2 * lagHi, 4 * lagHi}) { std::vector src(n, 0.0f); for (std::size_t i = lead; i < n; ++i) { src[i] = static_cast( std::sin(2.0 * kPi * static_cast(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 burst(n); std::uint32_t rng = 4242u; for (auto& x : burst) { rng = rng * 1664525u + 1013904223u; x = static_cast((static_cast(rng >> 8) / 8388608.0) - 1.0); } for (std::size_t i = 0; i < 2 * lagHi; ++i) { burst[i] = static_cast(std::sin(2.0 * kPi * static_cast(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() { // 30 Hz and 29 Hz — the pair the Preserve geometry work is measured against. 29 Hz is the // sharper case: its period does not divide the splice window, so the shifter needs the // detected value to be right rather than merely present. for (double hz : {30.0, 29.0}) { const double p = 44100.0 / hz; const PeriodEstimate est = detectPeriod(sineOfPeriod(160000, p), 44100); std::printf(" %.0f Hz -> %s (%.3f, want %.3f)\n", hz, est.valid() ? "detected" : "NONE", est.frames, p); CHECK(est.valid()); if (est.valid()) CHECK(std::fabs(est.frames - p) < 0.5); } } // --- 5. The analysis span ------------------------------------------------------------------- static void testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand() { const int rate = 44100; 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 * 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{0, 0, false}, {60000, 120000, false}, {90000, 90000, true}, {90000, 80000, true}, {-1, 90000, true}, {60000, 130000, true}}) { const AnalysisSpan s = periodAnalysisSpan(frames, lo, hi, has, rate); CHECK(s.from == 0 && s.count == frames); } // A loop one frame under the minimum falls back to the WIDER span, not to none. const AnalysisSpan shortLoop = periodAnalysisSpan(frames, 60000, 60000 + static_cast(minimum) - 1, true, rate); CHECK(shortLoop.from == 0 && shortLoop.count == frames); // At the minimum exactly, the loop is taken. const AnalysisSpan atMinimum = periodAnalysisSpan(frames, 60000, 60000 + static_cast(minimum), true, rate); CHECK(atMinimum.from == 60000 && atMinimum.count == minimum); const double p = static_cast(rate) / 30.0; const std::vector src = sineOfPeriod(frames, p); // The span IS taken at the minimum, but that alone doesn't say detection BEHAVES there: at // exactly one probe block, detectPeriod takes the lone-probe carve-out (no agreement check // at all) — the span choice and the accept rule meet at this exact boundary, and that // meeting point is what needs to actually detect, not just be selected. const PeriodEstimate atMin = detectPeriod(src, rate, atMinimum.from, atMinimum.count); std::printf(" loop at the minimum span -> %s (%.3f, want %.3f)\n", atMin.valid() ? "detected" : "NONE", atMin.frames, p); CHECK(atMin.valid()); if (atMin.valid()) CHECK(std::fabs(atMin.frames - p) < 0.5); // And the too-short loop still DETECTS through the wider span — refusing there would be a // regression against analysing the whole source, and a short sustain loop is common. const PeriodEstimate est = detectPeriod(src, rate, shortLoop.from, shortLoop.count); std::printf(" short loop -> whole source: %s (%.3f)\n", est.valid() ? "detected" : "NONE", est.frames); CHECK(est.valid()); if (est.valid()) CHECK(std::fabs(est.frames - p) < 0.5); } static void testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop() { // The case the whole-source analysis cannot answer: the head sustains one pitch, the looped // tail another. Analysed whole, two probes land each side and the strict-majority rule // correctly refuses — there is no ONE period over the whole source. But under Gate the // splicer lives in the loop, whose period is perfectly well defined. const int rate = 44100; const std::size_t frames = 120000; const std::int64_t loopStart = 60000; const double headPeriod = 300.0; const double loopPeriod = static_cast(rate) / 30.0; // 1470 frames std::vector src(frames); double phase = 0.0; for (std::size_t i = 0; i < frames; ++i) { phase += 2.0 * kPi / (i < static_cast(loopStart) ? headPeriod : loopPeriod); src[i] = static_cast(std::sin(phase)); } // BEFORE this rule: the whole source is what was analysed, and it reports none. const PeriodEstimate whole = detectPeriod(src, rate); std::printf(" phrase analysed whole -> %s (%.3f)\n", whole.valid() ? "DETECTED" : "none", whole.frames); CHECK(!whole.valid()); // AFTER: the loop is long enough to host the full band, so it is the analysed span. const AnalysisSpan span = periodAnalysisSpan(frames, loopStart, static_cast(frames), true, rate); CHECK(span.from == static_cast(loopStart)); const PeriodEstimate looped = detectPeriod(src, rate, span.from, span.count); std::printf(" phrase analysed over its loop -> %s (%.3f, want %.3f)\n", looped.valid() ? "detected" : "NONE", looped.frames, loopPeriod); CHECK(looped.valid()); if (looped.valid()) CHECK(std::fabs(looped.frames - loopPeriod) < 2.0); // The narrowed span must not turn a genuinely aperiodic loop into a period: same geometry, // noise in the loop region. std::vector noisyLoop = src; std::uint32_t rng = 777u; for (std::size_t i = static_cast(loopStart); i < frames; ++i) { rng = rng * 1664525u + 1013904223u; noisyLoop[i] = static_cast((static_cast(rng >> 8) / 8388608.0) - 1.0); } CHECK(!detectPeriod(noisyLoop, rate, span.from, span.count).valid()); } static void testAnOutOfRangeSpanEstimatesNothing() { const std::vector src = sineOfPeriod(120000, 441.0); CHECK(!detectPeriod(src, 44100, 120001, 10).valid()); CHECK(!detectPeriod(src, 44100, 119000, 5000).valid()); CHECK(!detectPeriod(src, 44100, 0, 0).valid()); } // --- 6. What the load pays ------------------------------------------------------------------ // The whole reason a detector is affordable in a sampler is that it runs ONCE, off the audio // thread, on a source that is already fully known. This prints what that once costs, and // asserts the property that makes it safe: the cost does NOT grow with the sample length — // a fixed number of fixed-size probes is analysed however long the capture is. Meaningful // only in a Release build; asserted as a RATIO so it holds at either optimization level. static void testDetectionCostIsBoundedRegardlessOfSampleLength() { double shortMs = 0.0, longMs = 0.0; for (std::size_t frames : {std::size_t{220500}, std::size_t{4410000}}) { // 5 s and 100 s const std::vector src = sineOfPeriod(frames, 441.0); const int reps = 5; const auto t0 = std::chrono::steady_clock::now(); double guard = 0.0; for (int r = 0; r < reps; ++r) guard += detectPeriod(src, 44100).frames; const double ms = 1000.0 * std::chrono::duration(std::chrono::steady_clock::now() - t0).count() / reps; CHECK(guard > 0.0); std::printf(" [measure] detectPeriod over %7.1f s of source: %.3f ms\n", static_cast(frames) / 44100.0, ms); (frames == 220500 ? shortMs : longMs) = ms; } // 20x the source for well under 2x the cost — the probes are fixed-size and fixed in // number, so the only length dependence left is the cache behaviour of reaching further // into the buffer. CHECK(longMs < shortMs * 2.0 + 0.5); } int main() { testPureTonePeriodIsFoundToBetterThanATenthOfAFrame(); testTheEstimateIsInSourceFramesSoTheRateOnlyMovesTheBand(); testHarmonicRichSourceReportsTheRepeatPeriodNotAPartial(); testMissingFundamentalStillReportsTheRepeatPeriod(); testNoiseSilenceAndAPeriodChangeAllReportNone(); testAPercussiveDecayIsNotForcedIntoAPeriod(); testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple(); testAShortSourceShortensTheSearchRatherThanRefusing(); testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe(); testAStationarySourceDetectsAtEveryLengthAcrossTheProbeCountSteps(); testAPeriodChangeIsRefusedWhereTheProbesOverlapToo(); testSilenceIsNotDissentButAnAbsentPeriodIs(); testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected(); testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand(); testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop(); testAnOutOfRangeSpanEstimatesNothing(); testDetectionCostIsBoundedRegardlessOfSampleLength(); if (g_fail == 0) { std::printf("all period_detect tests passed\n"); return 0; } std::printf("%d period_detect check(s) failed\n", g_fail); return 1; }