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:
2026-07-28 08:49:05 -04:00
parent e99bdcf264
commit 2ef3514bc7
6 changed files with 447 additions and 238 deletions
+64 -54
View File
@@ -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();