fix(preserve): GA3 tail wind-down — freeze the SOLA writer at source exhaustion so the tail recycles frozen real content (no DC-splice chop through the final window + release)
This commit is contained in:
@@ -342,6 +342,116 @@ static void testUnityBitExactAndLatency() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 7. Tail wind-down (GA3): freezeTail() at source exhaustion keeps the output a
|
||||
// continuous, full-amplitude tone at the shifted frequency — the splice machinery
|
||||
// recycles the ring's frozen ALL-REAL tail instead of chopping against held-DC
|
||||
// padding (the DAW "ring modulation" troughs growing toward the note end). ---
|
||||
static void testFreezeTailContinuousTone() {
|
||||
const std::int64_t w = 2205;
|
||||
const double f0 = 1.0 / 196.37; // non-integer period (the test-5 adversarial tone)
|
||||
const std::size_t stream = 20000; // frames fed before exhaustion (several splice cycles)
|
||||
const double ratios[] = {std::pow(2.0, 7.0 / 12.0), // +7 st (the DAW report regime)
|
||||
2.0, // octave up
|
||||
std::pow(2.0, 24.0 / 12.0), // +24 st: fast frozen drain
|
||||
std::pow(2.0, -5.0 / 12.0), // -5 st (down-shift tail)
|
||||
1.0}; // unity: frozen delay drains at 1 —
|
||||
// splices NOW fire even at unity
|
||||
for (double r : ratios) {
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
std::vector<AudioSample> src(stream + static_cast<std::size_t>(w));
|
||||
for (std::size_t i = 0; i < src.size(); ++i) {
|
||||
src[i] = static_cast<AudioSample>(
|
||||
std::sin(2.0 * kPi * f0 * static_cast<double>(i)));
|
||||
}
|
||||
ps.prime(src.data(), w);
|
||||
ps.setShiftRatio(r);
|
||||
for (std::size_t i = 0; i < stream; ++i) {
|
||||
(void)ps.process(src[i + static_cast<std::size_t>(w)]);
|
||||
}
|
||||
// Source exhausted: freeze (idempotent) and keep producing for one full window — the
|
||||
// longest a Voice runs frozen (its own note end lands within a window of exhaustion).
|
||||
CHECK(!ps.tailFrozen());
|
||||
ps.freezeTail();
|
||||
ps.freezeTail(); // double-freeze harmless
|
||||
CHECK(ps.tailFrozen());
|
||||
const std::size_t tail = static_cast<std::size_t>(w);
|
||||
std::vector<double> out(tail);
|
||||
for (std::size_t i = 0; i < tail; ++i) {
|
||||
out[i] = static_cast<double>(ps.process(0.0f)); // input ignored while frozen
|
||||
CHECK(std::isfinite(out[i]));
|
||||
}
|
||||
// (a) No dead stretches: a unit-amplitude tone dwells below 0.05 only a few frames
|
||||
// per zero crossing; the pre-GA3 DC chop ran hundreds.
|
||||
std::size_t worstGap = 0, run = 0;
|
||||
for (std::size_t i = 0; i < tail; ++i) {
|
||||
if (std::fabs(out[i]) < 0.05) {
|
||||
++run;
|
||||
if (run > worstGap) worstGap = run;
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
CHECK(worstGap < 24);
|
||||
// (b) Full amplitude throughout: every 256-frame block spans > a half period at all
|
||||
// tested ratios, so a continuous tone peaks near 1.0 in each.
|
||||
for (std::size_t b = 0; b + 256 <= tail; b += 256) {
|
||||
double peak = 0.0;
|
||||
for (std::size_t i = b; i < b + 256; ++i) {
|
||||
if (std::fabs(out[i]) > peak) peak = std::fabs(out[i]);
|
||||
}
|
||||
CHECK(peak > 0.5);
|
||||
CHECK(peak < 1.1); // aligned complementary fades: no cancellation, no bulge
|
||||
}
|
||||
}
|
||||
|
||||
// Freeze landing MID-CROSSFADE: at ratio 2 from a fresh prime the tap drains from delay
|
||||
// w at 1/frame, splices at w/4 (frame 3w/4), then fades for w/4 frames — so frame
|
||||
// 3w/4 + w/8 is deterministically mid-fade. The frozen writer makes the outgoing tap
|
||||
// close at the FULL ratio; the transition caps the live fade so it completes before
|
||||
// reading lapped content — output must stay finite, gap-free, and bounded.
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
std::vector<AudioSample> src(4 * static_cast<std::size_t>(w));
|
||||
for (std::size_t i = 0; i < src.size(); ++i) {
|
||||
src[i] = static_cast<AudioSample>(
|
||||
std::sin(2.0 * kPi * f0 * static_cast<double>(i)));
|
||||
}
|
||||
ps.prime(src.data(), w);
|
||||
ps.setShiftRatio(2.0);
|
||||
const std::size_t preFreeze = static_cast<std::size_t>(3 * w / 4 + w / 8);
|
||||
for (std::size_t i = 0; i < preFreeze; ++i) {
|
||||
(void)ps.process(src[i + static_cast<std::size_t>(w)]);
|
||||
}
|
||||
ps.freezeTail();
|
||||
std::size_t worstGap = 0, run = 0;
|
||||
for (std::size_t i = 0; i < static_cast<std::size_t>(w); ++i) {
|
||||
const double o = static_cast<double>(ps.process(0.0f));
|
||||
CHECK(std::isfinite(o));
|
||||
CHECK(std::fabs(o) < 1.1);
|
||||
if (std::fabs(o) < 0.05) {
|
||||
++run;
|
||||
if (run > worstGap) worstGap = run;
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
CHECK(worstGap < 24);
|
||||
// reset()/prime() clear the freeze: the shifter is fully reusable for the next
|
||||
// note-on, and a primed unity run is STILL bit-exact zero-latency (no stale state).
|
||||
ps.reset();
|
||||
CHECK(!ps.tailFrozen());
|
||||
ps.prime(src.data(), w);
|
||||
ps.setShiftRatio(1.0);
|
||||
std::size_t badZeroLat = 0;
|
||||
for (std::size_t i = 0; i < 2000; ++i) {
|
||||
if (ps.process(src[i + static_cast<std::size_t>(w)]) != src[i]) ++badZeroLat;
|
||||
}
|
||||
CHECK(badZeroLat == 0);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDurationInvariance();
|
||||
testUnityRoughlyReproduces();
|
||||
@@ -349,6 +459,7 @@ int main() {
|
||||
testRtDisciplineAndPassthrough();
|
||||
testRepitchSpectralPurityAndOnset();
|
||||
testUnityBitExactAndLatency();
|
||||
testFreezeTailContinuousTone();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all pitch_shift tests passed\n");
|
||||
|
||||
Reference in New Issue
Block a user