diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h index d2dd645..c661dae 100644 --- a/src/core/instrument/engine/time_stretch.h +++ b/src/core/instrument/engine/time_stretch.h @@ -22,11 +22,15 @@ namespace reasampler::instrument::engine { // has less than one period to align against. Measured at rate 4.0, shift 0.25 (-24 st): // interval 2205/3.75 ~= 588 vs period ~4*P ~= 785 frames (P ~= 196) — matches the originally // observed 539-vs-785 failure. This range's ceiling (2.0, not 4.0) raises the safe floor, it -// does not remove it: at rate 2.0, shift 0.25, interval = 2205/1.75 = 1260 still fails for -// any source period P > 315 frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -// -24 st is reachable from the Pitch knob alone. (The pre-stretch rate-1.0 engine's floor by -// the same inequality is P > 735, ~60 Hz — what this range raises the floor from, not what -// it removes.) +// does not remove it: at rate 2.0, shift 0.25, interval = 2205/1.75 = 1260 still produces +// measurable splice debris for any source period P > 315 frames (~140 Hz at 44.1k) — inside +// bass/low-vocal material, and -24 st is reachable from the Pitch knob alone. pitch_shift_tests +// (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) asserts this corner directly at +// P=500/600/700: energy outside the fundamental runs 7-21% there against ~0% on an aligned +// control at the same rate/shift — zero-crossing period is NOT what it checks, since splice +// debris fools that estimator into reading the wrong period on a render whose fundamental is +// actually fine. (The pre-stretch rate-1.0 engine's floor by the same inequality is P > 735, +// ~60 Hz — what this range raises the floor from, not what it removes.) // // A SECOND, INDEPENDENT limit binds the same material, and no rate bound touches it. A splice // relocates the tap by the nominal window refined by a search over +/- window/4, so the diff --git a/tests/energy_outside_fundamental.h b/tests/energy_outside_fundamental.h new file mode 100644 index 0000000..b71ba97 --- /dev/null +++ b/tests/energy_outside_fundamental.h @@ -0,0 +1,50 @@ +#pragma once +// Out-of-band spectral energy metric: the same period-grid, Hann-windowed direct-evaluation +// approach as test_preserve_low_frequency.cpp's reportSpectrum. Chosen over zero-crossing +// counting because splice debris adds spurious crossings that make that estimator +// anti-correlated with severity (a render can read a badly wrong PERIOD while this metric +// shows it is mostly clean, or vice versa). Grid/segment sizes are smaller than the hand-run +// harness's — this one runs inside the gated suite. + +#include +#include +#include + +namespace reasampler::test_support { + +// Percentage (0..100) of the segment [from, from+len)'s spectral energy that falls outside +// +/- 6% of `wantPeriod` (frames). 0 = a clean single tone at that period; higher values mean +// harmonics, splice-cadence sidebands, or crossfade cancellation debris are present. +inline double energyOutsideFundamentalPercent(const std::vector& v, std::size_t from, + std::size_t len, double wantPeriod) { + constexpr double kPi = 3.14159265358979323846; + constexpr int kGrid = 400; + const double pLo = 30.0, pHi = 8000.0; + std::vector mag(static_cast(kGrid)); + std::vector per(static_cast(kGrid)); + for (int g = 0; g < kGrid; ++g) { + // Geometric grid: constant relative resolution across the swept period range. + const double p = pLo * std::pow(pHi / pLo, static_cast(g) / (kGrid - 1)); + per[static_cast(g)] = p; + double re = 0.0, im = 0.0; + const double w = 2.0 * kPi / p; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) { + const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast(k) / + static_cast(len))); + const double x = v[from + k] * hann; + re += x * std::cos(w * static_cast(k)); + im += x * std::sin(w * static_cast(k)); + } + mag[static_cast(g)] = std::sqrt(re * re + im * im); + } + double eTotal = 0.0, eFund = 0.0; + for (int g = 0; g < kGrid; ++g) { + const std::size_t i = static_cast(g); + const double e = mag[i] * mag[i]; + eTotal += e; + if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e; + } + return eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0; +} + +} // namespace reasampler::test_support diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index be8d0bf..c63cf6c 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -32,6 +32,7 @@ // decorrelated stereo content where an independent per-channel search provably diverges. #include "../src/core/instrument/engine/pitch_shift.h" +#include "energy_outside_fundamental.h" #include #include @@ -740,15 +741,30 @@ static void testStretchAndShiftComposeSafely() { // The [0.5, 2.0] rate bound (time_stretch.h) narrows the splice-cadence failure onto the // source fundamental rather than eliminating it. At rate 2.0, shift 0.25 (-24 st) — both // inside the shipped range — the header's own derivation puts the safe-source floor at a -// period of 315 frames (~140 Hz @ 44.1k): testStretchAndShiftComposeSafely's probe period of -// 196.37 frames (~225 Hz) sits ABOVE that floor, so it passes because of the probe, not -// because of headroom. This probe sits BELOW the floor on purpose, asserting the corner -// rather than assuming it. A failure here is the inequality's PREDICTED outcome, not a -// defect this test exists to chase — report it, don't retune the tolerance to hide it. -static void testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter() { +// period of 315 frames (~140 Hz @ 44.1k): P=500/600/700 sit above that floor, on purpose, +// asserting the corner rather than assuming it. Zero-crossing period is NOT the right +// observable here: an investigation (test_preserve_low_frequency.cpp) found the P=500 +// render's FUNDAMENTAL within 0.03% of target by autocorrelation and spectral peak alike, +// while the zero-crossing estimator read 23% flat — splice debris adds spurious crossings +// the count cannot tell from a real detune. Energy outside the fundamental tracks the actual +// damage instead: measured here (same rate/shift/source, this file's own metric parameters) +// at 7.31% / 14.41% / 21.22% for P=500/600/700, against 0.10% on an alignable control (P=200, +// below the safe floor) at the same rate and shift — so that is what this asserts: a known, +// characterised property of the range, not a pass/fail on a period estimate. A failure on +// either bound below is a finding — report it, don't retune the thresholds to hide it. +static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() { + using reasampler::test_support::energyOutsideFundamentalPercent; const std::int64_t w = 2205; const double rate = 2.0; const double shift = std::pow(2.0, -24.0 / 12.0); // 0.25 + const std::size_t outFrames = 60000; + const std::size_t from = 20000, len = 32768; + + // Below the safe floor (P > 315 frames): the cadence inequality predicts real damage, + // measured at 7-21% (see above). The threshold (5%) sits above the alignable control's + // near-zero floor and under the observed range, so it discriminates a genuine cadence hit + // from a clean render; the ceiling (30%) is a generous margin above the highest measured + // value, there to catch a much worse regression rather than to chase today's exact number. for (double period : {500.0, 600.0, 700.0}) { const double f0 = 1.0 / period; const std::size_t srcLen = 400000; @@ -756,16 +772,35 @@ static void testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter() { for (std::size_t i = 0; i < srcLen; ++i) { src[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); } - const std::size_t outFrames = 60000; const std::vector out = runStretch(src, w, rate, shift, outFrames, nullptr); for (double v : out) CHECK(std::isfinite(v)); - const double p = periodIn(out, 20000, 50000); const double want = period / shift; - const bool ok = approx(p, want, want * 0.12); - std::printf(" [floor probe] period %.0f (rate 2.0, -24 st): observed %.2f want %.2f " - "-> %s\n", period, p, want, ok ? "held" : "FAILED (predicted by the " - "inequality in time_stretch.h)"); - CHECK(ok); + const double energyPct = energyOutsideFundamentalPercent(out, from, len, want); + std::printf(" [cadence corner] period %.0f (rate 2.0, -24 st): energy outside " + "fundamental %.2f%% (want period %.1f fr)\n", period, energyPct, want); + CHECK(energyPct > 5.0); + CHECK(energyPct < 30.0); + } + + // The alignable control: same rate/shift, a source period (200 < 315) the cadence + // inequality does not reach. Without this, a future change that raised the noise floor + // EVERYWHERE (not just at this corner) would still read "under 30%" above and slide + // through — this is what catches that case. + { + const double period = 200.0; + const double f0 = 1.0 / period; + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); + } + const std::vector out = runStretch(src, w, rate, shift, outFrames, nullptr); + for (double v : out) CHECK(std::isfinite(v)); + const double want = period / shift; + const double energyPct = energyOutsideFundamentalPercent(out, from, len, want); + std::printf(" [alignable control] period %.0f (rate 2.0, -24 st): energy outside " + "fundamental %.2f%% (want period %.1f fr)\n", period, energyPct, want); + CHECK(energyPct < 5.0); } } @@ -790,7 +825,7 @@ int main() { testStereoLinkedLagSharedSchedule(); testStretchMovesDurationNotPitch(); testStretchAndShiftComposeSafely(); - testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter(); + testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter(); testStretchEntryPointsOnPassThrough(); if (g_fail == 0) {