Γ-W1-T7: make Preserve's splices pitch-synchronous — the jump is a whole number of the source's own period, detected once at load
30 Hz out-of-band energy 15.45% -> 0.00%; the 29 Hz rate-2.0 detune -133 -> +0 cents. An unknown period keeps the fixed-window geometry bit for bit. The detector cannot reach process(): sampler_core does not link it.
This commit is contained in:
@@ -14,6 +14,11 @@
|
||||
// the load-bearing metric is energy outside it. Zero-crossing counting in particular reports a
|
||||
// wrong period on renders whose fundamental is provably correct, which is section C.
|
||||
//
|
||||
// It now runs every frequency-dependent section TWICE — once with splices falling back to the
|
||||
// fixed window (the behaviour every number above was measured on) and once pitch-synchronous,
|
||||
// with the period detected from the PCM exactly as the loader would. The two columns differ in
|
||||
// that one thing, so the comparison needs no second binary and no remembered baseline.
|
||||
//
|
||||
// Measures, at both 44.1k and 48k geometry:
|
||||
// A. the reachable relocation interval, observed rather than derived (jump/lag/frac off
|
||||
// every SpliceEvent), and the alignment-reachability predicate over frequency.
|
||||
@@ -24,9 +29,11 @@
|
||||
// D. a window sweep at 30 Hz — what a larger window would buy, and what it would cost.
|
||||
// E. alignable frequencies under identical conditions, without which D and B have no scale.
|
||||
|
||||
#include "../src/core/instrument/engine/period_detect.h"
|
||||
#include "../src/core/instrument/engine/pitch_shift.h"
|
||||
#include "../src/core/instrument/engine/time_stretch.h"
|
||||
#include "../src/core/instrument/engine/voice.h"
|
||||
#include "tone_metrics.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -44,12 +51,20 @@ static int g_fail = 0;
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
// Whether a source carries its detected period into the shifter — i.e. whether splices are
|
||||
// pitch-synchronous or fall back to the fixed window. Every section below runs under whichever
|
||||
// is set, so main() can drive the SAME measurements both ways from one binary and the two
|
||||
// columns are comparable by construction.
|
||||
static bool g_pitchSynchronous = true;
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Source + render helpers
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// A pure sine at `freqHz`, phase-continuous, long enough that a rate-2.0 render never
|
||||
// exhausts it (the caller sizes `frames`).
|
||||
// exhausts it (the caller sizes `frames`). The period is DETECTED rather than computed from
|
||||
// freqHz on purpose: that is the number the loader would actually hand the engine, so the
|
||||
// measurement includes any detector error rather than assuming it away.
|
||||
static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames,
|
||||
PitchEngine engine, double phase = 0.0) {
|
||||
SampleData s;
|
||||
@@ -61,6 +76,7 @@ static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames,
|
||||
s.sampleRate = sampleRate;
|
||||
s.rootNote = 60;
|
||||
s.play.pitchEngine = engine; // Gate, no loop, default (fully open) AHDSR
|
||||
if (g_pitchSynchronous) s.sourcePeriodFrames = detectPeriod(s.frames, sampleRate).frames;
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -162,37 +178,10 @@ static double medianResidual(const std::vector<double>& v, std::size_t from, std
|
||||
return r[mid];
|
||||
}
|
||||
|
||||
// Period of the highest normalized-autocorrelation peak over [minLag, maxLag] — a pitch
|
||||
// estimator that, unlike zero-crossing counting, is not fooled by a low-level fast component
|
||||
// adding spurious crossings. The two disagreeing is itself the diagnosis.
|
||||
static double autocorrPeriod(const std::vector<double>& v, std::size_t from, std::size_t len,
|
||||
std::int64_t minLag, std::int64_t maxLag) {
|
||||
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;
|
||||
double best = -1e18; std::int64_t bestLag = 0;
|
||||
std::vector<double> score(static_cast<std::size_t>(maxLag - minLag + 1), 0.0);
|
||||
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<std::size_t>(lag) < v.size();
|
||||
++k) {
|
||||
const double b = v[from + k + static_cast<std::size_t>(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<std::size_t>(lag - minLag)] = r;
|
||||
if (r > best) { best = r; bestLag = lag; }
|
||||
}
|
||||
// Parabolic refinement so the estimate isn't quantized to whole frames.
|
||||
const std::size_t i = static_cast<std::size_t>(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<double>(bestLag) + frac;
|
||||
}
|
||||
// A pitch estimator that, unlike zero-crossing counting, is not fooled by a low-level fast
|
||||
// component adding spurious crossings. The two disagreeing is itself the diagnosis. Shared with
|
||||
// the gated tests (tone_metrics.h) so there is one estimator and not two.
|
||||
using reasampler::test_support::autocorrelationPeriod;
|
||||
|
||||
// The five strongest spectral peaks over a Hann-windowed segment, scanned on a fine period
|
||||
// grid (Goertzel-style direct evaluation, no FFT-bin quantization). Prints period in frames
|
||||
@@ -264,24 +253,28 @@ struct SpliceStats {
|
||||
double minReloc = 1e18, maxReloc = -1e18;
|
||||
std::int64_t minLag = 1LL << 40, maxLag = -(1LL << 40);
|
||||
double meanInterval = 0.0;
|
||||
bool jumpAlwaysNominal = true; // |jump| == window on every splice (steady state)
|
||||
bool jumpAlwaysNominal = true; // |jump| == the nominal on every splice (steady state)
|
||||
std::int64_t nominalJump = 0; // what the shifter itself resolved the nominal to
|
||||
};
|
||||
|
||||
// Drives a bare PitchShifter over the same feed schedule Voice uses, recording every splice.
|
||||
// The audio is not kept — this measures the DECISIONS, not the sound.
|
||||
static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int64_t window,
|
||||
double rate, double shift, std::size_t outFrames,
|
||||
std::vector<double>* audio = nullptr) {
|
||||
std::vector<double>* audio = nullptr,
|
||||
double sourcePeriod = 0.0) {
|
||||
PitchShifter ps;
|
||||
ps.configure(window);
|
||||
ps.prime(src.data(), window);
|
||||
ps.setShiftRatio(shift);
|
||||
ps.setFeedRate(rate);
|
||||
ps.setSourcePeriod(sourcePeriod);
|
||||
StretchCursor cur;
|
||||
cur.start(window);
|
||||
loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through
|
||||
|
||||
SpliceStats st;
|
||||
st.nominalJump = ps.spliceJump();
|
||||
if (audio != nullptr) audio->assign(outFrames, 0.0);
|
||||
std::size_t lastSpliceAt = 0;
|
||||
double intervalSum = 0.0;
|
||||
@@ -308,7 +301,7 @@ static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int6
|
||||
if (reloc > st.maxReloc) st.maxReloc = reloc;
|
||||
if (ev.lag < st.minLag) st.minLag = ev.lag;
|
||||
if (ev.lag > st.maxLag) st.maxLag = ev.lag;
|
||||
if (std::llabs(ev.jump) != window) st.jumpAlwaysNominal = false;
|
||||
if (std::llabs(ev.jump) != st.nominalJump) st.jumpAlwaysNominal = false;
|
||||
if (lastSpliceAt != 0) { intervalSum += static_cast<double>(i - lastSpliceAt); ++intervals; }
|
||||
lastSpliceAt = i;
|
||||
}
|
||||
@@ -503,24 +496,29 @@ static void measureRow(const char* label, double freqHz, int sr, std::int64_t wi
|
||||
const double resid = medianResidual(out, from, to, wantCpf);
|
||||
|
||||
std::vector<AudioSample> src(s.frames.begin(), s.frames.end());
|
||||
const SpliceStats st = spliceGeometry(src, window, rate, shift, outFrames);
|
||||
const SpliceStats st =
|
||||
spliceGeometry(src, window, rate, shift, outFrames, nullptr, s.sourcePeriodFrames);
|
||||
const double lo = static_cast<double>(window - window / 4);
|
||||
const double hi = static_cast<double>(window + window / 4);
|
||||
int n = 0;
|
||||
// Reachability of the FIXED-window interval. With a source period known this is no longer
|
||||
// the binding question — the nominal jump is a multiple of the period by construction —
|
||||
// but it stays reported because it is what the "NO" rows below were diagnosed by.
|
||||
const bool reach = alignmentReachable(srcPeriod, lo, hi, &n);
|
||||
|
||||
// Effective frequency error implied by the drift, and the phase step it works out to per
|
||||
// splice — the number that says whether a splice is stepping the phase or not.
|
||||
const double driftPerSplice = st.count > 0 ? drift / static_cast<double>(st.count) : 0.0;
|
||||
std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | period got %8.2f want %8.2f "
|
||||
"(%+.2f%%) | splices %4lld every %7.0f fr | phase drift %+8.3f cyc "
|
||||
"(%+7.1f deg/splice, worst step %.1f deg) | resid %.4f | peak %.3f | "
|
||||
"align %s%s\n",
|
||||
label, freqHz, shift, rate, gotPeriod, wantPeriod,
|
||||
std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | P det %8.2f jump %5lld | "
|
||||
"period got %8.2f want %8.2f (%+.2f%%) | splices %4lld every %7.0f fr | "
|
||||
"phase drift %+8.3f cyc (%+7.1f deg/splice, worst step %.1f deg) | "
|
||||
"resid %.4f | peak %.3f | fixed-window align %s%s\n",
|
||||
label, freqHz, shift, rate, s.sourcePeriodFrames,
|
||||
static_cast<long long>(st.nominalJump), gotPeriod, wantPeriod,
|
||||
wantPeriod > 0.0 ? 100.0 * (gotPeriod - wantPeriod) / wantPeriod : 0.0,
|
||||
st.count, st.meanInterval, drift, 360.0 * driftPerSplice, 360.0 * worstStep,
|
||||
resid, peak, reach ? "YES" : "NO",
|
||||
reach ? "" : " <-- no whole period in the reachable interval");
|
||||
reach ? "" : " <-- no whole period in the fixed-window reachable interval");
|
||||
CHECK(finite);
|
||||
}
|
||||
|
||||
@@ -536,7 +534,7 @@ static void deepDive(const char* label, double freqHz, int sr, std::int64_t wind
|
||||
const double zc = periodIn(out, 40000, 280000);
|
||||
// Search bounded to [0.5, 1.7] x the wanted period: a pure sine autocorrelates equally at
|
||||
// EVERY multiple of its period, so an unbounded search reports 2P about half the time.
|
||||
const double ac = autocorrPeriod(out, 60000, 60000,
|
||||
const double ac = autocorrelationPeriod(out, 60000, 60000,
|
||||
std::max<std::int64_t>(40,
|
||||
static_cast<std::int64_t>(wantPeriod * 0.5)),
|
||||
static_cast<std::int64_t>(wantPeriod * 1.7));
|
||||
@@ -617,10 +615,11 @@ static void reportFloorProbeMechanism() {
|
||||
std::sin(2.0 * kPi * static_cast<double>(i) / period));
|
||||
}
|
||||
std::vector<double> out;
|
||||
const SpliceStats st = spliceGeometry(src, w, rate, shift, 60000, &out);
|
||||
const SpliceStats st = spliceGeometry(src, w, rate, shift, 60000, &out,
|
||||
g_pitchSynchronous ? period : 0.0);
|
||||
const double want = period / shift;
|
||||
const double zc = periodIn(out, 20000, 50000);
|
||||
const double ac = autocorrPeriod(out, 20000, 20000,
|
||||
const double ac = autocorrelationPeriod(out, 20000, 20000,
|
||||
static_cast<std::int64_t>(want * 0.5),
|
||||
static_cast<std::int64_t>(want * 1.7));
|
||||
std::printf(" P=%.0f: zero-crossing %.2f (%+.2f%%) | autocorrelation %.2f (%+.2f%%) "
|
||||
@@ -681,14 +680,39 @@ static void reportAlignableControls() {
|
||||
deepDive("220 Hz rate 2.0", 220.0, 44100, 2205, 60, 2.0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
reportReachableInterval();
|
||||
reportReachabilityByFrequency();
|
||||
// The frequency-dependent sections, run under whichever splice geometry is set. Everything
|
||||
// that can differ between the two is in here; section A (the reachable interval, measured on
|
||||
// noise) and the reachability arithmetic are properties of the fixed-window search alone and
|
||||
// run once.
|
||||
static void runFrequencySections() {
|
||||
testRootRateUnityIsBitIdenticalToTheDirectRead();
|
||||
reportTransposedAt30Hz();
|
||||
reportFrequencySweep();
|
||||
reportFloorProbeMechanism();
|
||||
reportAlignableControls();
|
||||
}
|
||||
|
||||
int main() {
|
||||
reportReachableInterval();
|
||||
reportReachabilityByFrequency();
|
||||
|
||||
// The same measurements twice, from one binary, so the two columns differ in exactly one
|
||||
// thing. The FIXED-WINDOW pass reproduces the pre-PSOLA engine — it is the baseline every
|
||||
// number in the investigation was taken against.
|
||||
g_pitchSynchronous = false;
|
||||
std::printf("\n\n##################################################################\n");
|
||||
std::printf("### FIXED-WINDOW splices (no source period) — the prior behaviour ###\n");
|
||||
std::printf("##################################################################\n");
|
||||
runFrequencySections();
|
||||
|
||||
g_pitchSynchronous = true;
|
||||
std::printf("\n\n##################################################################\n");
|
||||
std::printf("### PITCH-SYNCHRONOUS splices (detected source period) ###\n");
|
||||
std::printf("##################################################################\n");
|
||||
runFrequencySections();
|
||||
|
||||
// The window sweep is about what a LARGER WINDOW would buy, which was the alternative to
|
||||
// this track. Run under the shipped geometry only.
|
||||
reportWindowSweep();
|
||||
|
||||
if (g_fail == 0) {
|
||||
|
||||
Reference in New Issue
Block a user