413 lines
20 KiB
C++
413 lines
20 KiB
C++
// 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.
|
|
// 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 <chrono>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <tuple>
|
|
#include <vector>
|
|
|
|
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<AudioSample> sineOfPeriod(std::size_t frames, double period,
|
|
double phase = 0.0) {
|
|
std::vector<AudioSample> s(frames);
|
|
for (std::size_t i = 0; i < frames; ++i) {
|
|
s[i] = static_cast<AudioSample>(
|
|
std::sin(2.0 * kPi * static_cast<double>(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<AudioSample> 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<double>(rate) / 30.0;
|
|
const std::vector<AudioSample> 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<AudioSample> 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<double>(i) / p) / h;
|
|
}
|
|
src[i] = static_cast<AudioSample>(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<AudioSample> 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<double>(i) / p);
|
|
}
|
|
src[i] = static_cast<AudioSample>(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<AudioSample> src(120000);
|
|
std::uint32_t rng = 22222u;
|
|
for (auto& x : src) {
|
|
rng = rng * 1664525u + 1013904223u;
|
|
x = static_cast<AudioSample>((static_cast<double>(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<AudioSample> 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<AudioSample> 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<AudioSample>(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<AudioSample> 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<double>(rng >> 8) / 8388608.0) - 1.0;
|
|
lp += 0.25 * (n - lp);
|
|
const double env = std::exp(-static_cast<double>(i % 22050) / 2000.0);
|
|
src[i] = static_cast<AudioSample>(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<AudioSample> 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<AudioSample> 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 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.
|
|
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 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);
|
|
|
|
// 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);
|
|
}
|
|
|
|
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 * static_cast<std::size_t>(rate / kPeriodDetectMinHz);
|
|
|
|
// 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},
|
|
{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<std::int64_t>(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<std::int64_t>(minimum), true, rate);
|
|
CHECK(atMinimum.from == 60000 && atMinimum.count == minimum);
|
|
|
|
// 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 double p = static_cast<double>(rate) / 30.0;
|
|
const std::vector<AudioSample> src = sineOfPeriod(frames, p);
|
|
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<double>(rate) / 30.0; // 1470 frames
|
|
|
|
std::vector<AudioSample> src(frames);
|
|
double phase = 0.0;
|
|
for (std::size_t i = 0; i < frames; ++i) {
|
|
phase += 2.0 * kPi /
|
|
(i < static_cast<std::size_t>(loopStart) ? headPeriod : loopPeriod);
|
|
src[i] = static_cast<AudioSample>(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<std::int64_t>(frames), true, rate);
|
|
CHECK(span.from == static_cast<std::size_t>(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<AudioSample> noisyLoop = src;
|
|
std::uint32_t rng = 777u;
|
|
for (std::size_t i = static_cast<std::size_t>(loopStart); i < frames; ++i) {
|
|
rng = rng * 1664525u + 1013904223u;
|
|
noisyLoop[i] = static_cast<AudioSample>((static_cast<double>(rng >> 8) / 8388608.0) - 1.0);
|
|
}
|
|
CHECK(!detectPeriod(noisyLoop, rate, span.from, span.count).valid());
|
|
}
|
|
|
|
static void testAnOutOfRangeSpanEstimatesNothing() {
|
|
const std::vector<AudioSample> 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<AudioSample> 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<double>(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<double>(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();
|
|
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;
|
|
}
|