#pragma once // The two tone metrics the Preserve tests and the hand-run low-frequency harness share, so // there is one of each rather than a copy per file. // // Zero-crossing counting is deliberately NOT among them: splice debris adds spurious crossings // that make that estimator anti-correlated with severity (a render can read a badly wrong // PERIOD while it is spectrally clean, or vice versa). #include #include #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), evaluated directly on a geometric period grid (no FFT-bin // quantization). 0 = a clean single tone at that period; higher values mean harmonics, // splice-cadence sidebands, or crossfade cancellation debris are present. // // `grid` trades resolution for cost: the gated tests run the default, the hand-run harness // raises it. The value is NOT comparable across grid sizes or segment lengths — a long period // under a short segment leaks part of its own mainlobe outside the +/-6% band, so a reading is // only meaningful against a control measured at the SAME len and grid. inline double energyOutsideFundamentalPercent(const std::vector& v, std::size_t from, std::size_t len, double wantPeriod, int grid = 400) { constexpr double kPi = 3.14159265358979323846; const int kGrid = grid; 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; } // Period (frames) of the highest normalized-autocorrelation peak over [minLag, maxLag], with a // parabolic refinement so the answer is not quantized to whole frames. Bracket the caller's // range to roughly [0.5, 1.7] x the expected period: a pure tone autocorrelates equally at // EVERY multiple of its period, so an unbounded search reports 2P about half the time. inline double autocorrelationPeriod(const std::vector& v, std::size_t from, std::size_t len, std::int64_t minLag, std::int64_t maxLag) { if (maxLag <= minLag) return 0.0; double e0 = 0.0; for (std::size_t k = 0; k < len && from + k < v.size(); ++k) e0 += v[from + k] * v[from + k]; if (e0 <= 0.0) return 0.0; std::vector score(static_cast(maxLag - minLag + 1), 0.0); double best = -1e18; std::int64_t bestLag = minLag; for (std::int64_t lag = minLag; lag <= maxLag; ++lag) { double s = 0.0, e = 0.0; for (std::size_t k = 0; k < len && from + k + static_cast(lag) < v.size(); ++k) { const double b = v[from + k + static_cast(lag)]; s += v[from + k] * b; e += b * b; } const double r = e > 0.0 ? s / std::sqrt(e0 * e) : 0.0; score[static_cast(lag - minLag)] = r; if (r > best) { best = r; bestLag = lag; } } const std::size_t i = static_cast(bestLag - minLag); double frac = 0.0; if (i > 0 && i + 1 < score.size()) { const double den = score[i - 1] - 2.0 * score[i] + score[i + 1]; if (den < 0.0) frac = 0.5 * (score[i - 1] - score[i + 1]) / den; } return static_cast(bestLag) + frac; } } // namespace reasampler::test_support