Fix cross-rate Preserve splices, re-stamp on host-rate change, name the rate refusal

The splice crossfade now counts output frames and stops before the ring's far end (this also fixes a lap at matched rate). The search strides by the rate ratio. The editor takes its time base from its own decode. Same-rate output is pinned to hashes from before the change.
This commit is contained in:
2026-09-25 14:17:11 -04:00
parent ec60cf78c6
commit 64f314a6a4
24 changed files with 592 additions and 183 deletions
+80 -18
View File
@@ -748,6 +748,65 @@ static void testStretchAndShiftComposeSafely() {
}
}
// A down-splice's crossfade walks the OUTGOING tap away from the writer at (feedRate - shift)
// source frames per output frame, toward the ring's far end — a quarter window past the
// trigger. The live fade must end before that walk laps the ring, both at a matched rate (a fast
// feed under a down-shift) and at a high source ratio, where the fade is counted in output
// frames while the tap moves in source frames. The first case per ratio is also checked for
// its nominal length: window/4 source frames' worth of output time.
static void testDownSpliceFadeNeverLapsTheRingsFarEnd() {
const std::int64_t w = 2205;
const double ringLen = 2.0 * static_cast<double>(w);
const double dHigh = ringLen - static_cast<double>(w / 4);
struct Case { double feed, shift, sourceRatio; std::int64_t nominalFade; };
const Case cases[] = {
{1.0, 0.5, 1.0, w / 4}, // -12 st, matched: room to spare, the full fade
{2.0, 1.0, 1.0, -1}, // Rate 200 %, no shift: recedes one frame per frame
{2.0, 0.25, 1.0, -1}, // Rate 200 %, -24 st
{4.0, 2.0, 4.0, 138}, // -12 st at a 4x source ratio: round(551 / 4)
{4.0, 1.0, 4.0, -1}, // -24 st at a 4x source ratio
};
std::vector<AudioSample> src(400000);
for (std::size_t i = 0; i < src.size(); ++i) {
src[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * static_cast<double>(i) / 196.37));
}
for (const Case& c : cases) {
PitchShifter ps;
ps.configure(w);
ps.prime(src.data(), w);
ps.setShiftRatio(c.shift);
ps.setFeedRate(c.feed);
ps.setSourceRateRatio(c.sourceRatio);
std::size_t pos = static_cast<std::size_t>(w);
double debt = 0.0;
SpliceEvent first;
for (int i = 0; i < 40000 && !first.fired; ++i) {
debt += c.feed;
const int due = static_cast<int>(debt);
debt -= static_cast<double>(due);
for (int k = 0; k + 1 < due; ++k) ps.writeFrame(src[pos++]);
if (due > 0) ps.process(src[pos++]);
else ps.processNoInput();
first = ps.lastSplice();
}
CHECK(first.fired);
CHECK(first.jump < 0); // a DOWN-splice: the tap jumped toward the writer
const double recede = c.feed - c.shift;
// Worst case: the trigger overshot dHigh by one frame's drift, and the integer feed runs
// up to one frame ahead of its average. One frame short of the ring is the interpolator's
// read-ahead.
const double worstDelay =
dHigh + recede + recede * static_cast<double>(first.fadeLen) + 1.0;
if (!(worstDelay < ringLen)) {
std::printf(" feed %.2f shift %.2f ratio %.1f: fade %lld reaches delay %.1f of %.0f\n",
c.feed, c.shift, c.sourceRatio, static_cast<long long>(first.fadeLen),
worstDelay, ringLen);
}
CHECK(worstDelay < ringLen);
if (c.nominalFade > 0) CHECK(first.fadeLen == c.nominalFade);
}
}
// 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
@@ -757,18 +816,18 @@ static void testStretchAndShiftComposeSafely() {
// 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 is 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/shift/source, this file's own metric parameters) at 7.08% / 13.90% / 20.92% for
// P=500/600/700, against 0.04% on an alignable control (P=200, below the safe floor) at the
// same rate and shift. Those readings are stable and are what the bounds below hold.
//
// **CORRECTED — what those three readings MEAN.** They were once read as the corner's damage.
// They are almost entirely the metric's own floor: an ideal tone at the same want-period,
// measured identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because
// a long period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6%
// band. The corner's real EXCESS over that floor is 0.23% / 0.51% / 0.30% — small, real, and
// nothing like the headline numbers. The alignable control's 0.10% is genuinely near-zero only
// because its want-period is short enough to have almost no floor. The bounds below are kept
// as a stable regression tripwire on the raw readings; read the excess, not the reading.
// They are the metric's own floor: an ideal tone at the same want-period, measured
// identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because a long
// period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6% band.
// The corner's EXCESS over that floor once measured 0.23% / 0.51% / 0.30% (control 0.10%); that
// residue was the down-shift crossfade (recede 1.75 frames/frame) lapping the ring's far end,
// and splice()'s far-end fade cap took it to 0.00%. The bounds below are kept as a stable
// regression tripwire on the raw readings; read the excess, not the reading.
//
// This measures the FIXED-WINDOW path — no source period is set, which is what a capture with
// no single period (percussive, polyphonic, noise) gets. What the same corner does once the
@@ -782,8 +841,8 @@ static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() {
const std::size_t from = 20000, len = 32768;
// Below the safe floor (P > 315 frames): per the header's CORRECTED note, this raw reading
// is mostly the metric's own mainlobe-leakage floor, not cadence damage (the real excess is
// ~0.2-0.5%, asserted in testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
// is the metric's own mainlobe-leakage floor, not cadence damage (the excess is asserted in
// testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
// below are a RAW-READING STABILITY TRIPWIRE, not a discriminator — they catch a large swing
// in the raw measurement; 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.
@@ -1001,13 +1060,15 @@ static void testTwentyNineHertzAtRateTwoKeepsItsPitch() {
// The cadence corner (rate 2.0, -24 st, source periods above the 315-frame safe floor) is the
// OTHER mechanism — a splice landing inside a single perceived cycle. Measured against the
// metric's own floor, PSOLA moves it by nothing: 0.23/0.51/0.30% excess becomes 0.24/0.49/0.30%.
// metric's own floor, PSOLA moves it by nothing: 0.00% excess either way (it was 0.23/0.51/0.30%
// both ways until the far-end fade cap removed the lapped crossfade behind it).
//
// That is not a shortfall, it is what the corner turned out to be. Correcting the previous
// test's reading (see its comment) shrank the corner from a 7-21% headline to a sub-1% excess,
// which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each splice
// land in phase, and these splices already did; what it cannot do is make them less frequent.
// So this asserts NO REGRESSION, not an improvement, and says so rather than claiming one.
// test's reading (see its comment) shrank the corner from a 7-21% headline to no measurable
// excess, which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each
// splice land in phase, and these splices already did; what it cannot do is make them less
// frequent. So this asserts NO REGRESSION, not an improvement, and says so rather than
// claiming one.
static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
using reasampler::test_support::energyOutsideFundamentalPercent;
const std::int64_t w = 2205;
@@ -1045,7 +1106,7 @@ static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
//
// MEASURED (Debug, this machine): metric floor 55.86% (P=1470's want-period of 5880 fr under a
// 32768-frame segment leaks a lot of mainlobe, same effect as the P=500-700 rows, just larger),
// fixed-window excess 18.52%, pitch-synchronous excess 0.00%. The faster cadence does NOT
// fixed-window excess 18.61%, pitch-synchronous excess 0.00%. The faster cadence does NOT
// regress this corner — every splice at n=1 lands exactly one source period away, so despite
// firing 1.5x as often each one is phase-perfect rather than merely aligned-on-average, and the
// corner clears rather than worsens. Recorded as read, not tuned: if a future change moves
@@ -1073,7 +1134,7 @@ static void testCadenceCollapseBandAtThirtyHertzUnderPitchSynchronousSplice() {
"excess %.2f%% -> %.2f%%\n", period, want, floor, pctOff - floor, pctOn - floor);
CHECK(pctOn - floor < 1.0); // measured 0.00%: phase-perfect at n=1, not merely aligned
// Vacuity guard (shape of testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown's :961): the
// FIXED-WINDOW arm is asserted too (measured 18.52% excess), so a setSourcePeriod that
// FIXED-WINDOW arm is asserted too (measured 18.61% excess), so a setSourcePeriod that
// silently did nothing would render both arms identically and pass the bound above by luck.
CHECK(pctOff - floor > pctOn - floor + 1.0);
}
@@ -1099,6 +1160,7 @@ int main() {
testStereoLinkedLagSharedSchedule();
testStretchMovesDurationNotPitch();
testStretchAndShiftComposeSafely();
testDownSpliceFadeNeverLapsTheRingsFarEnd();
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound();
testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry();