Preserve's period detection: probes are placed by position, and a sustain loop is the span analysed
This commit is contained in:
@@ -10,15 +10,19 @@
|
||||
// 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.
|
||||
// 5. what the load pays, and that it does not grow with the sample length.
|
||||
// 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;
|
||||
@@ -217,7 +221,144 @@ static void testAShortSourceShortensTheSearchRatherThanRefusing() {
|
||||
CHECK(!detectPeriod(sineOfPeriod(40, 20.0), 44100).valid());
|
||||
}
|
||||
|
||||
// --- 5. What the load pays ------------------------------------------------------------------
|
||||
// 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
|
||||
@@ -255,6 +396,11 @@ int main() {
|
||||
testAPercussiveDecayIsNotForcedIntoAPeriod();
|
||||
testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple();
|
||||
testAShortSourceShortensTheSearchRatherThanRefusing();
|
||||
testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe();
|
||||
testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected();
|
||||
testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand();
|
||||
testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop();
|
||||
testAnOutOfRangeSpanEstimatesNothing();
|
||||
testDetectionCostIsBoundedRegardlessOfSampleLength();
|
||||
|
||||
if (g_fail == 0) {
|
||||
|
||||
@@ -926,6 +926,45 @@ static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() {
|
||||
CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz
|
||||
}
|
||||
|
||||
// The span half of the same wire: buildSampleData must hand detection the LOOP region when the
|
||||
// capture carries one, not the whole decoded PCM. Asserted through the real build for the same
|
||||
// reason as the test above — period_detect's own coverage cannot see which span the loader picks.
|
||||
static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() {
|
||||
const int rate = 44100;
|
||||
const std::size_t frames = 120000;
|
||||
const std::int64_t loopStart = 60000;
|
||||
const double kPi = 3.14159265358979323846;
|
||||
const double loopPeriod = static_cast<double>(rate) / 30.0; // 1470 frames
|
||||
|
||||
// Head at 147 Hz, looped tail at 30 Hz: analysed whole, the probes split two-and-two and
|
||||
// detection correctly refuses. Analysed over the loop, the 30 Hz sustain is unambiguous.
|
||||
std::vector<AudioSample> pcm(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) ? 300.0 : loopPeriod);
|
||||
pcm[i] = static_cast<float>(std::sin(phase));
|
||||
}
|
||||
|
||||
InstrumentParams noLoop;
|
||||
const SampleData bare = buildSampleData(resolveCapture(ref("b/a.wav", 60), noLoop),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
CHECK(bare.sourcePeriodFrames == 0.0); // no loop -> whole source -> no ONE period
|
||||
|
||||
InstrumentParams looped;
|
||||
looped.loopOverride = SampleLoop{true, loopStart, static_cast<std::int64_t>(frames)};
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), looped),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
CHECK(std::fabs(sd.sourcePeriodFrames - loopPeriod) < 2.0);
|
||||
|
||||
// A loop too short to host the full search band falls back to the whole source rather than
|
||||
// to none — here that whole source has no one period, so the answer is the bare one above.
|
||||
InstrumentParams shortLoop;
|
||||
shortLoop.loopOverride = SampleLoop{true, 118000, static_cast<std::int64_t>(frames)};
|
||||
const SampleData shortSd = buildSampleData(resolveCapture(ref("b/a.wav", 60), shortLoop),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
CHECK(shortSd.sourcePeriodFrames == bare.sourcePeriodFrames);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataCarriesTheVelocityCurve() {
|
||||
InstrumentParams p;
|
||||
p.velocityCurve = VelocityCurve::linear();
|
||||
@@ -994,6 +1033,7 @@ int main() {
|
||||
testBuildSampleDataEmptyPcmIsUnplayable();
|
||||
testBuildSampleDataCarriesTheVelocityCurve();
|
||||
testBuildSampleDataDetectsThirtyHertzSourcePeriod();
|
||||
testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource();
|
||||
|
||||
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
|
||||
Reference in New Issue
Block a user