fix(preserve): prime SOLA rings with real source (zero-latency, gap-free onset) + sub-sample splice alignment — clean repitch C1..C8
This commit is contained in:
+124
-89
@@ -12,15 +12,17 @@
|
||||
// 4. RT discipline surrogate — after configure()+warm() (the off-thread setup), a long
|
||||
// process() run never resizes the ring (checked via window() constancy) and never returns
|
||||
// NaN/inf; pass-through (unconfigured) returns input verbatim.
|
||||
// 5. spectral purity (GA-Preserve regression) — a repitched PURE SINE must come out as a
|
||||
// SINGLE tone at the shifted frequency: near-total least-squares fit to the shifted
|
||||
// sinusoid, and no deep amplitude beating across the run. This is the test that fails on
|
||||
// any splice/crossfade phase-alignment defect (the DAW "multiple partials from a sine"
|
||||
// report). Ratios bracket the real playable range: +24 st (ratio 4 — the geometry-fix
|
||||
// target where an unscaled fade reads stale data) and a full octave down included.
|
||||
// 6. unity contract — the header's two hard claims, asserted bit-exactly: at ratio 1.0 the
|
||||
// shifter IS a clean window/2 delay (out[i] == in[i - w/2] to the bit; no splice, no
|
||||
// interpolation error), which is simultaneously the latency == window/2 assertion.
|
||||
// 5. spectral purity + onset integrity (GA / GA2 regressions) — a PRIMED repitched PURE
|
||||
// SINE must come out as a SINGLE tone at the shifted frequency FROM THE VERY FIRST
|
||||
// MILLISECOND: no zero-gaps anywhere (the GA2 DAW report: silence-warmed rings made
|
||||
// every early splice jump into zeros — burst/gap/burst stutter in the first few ms),
|
||||
// and a per-block least-squares residual floor that catches harmonics, splice-cadence
|
||||
// sideband combs, and crossfade cancellation alike. Ratios cover the FULL playable
|
||||
// range the DAW report exercised: +2/-3 st, +/-1 octave, +24 st, +48 st (C8 from C4,
|
||||
// ratio 16) and -36 st (C1 from C4, ratio 1/8).
|
||||
// 6. unity + latency contract — asserted bit-exactly: a warm()ed shifter at ratio 1.0 IS a
|
||||
// clean window delay; a prime()d one has ZERO added latency (out[i] == src[i] to the
|
||||
// bit) — the GA2 immediate-onset claim.
|
||||
|
||||
#include "../src/vst/pitch_shift.h"
|
||||
|
||||
@@ -181,8 +183,9 @@ static void testRtDisciplineAndPassthrough() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5. Spectral purity: a repitched pure sine stays a SINGLE shifted tone. ---
|
||||
static void testRepitchSpectralPurity() {
|
||||
// --- 5. Spectral purity + onset integrity: a PRIMED repitched pure sine is a SINGLE shifted
|
||||
// tone from the very first millisecond. ---
|
||||
static void testRepitchSpectralPurityAndOnset() {
|
||||
// Frequencies are in cycles/sample (rate-free). The source tone is chosen ADVERSARIALLY
|
||||
// on TWO axes simultaneously:
|
||||
// (a) f0*(w/2) = (2205/2)/196 = 1102/196 ≈ 5.622 cycles (frac ≈ 0.622) — content half a
|
||||
@@ -196,106 +199,138 @@ static void testRepitchSpectralPurity() {
|
||||
// is 22.05 (frac ≈ 0.05), near a zero-crossing — the artifact is near-benign, so the
|
||||
// +24 st purity case would pass even with the clamping reverted. f0=1/196 forces the
|
||||
// half-integer alignment that makes the pre-fix artifact catastrophic.
|
||||
//
|
||||
// The shifter is driven exactly as the Voice drives it since GA2: prime() with the first
|
||||
// window of the source, then stream the CONTINUATION — so the measurements start at
|
||||
// output frame 0 and the onset regime (early splices near the primed boundary, the DAW
|
||||
// "zero-sample gaps in the first few ms" report) is inside the assertions, not skipped.
|
||||
const std::int64_t w = 2205; // ~50 ms @ 44.1k (the product window)
|
||||
const double f0 = 1.0 / 196.0; // source: period 196 samples; see adversarial note above
|
||||
const double ratios[] = {std::pow(2.0, 2.0 / 12.0), // +2 semitones (the DAW report: D from C)
|
||||
const double ratios[] = {std::pow(2.0, 2.0 / 12.0), // +2 semitones (D from C)
|
||||
std::pow(2.0, -3.0 / 12.0), // -3 semitones (down-shift path)
|
||||
2.0, // octave up (nominal-fade boundary)
|
||||
2.0, // octave up (the GA2 report: C5)
|
||||
std::pow(2.0, 24.0 / 12.0), // +24 st: ratio 4 — the ratio-scaled-
|
||||
// fade target (unscaled fade would
|
||||
// read stale data at ~75% gain)
|
||||
std::pow(2.0, -12.0 / 12.0)}; // octave down (full down-shift path)
|
||||
std::pow(2.0, 48.0 / 12.0), // +48 st: ratio 16 — C8 from C4 (the
|
||||
// GA2 "awful at C8" report; fast
|
||||
// splice cadence, short fades)
|
||||
std::pow(2.0, -12.0 / 12.0), // octave down (full down-shift path)
|
||||
std::pow(2.0, -36.0 / 12.0)}; // -36 st: ratio 1/8 — C1 from C4
|
||||
// (the GA2 down-shift report)
|
||||
for (double r : ratios) {
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(r);
|
||||
const std::size_t n = 120000;
|
||||
std::vector<AudioSample> src(n + 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); // the Voice's note-on path: real content, not warm zeros
|
||||
ps.setShiftRatio(r);
|
||||
std::vector<double> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const double x = std::sin(2.0 * kPi * f0 * static_cast<double>(i));
|
||||
out[i] = static_cast<double>(ps.process(static_cast<AudioSample>(x)));
|
||||
out[i] = static_cast<double>(ps.process(src[i + static_cast<std::size_t>(w)]));
|
||||
}
|
||||
|
||||
// Least-squares fit of a*sin + b*cos at the SHIFTED frequency over the settled span
|
||||
// (past 3 windows of onset/latency). Solve the exact 2x2 normal equations so a
|
||||
// non-integer cycle count doesn't leak into the residual.
|
||||
const std::size_t from = static_cast<std::size_t>(3 * w);
|
||||
// (a) ONSET/GAP integrity over the ENTIRE run, frame 0 included: no near-zero run
|
||||
// longer than 32 frames (~0.7 ms). A unit-amplitude shifted sine dwells below 1e-3
|
||||
// for well under one frame per zero crossing even at the lowest ratio here, while the
|
||||
// pre-fix onset gaps were hundreds to thousands of frames of literal silence.
|
||||
std::size_t worstGap = 0, run = 0;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
if (std::fabs(out[i]) < 1e-3) {
|
||||
++run;
|
||||
if (run > worstGap) worstGap = run;
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
CHECK(worstGap < 32);
|
||||
|
||||
// (b) PER-BLOCK least-squares fit of a*sin + b*cos at the SHIFTED frequency, from the
|
||||
// FIRST block. Fitting phase per block deliberately tolerates the slow (pitch-true,
|
||||
// inaudible) SOLA phase wander across seconds while catching everything audible:
|
||||
// harmonics ("square-ish"), splice-cadence sideband combs (the spectrogram alias
|
||||
// lines), crossfade cancellation, and onset gaps all land in the residual or collapse
|
||||
// the in-block fit amplitude. Solve the exact 2x2 normal equations per block.
|
||||
const double f1 = r * f0;
|
||||
double sss = 0.0, scc = 0.0, ssc = 0.0, sys = 0.0, syc = 0.0;
|
||||
for (std::size_t i = from; i < n; ++i) {
|
||||
const double ph = 2.0 * kPi * f1 * static_cast<double>(i);
|
||||
const double s = std::sin(ph), c = std::cos(ph);
|
||||
sss += s * s; scc += c * c; ssc += s * c;
|
||||
sys += out[i] * s; syc += out[i] * c;
|
||||
const std::size_t block = 4096;
|
||||
for (std::size_t b0 = 0; b0 + block <= n; b0 += block) {
|
||||
double sss = 0.0, scc = 0.0, ssc = 0.0, sys = 0.0, syc = 0.0;
|
||||
for (std::size_t i = b0; i < b0 + block; ++i) {
|
||||
const double ph = 2.0 * kPi * f1 * static_cast<double>(i);
|
||||
const double s = std::sin(ph), c = std::cos(ph);
|
||||
sss += s * s; scc += c * c; ssc += s * c;
|
||||
sys += out[i] * s; syc += out[i] * c;
|
||||
}
|
||||
const double det = sss * scc - ssc * ssc;
|
||||
CHECK(det > 0.0);
|
||||
const double a = (sys * scc - syc * ssc) / det;
|
||||
const double b = (syc * sss - sys * ssc) / det;
|
||||
double residSq = 0.0, fitSq = 0.0;
|
||||
for (std::size_t i = b0; i < b0 + block; ++i) {
|
||||
const double ph = 2.0 * kPi * f1 * static_cast<double>(i);
|
||||
const double fit = a * std::sin(ph) + b * std::cos(ph);
|
||||
residSq += (out[i] - fit) * (out[i] - fit);
|
||||
fitSq += fit * fit;
|
||||
}
|
||||
const double fitRms = std::sqrt(fitSq / static_cast<double>(block));
|
||||
const double residRms = std::sqrt(residSq / static_cast<double>(block));
|
||||
// The shifted tone is there at full amplitude (unit sine RMS ~0.707) in EVERY
|
||||
// block — a gapped or beating block collapses this...
|
||||
CHECK(fitRms > 0.6);
|
||||
CHECK(fitRms < 0.8);
|
||||
// ...and it is the ONLY thing there: residual at least 30 dB under the tone.
|
||||
// (Post-fix the engine measures ~-75 dB and better; the old integer-lag splices
|
||||
// sat near -59 dB sidebands and the warm-zero onset failed outright.)
|
||||
CHECK(residRms < 0.0316 * fitRms);
|
||||
}
|
||||
const double det = sss * scc - ssc * ssc;
|
||||
CHECK(det > 0.0);
|
||||
const double a = (sys * scc - syc * ssc) / det;
|
||||
const double b = (syc * sss - sys * ssc) / det;
|
||||
double residSq = 0.0, fitSq = 0.0;
|
||||
for (std::size_t i = from; i < n; ++i) {
|
||||
const double ph = 2.0 * kPi * f1 * static_cast<double>(i);
|
||||
const double fit = a * std::sin(ph) + b * std::cos(ph);
|
||||
const double resid = out[i] - fit;
|
||||
residSq += resid * resid;
|
||||
fitSq += fit * fit;
|
||||
}
|
||||
const std::size_t span = n - from;
|
||||
const double fitRms = std::sqrt(fitSq / static_cast<double>(span));
|
||||
const double residRms = std::sqrt(residSq / static_cast<double>(span));
|
||||
CHECK(fitRms > 0.5); // the shifted tone is actually there (unit sine ~0.707)
|
||||
CHECK(residRms < 0.1 * fitRms); // >=99% of the energy in the ONE shifted tone
|
||||
|
||||
// No beating: sliding-window RMS must not dip (the old design dipped to ~13% of peak).
|
||||
// The window must RESOLVE a within-fade dip (the ratio-4 fade is only ~w/12 = 183
|
||||
// frames; the original win=2000 averaged straight over total cancellation), yet a
|
||||
// window that is not an integer number of output periods has phase-dependent RMS on a
|
||||
// pure sine (at ratio 0.5 the output period is 400 frames — a fixed 256 window dips
|
||||
// to ~0.78 of max on the CLEAN signal alone). Smallest phase-clean choice: exactly one
|
||||
// output period per window (50..400 frames here), hop of half a window.
|
||||
const std::size_t win = static_cast<std::size_t>(std::lround(1.0 / f1));
|
||||
const std::size_t hop = std::max<std::size_t>(1, win / 2);
|
||||
double minRms = 1e9, maxRms = 0.0;
|
||||
for (std::size_t s0 = from; s0 + win <= n; s0 += hop) {
|
||||
double e = 0.0;
|
||||
for (std::size_t i = s0; i < s0 + win; ++i) e += out[i] * out[i];
|
||||
const double rms = std::sqrt(e / static_cast<double>(win));
|
||||
if (rms < minRms) minRms = rms;
|
||||
if (rms > maxRms) maxRms = rms;
|
||||
}
|
||||
CHECK(maxRms > 0.0);
|
||||
CHECK(minRms > 0.8 * maxRms); // steady amplitude — no crossfade cancellation
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6. Unity contract: bit-exact window/2 delay == the latency claim. ---
|
||||
// --- 6. Unity + latency contract: warm = bit-exact window delay; primed = bit-exact ZERO
|
||||
// latency. ---
|
||||
static void testUnityBitExactAndLatency() {
|
||||
// The header claims a configured shifter at ratio 1.0 is a CLEAN window/2 delay: the tap
|
||||
// is parked mid-band (no splice ever fires) at an integral delay (no interpolation error),
|
||||
// so every output equals the input from exactly w/2 frames earlier TO THE BIT. This is
|
||||
// simultaneously the latency assertion: steady-state latency == window/2, no more, no
|
||||
// less. warm() has already consumed the cold-start region, so the first w/2 outputs are
|
||||
// the tail of the warm-up silence and everything after is the delayed input verbatim.
|
||||
const std::int64_t w = 2205; // the product window (odd: w/2 truncates)
|
||||
const std::int64_t lat = w / 2; // 1102
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(1.0);
|
||||
// A configured shifter at ratio 1.0 parks the tap mid-band (no splice ever fires) at an
|
||||
// integral delay (no interpolation error). After warm() that delay is exactly one window
|
||||
// of declared silence, so out[i] == in[i - w] to the bit. After prime() with the first
|
||||
// window of source the tap sits ON src[0] — out[i] == src[i] to the bit from the very
|
||||
// first frame: the GA2 zero-structural-latency (immediate onset) claim.
|
||||
const std::int64_t w = 2205; // the product window
|
||||
const std::size_t n = 6000;
|
||||
const std::vector<AudioSample> in = sine(n, 37.0);
|
||||
std::vector<AudioSample> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
|
||||
std::size_t badSilence = 0, badDelay = 0;
|
||||
for (std::size_t i = 0; i < static_cast<std::size_t>(lat); ++i) {
|
||||
if (out[i] != 0.0f) ++badSilence; // pre-latency region: warm-up silence, exact
|
||||
const std::vector<AudioSample> in = sine(n + static_cast<std::size_t>(w), 37.0);
|
||||
// warm(): a clean, bit-exact one-window delay of the streamed input.
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(1.0);
|
||||
std::vector<AudioSample> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
|
||||
std::size_t badSilence = 0, badDelay = 0;
|
||||
for (std::size_t i = 0; i < static_cast<std::size_t>(w); ++i) {
|
||||
if (out[i] != 0.0f) ++badSilence; // pre-latency region: declared silence, exact
|
||||
}
|
||||
for (std::size_t i = static_cast<std::size_t>(w); i < n; ++i) {
|
||||
if (out[i] != in[i - static_cast<std::size_t>(w)]) ++badDelay; // bit-exact delay
|
||||
}
|
||||
CHECK(badSilence == 0);
|
||||
CHECK(badDelay == 0);
|
||||
}
|
||||
for (std::size_t i = static_cast<std::size_t>(lat); i < n; ++i) {
|
||||
if (out[i] != in[i - static_cast<std::size_t>(lat)]) ++badDelay; // bit-exact delay
|
||||
// prime(): zero added latency — the output IS the source from frame 0, bit-exact.
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
ps.prime(in.data(), w);
|
||||
ps.setShiftRatio(1.0);
|
||||
std::size_t badZeroLat = 0;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
if (ps.process(in[i + static_cast<std::size_t>(w)]) != in[i]) ++badZeroLat;
|
||||
}
|
||||
CHECK(badZeroLat == 0);
|
||||
}
|
||||
CHECK(badSilence == 0);
|
||||
CHECK(badDelay == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
@@ -303,7 +338,7 @@ int main() {
|
||||
testUnityRoughlyReproduces();
|
||||
testTransposeDirection();
|
||||
testRtDisciplineAndPassthrough();
|
||||
testRepitchSpectralPurity();
|
||||
testRepitchSpectralPurityAndOnset();
|
||||
testUnityBitExactAndLatency();
|
||||
|
||||
if (g_fail == 0) {
|
||||
|
||||
+64
-54
@@ -1337,33 +1337,42 @@ static void testPreserveVoiceCap() {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FA1 (re-scoped by Phase S) — the Preserve unity-Varispeed bypass now belongs to the PREVIEW
|
||||
// CARD ONLY. The MIDI engine keeps the shifter at EVERY Preserve note so a chromatic line has
|
||||
// one uniform onset (the FA1-review ~25 ms root-note timing-step finding); the card — always
|
||||
// fired at the effective root, latency-critical, with no line to be uneven against — opts in
|
||||
// and speaks on frame one.
|
||||
// CARD ONLY. The MIDI engine keeps the shifter at EVERY Preserve note. GA2 update: the primed
|
||||
// shifter speaks on frame 0 at every ratio (the ring holds the first window of real source),
|
||||
// so onset is uniformly IMMEDIATE across the keyboard and the bypass survives purely as a
|
||||
// per-frame work-skip for the card.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The ENGINE'S root-note Preserve voice now keeps the OLA path: frame 0 is the shifter's fill
|
||||
// (near-silent), full level once the ring fills — the SAME onset as its transposed neighbors.
|
||||
// Pre-re-scope this voice was demoted and spoke at 1.0 on frame 0.
|
||||
static void testPreserveUnityEngineVoiceKeepsUniformOnset() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(60, 127); // at root: unity shift — NO demotion in the MIDI engine
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1500);
|
||||
double early = 0.0;
|
||||
for (std::size_t i = 0; i < 8; ++i) {
|
||||
early = (std::max)(early, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
CHECK(early < 0.1); // shifter onset, exactly like a transposed note
|
||||
double late = 0.0;
|
||||
for (std::size_t i = 600; i < 1500; ++i) {
|
||||
late = (std::max)(late, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
CHECK(late > 0.9); // and the ring fills to full level
|
||||
// The ENGINE'S root-note Preserve voice keeps the OLA path — and since the GA2 prime fix the
|
||||
// primed shifter speaks on frame 0 at EVERY ratio (the ring holds the first window of real
|
||||
// source, not warm-up zeros). Uniform onset across the keyboard now means uniformly IMMEDIATE:
|
||||
// the root and a transposed neighbor both open at full level on the very first frames.
|
||||
static void testPreserveUnityEngineVoiceSpeaksImmediately() {
|
||||
auto earlyAndLate = [](int note, double& early, double& late) {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(note, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1500);
|
||||
early = 1e9;
|
||||
for (std::size_t i = 0; i < 8; ++i) {
|
||||
early = (std::min)(early, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
late = 0.0;
|
||||
for (std::size_t i = 600; i < 1500; ++i) {
|
||||
late = (std::max)(late, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
};
|
||||
double earlyRoot = 0.0, lateRoot = 0.0, earlyUp = 0.0, lateUp = 0.0;
|
||||
earlyAndLate(60, earlyRoot, lateRoot); // at root: unity shift — NO demotion in the engine
|
||||
earlyAndLate(62, earlyUp, lateUp); // +2 st: a real shift, same immediate onset
|
||||
CHECK(earlyRoot > 0.9); // primed ring: full level from frame 0 (no fill silence)
|
||||
CHECK(earlyUp > 0.9); // ...uniformly across the keyboard (the GA2 onset-gap fix)
|
||||
CHECK(lateRoot > 0.9);
|
||||
CHECK(lateUp > 0.9); // and no gaps later either (splices land in real history)
|
||||
}
|
||||
|
||||
// The PREVIEW CARD at unity speaks on frame ONE — the FA1 latency fix, now scoped to the card.
|
||||
@@ -1393,47 +1402,48 @@ static void testPreviewCardKeyTrackZeroAlsoSpeaksImmediately() {
|
||||
CHECK(approx(buf[0], 1.0, 1e-6));
|
||||
}
|
||||
|
||||
// A TRANSPOSED preview keeps the genuine OLA path — the card's demotion is unity-ONLY.
|
||||
// A TRANSPOSED preview keeps the genuine OLA path — the card's demotion is unity-ONLY. Since
|
||||
// the GA2 prime fix onset silence can no longer distinguish the paths (both speak on frame 0),
|
||||
// so prove it by DURATION: a Preserve Trigger at 100% of a 1000-frame sample holds ~1000
|
||||
// output frames at +12 st, where a Varispeed demotion would run off in ~500.
|
||||
static void testPreviewCardTransposedKeepsShifter() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr();
|
||||
SampleData s = preserveTriggerSample(1000, 1.0);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||
card.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
|
||||
std::vector<AudioSample> buf(8, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
double early = 0.0;
|
||||
for (std::size_t i = 0; i < 8; ++i) {
|
||||
early = (std::max)(early, static_cast<double>(std::fabs(buf[i])));
|
||||
card.noteOn(72, 127); // +12 semitones: a real shift, NOT demoted
|
||||
std::vector<AudioSample> buf(1, 0.0f);
|
||||
std::size_t len = 0;
|
||||
for (std::size_t f = 0; f < 2000; ++f) {
|
||||
buf[0] = 0.0f;
|
||||
card.render(buf.data(), 1);
|
||||
if (card.active()) len = f + 1;
|
||||
else break;
|
||||
}
|
||||
CHECK(early < 0.1); // shifter fill — duration preservation kept for off-root previews
|
||||
CHECK(len > 700); // duration held (Preserve) — a Varispeed demotion would stop near 500
|
||||
CHECK(len < 1300); // ...and not doubled either (sanity)
|
||||
}
|
||||
|
||||
// A TRANSPOSED Preserve note keeps the genuine OLA path: onset is shifter-delayed (the inherent
|
||||
// half-window cost of preserving duration) and the voice reaches full level once the ring fills.
|
||||
// Also proves the demotion is unity-ONLY — the shifter still transposes off-root notes.
|
||||
static void testPreserveTransposedVoiceKeepsOlaPath() {
|
||||
// A TRANSPOSED Preserve note keeps the genuine OLA path — and since the GA2 prime fix that
|
||||
// path has NO onset cost: the ring is primed with the first window of real source, so a
|
||||
// transposed voice opens at full level on frame 0 (the DAW "zero-sample gaps in the first few
|
||||
// ms" regression) and NEVER dips while the source sustains (splices land in real history, not
|
||||
// warm-up zeros).
|
||||
static void testPreserveTransposedVoiceSpeaksImmediately() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1500);
|
||||
// Early frames are the shifter's fill (near-silent) — the structural OLA onset.
|
||||
double early = 0.0;
|
||||
for (std::size_t i = 0; i < 8; ++i) {
|
||||
early = (std::max)(early, static_cast<double>(std::fabs(out[i])));
|
||||
// Full level from the very first frame (a DC source through complementary crossfades and
|
||||
// aligned splices holds 1.0 throughout) — pre-fix the first ~window was fill silence.
|
||||
double lo = 1e9;
|
||||
for (std::size_t i = 0; i < 1500; ++i) {
|
||||
lo = (std::min)(lo, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
CHECK(early < 0.1);
|
||||
// Once the ring is full of the DC source (>= window frames in), output reaches the sample
|
||||
// level (Hann taps partition unity, so DC passes at gain 1).
|
||||
double late = 0.0;
|
||||
for (std::size_t i = 600; i < 1500; ++i) {
|
||||
late = (std::max)(late, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
CHECK(late > 0.9);
|
||||
CHECK(lo > 0.9);
|
||||
}
|
||||
|
||||
// Phase S re-scope consequence: a ROOT-note engine Preserve voice keeps its shifter, so it
|
||||
@@ -2284,11 +2294,11 @@ int main() {
|
||||
|
||||
// FA1 (re-scoped by Phase S) — the unity bypass is preview-card-only; the engine keeps a
|
||||
// uniform Preserve onset. Velocity under Preserve unchanged.
|
||||
testPreserveUnityEngineVoiceKeepsUniformOnset();
|
||||
testPreserveUnityEngineVoiceSpeaksImmediately();
|
||||
testPreviewCardUnitySpeaksImmediately();
|
||||
testPreviewCardKeyTrackZeroAlsoSpeaksImmediately();
|
||||
testPreviewCardTransposedKeepsShifter();
|
||||
testPreserveTransposedVoiceKeepsOlaPath();
|
||||
testPreserveTransposedVoiceSpeaksImmediately();
|
||||
testPreserveUnityVoiceCountsTowardCap();
|
||||
testVelocityCurveAppliesUnderPreserve();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user