Preview rides the real note path: mailbox drains into VoiceEngine noteOn/noteOff at the root note; PreviewCard retired; first-poll reopen heal reloads a silent-but-should-be-loaded instrument
This commit is contained in:
+40
-147
@@ -1331,8 +1331,8 @@ static void testPreserveGateStereoLoopComposes() {
|
||||
}
|
||||
|
||||
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
|
||||
// Since the Phase S re-scope EVERY engine Preserve voice (root included) runs the shifter and
|
||||
// counts toward the cap — the unity demotion is preview-card-only (see the Phase S section).
|
||||
// EVERY engine Preserve voice (root included) runs the shifter and counts toward the cap —
|
||||
// the FA1 unity demotion is gone (it was scoped to the retired PreviewCard).
|
||||
static void testPreserveVoiceCap() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
|
||||
@@ -1346,11 +1346,10 @@ 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. 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.
|
||||
// FA1 postscript — the Preserve unity-Varispeed bypass is REMOVED with the PreviewCard
|
||||
// (preview is now a plain engine noteOn at the root). The engine keeps the shifter at
|
||||
// EVERY Preserve note; GA2's primed ring speaks on frame 0 at every ratio, so onset is
|
||||
// uniformly IMMEDIATE across the keyboard with no demotion path at all.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The ENGINE'S root-note Preserve voice keeps the OLA path — and since the GA2 prime fix the
|
||||
@@ -1385,54 +1384,6 @@ static void testPreserveUnityEngineVoiceSpeaksImmediately() {
|
||||
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.
|
||||
static void testPreviewCardUnitySpeaksImmediately() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr();
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||
card.noteOn(60, 127); // at root: unity shift -> demoted inside the card, zero onset delay
|
||||
std::vector<AudioSample> buf(4, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 1.0, 1e-6)); // the DC sample, on the very first frame
|
||||
}
|
||||
|
||||
// keyTrack 0 collapses EVERY note to unity — an off-root preview also demotes, speaks at once.
|
||||
static void testPreviewCardKeyTrackZeroAlsoSpeaksImmediately() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr();
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].keyTrack = 0.0; // no tracking: all keys play root pitch (unity)
|
||||
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||
card.noteOn(67, 127);
|
||||
std::vector<AudioSample> buf(4, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 1.0, 1e-6));
|
||||
}
|
||||
|
||||
// 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 = preserveTriggerSample(1000, 1.0);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||
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(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 — 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
|
||||
@@ -1829,19 +1780,6 @@ static void testAllNotesOffClearsMonoHeldStack() {
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
// MAJOR-2 companion: the preview card's unconditional releaseAll (the panic peer).
|
||||
static void testPreviewCardReleaseAll() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
PreviewCard card(km);
|
||||
card.noteOn(70, 127);
|
||||
CHECK(card.active());
|
||||
card.releaseAll(); // no note argument: quiets whatever rings
|
||||
std::vector<AudioSample> buf(1, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 0.0, 1e-9));
|
||||
CHECK(!card.active());
|
||||
}
|
||||
|
||||
// CC 120 (allSoundsOff) hard-stops a ringing TRIGGER one-shot that would otherwise play to
|
||||
// its bounded playEnd (minutes on a full-length capture). This is the primary repro: allNotesOff
|
||||
// (CC 123) is a NO-OP on a Trigger voice — only allSoundsOff provides the actual hard stop.
|
||||
@@ -2211,46 +2149,30 @@ static void testZeroAttackGateRetrigNoStep() {
|
||||
CHECK(maxDeltaAcross(static_cast<double>(pre.back()), post) < 0.08);
|
||||
}
|
||||
|
||||
// GA2 — the PREVIEW click: a preview fired over a RINGING preview replaces the card's single
|
||||
// voice — the same hard cut as a mono takeover, previously entirely un-declicked (the card
|
||||
// never passed the opt-in). With takeoverDeclick opted in at construction, the
|
||||
// replace-restart runs the same difference-seeded ramp: boundary continuity + bounded slope.
|
||||
static void testPreviewRetriggerDeclicksRestart() {
|
||||
// PREVIEW re-audition declick (GA2, re-homed on the engine): the preview is now a plain
|
||||
// engine noteOn at the root, so re-auditioning while the first preview still rings is a
|
||||
// POLY AT-CAP STEAL when the pool is saturated — with takeoverDeclick opted in (the shell's
|
||||
// product default), the restart runs the same difference-seeded ramp: boundary continuity
|
||||
// + bounded slope. Same physics testPolyStealDeclicksRestart pins; this pins it at the
|
||||
// preview's exact shape (same note, root, full pool of 1).
|
||||
static void testPreviewReauditionDeclicksViaEngineSteal() {
|
||||
SampleData s = sineSample(48000, 100.0, 60); // default ADSR: instant unity (worst case)
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
PreviewCard card(km, 0, /*takeoverDeclick=*/true);
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
card.noteOn(60, 127);
|
||||
std::vector<AudioSample> pre(120, 0.0f);
|
||||
card.render(pre.data(), pre.size()); // ringing at ~ the sine peak
|
||||
eng.noteOn(60, 127); // preview: root note through the pool
|
||||
std::vector<AudioSample> pre;
|
||||
eng.render(pre, 120); // ringing at ~ the sine peak
|
||||
CHECK(pre.back() > 0.99f);
|
||||
|
||||
card.noteOn(60, 127); // audition again: replaces the ringing preview
|
||||
std::vector<AudioSample> post(400, 0.0f);
|
||||
card.render(post.data(), post.size());
|
||||
eng.noteOn(60, 127); // audition again: at-cap steal restart
|
||||
std::vector<AudioSample> post;
|
||||
eng.render(post, 400);
|
||||
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01);
|
||||
CHECK(maxDeltaAcross(static_cast<double>(pre.back()), post) < 0.08);
|
||||
}
|
||||
|
||||
// The preview declick is OPT-IN: a default-constructed card keeps the pre-fix hard cut
|
||||
// byte-identical — the replace-restart's first frame is the new voice's raw onset (sin(0)
|
||||
// == 0 here), pinning the pure-core regression baseline the shell layers the opt-in above.
|
||||
static void testPreviewDefaultOffKeepsHardCutBaseline() {
|
||||
SampleData s = sineSample(48000, 100.0, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
PreviewCard card(km); // declick NOT opted in
|
||||
|
||||
card.noteOn(60, 127);
|
||||
std::vector<AudioSample> pre(120, 0.0f);
|
||||
card.render(pre.data(), pre.size());
|
||||
CHECK(pre.back() > 0.99f);
|
||||
|
||||
card.noteOn(60, 127);
|
||||
std::vector<AudioSample> post(1, 0.0f);
|
||||
card.render(post.data(), post.size());
|
||||
CHECK(approx(post[0], 0.0, 1e-4)); // the raw hard cut: new onset, no ramp
|
||||
}
|
||||
|
||||
// GA-VoiceSteal repro (DAW bug): voiceCount 3, a triad note-on'd at the SAME sample time
|
||||
// (three note-ons in one block, no render between), then a 4th note. The steal must take
|
||||
// EXACTLY ONE voice (the oldest, none releasing) and leave the other two RINGING — the DAW
|
||||
@@ -2309,50 +2231,26 @@ static void testOverCapChordStealsExactlyOne() {
|
||||
CHECK(eng.activeVoiceCount() == 0); // the stolen-into 4th note releases last
|
||||
}
|
||||
|
||||
// PREVIEW-CARD ISOLATION: the card never consumes a pool voice, a FULL pool never drops a
|
||||
// preview, and pool stealing never touches the ringing preview. The two sum independently.
|
||||
static void testPreviewCardIsolatedFromPool() {
|
||||
// PREVIEW OBEYS VOICING (the PreviewCard-isolation reversal): a preview is a plain engine
|
||||
// noteOn, so it is a REAL pool voice — a full pool STEALS for it (never a parallel voice on
|
||||
// top), it counts toward activeVoiceCount, and its note-off releases through the normal
|
||||
// path. This is the processor's mailbox-drain contract, pinned in the pure core.
|
||||
static void testPreviewNoteObeysVoicing() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km);
|
||||
PreviewCard card(km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(62, 127); // the pool is now FULL
|
||||
card.noteOn(64, 127); // preview fires anyway — its own voice
|
||||
CHECK(eng.activeVoiceCount() == 2); // no pool voice consumed
|
||||
CHECK(card.active());
|
||||
std::vector<AudioSample> buf(4, 0.0f);
|
||||
eng.render(buf.data(), buf.size()); // engine sums 2 voices...
|
||||
card.render(buf.data(), buf.size()); // ...card ADDS its own on top
|
||||
CHECK(approx(buf[0], 3.0, 1e-6));
|
||||
eng.noteOn(64, 127); // pool steals INTERNALLY...
|
||||
eng.noteOn(64, 127); // the preview note: steals — no third voice
|
||||
CHECK(eng.activeVoiceCount() == 2);
|
||||
CHECK(card.active()); // ...the preview is untouched
|
||||
card.noteOff(64); // flat release: card gates off instantly
|
||||
std::vector<AudioSample> buf(4, 0.0f);
|
||||
eng.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 2.0, 1e-6)); // TWO voices sum — never 3 (no side-car)
|
||||
eng.noteOff(64); // flat release: the preview gates off
|
||||
std::vector<AudioSample> buf2(1, 0.0f);
|
||||
card.render(buf2.data(), buf2.size());
|
||||
CHECK(approx(buf2[0], 0.0, 1e-9));
|
||||
CHECK(eng.activeVoiceCount() == 2); // and the pool never noticed
|
||||
}
|
||||
|
||||
// The card is ONE voice: a new preview replaces the ringing one, a STALE note-off (for the
|
||||
// replaced note) is a no-op, and an out-of-zone preview is a defined no-play.
|
||||
static void testPreviewCardReplaceStaleOffAndOutOfZone() {
|
||||
Keymap km = twoLevelKeymap(); // zones [40,59] + [60,80]
|
||||
PreviewCard card(km);
|
||||
card.noteOn(50, 127);
|
||||
card.noteOn(70, 127); // replaces the first preview
|
||||
std::vector<AudioSample> buf(1, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 0.75, 1e-6)); // zone B is what rings
|
||||
card.noteOff(50); // STALE off for the replaced note: no-op
|
||||
CHECK(card.active());
|
||||
card.noteOff(70); // the sounding note's off gates it (release 0)
|
||||
std::vector<AudioSample> buf2(1, 0.0f);
|
||||
card.render(buf2.data(), buf2.size());
|
||||
CHECK(approx(buf2[0], 0.0, 1e-9));
|
||||
card.noteOn(20, 127); // out of every zone: defined no-play
|
||||
CHECK(!card.active());
|
||||
eng.render(buf2.data(), buf2.size());
|
||||
CHECK(eng.activeVoiceCount() == 1); // the surviving MIDI note still rings
|
||||
CHECK(approx(buf2[0], 1.0, 1e-6));
|
||||
}
|
||||
|
||||
// GA2 — bounded-blend overshoot regression: mid-ramp output must stay within full scale.
|
||||
@@ -2591,12 +2489,9 @@ int main() {
|
||||
testPreserveGateStereoLoopComposes();
|
||||
testPreserveVoiceCap();
|
||||
|
||||
// FA1 (re-scoped by Phase S) — the unity bypass is preview-card-only; the engine keeps a
|
||||
// uniform Preserve onset. Velocity under Preserve unchanged.
|
||||
// FA1 postscript — the unity demotion is gone with the PreviewCard; the engine keeps a
|
||||
// uniform Preserve onset at every note. Velocity under Preserve unchanged.
|
||||
testPreserveUnityEngineVoiceSpeaksImmediately();
|
||||
testPreviewCardUnitySpeaksImmediately();
|
||||
testPreviewCardKeyTrackZeroAlsoSpeaksImmediately();
|
||||
testPreviewCardTransposedKeepsShifter();
|
||||
testPreserveTransposedVoiceSpeaksImmediately();
|
||||
testPreserveUnityVoiceCountsTowardCap();
|
||||
testVelocityCurveAppliesUnderPreserve();
|
||||
@@ -2605,7 +2500,8 @@ int main() {
|
||||
testPerZoneAdsrReachesVoiceEnvelope();
|
||||
testZeroAdsrIsInstantSustain();
|
||||
|
||||
// Phase S — voice count, MONO mode (held stack + Retrigger/Legato), preview card.
|
||||
// Phase S — voice count, MONO mode (held stack + Retrigger/Legato); preview as a real
|
||||
// pool voice (PreviewCard retired — preview routes through the engine).
|
||||
testMonoLastNotePriorityAndFallback();
|
||||
testMonoReleaseOfLowerHeldNoteIsInaudible();
|
||||
testMonoRepressHeldNoteMovesToTop();
|
||||
@@ -2621,7 +2517,6 @@ int main() {
|
||||
testMonoLegatoTriggerHeldKeyStillRetunes();
|
||||
testAllNotesOffReleasesPolyVoices();
|
||||
testAllNotesOffClearsMonoHeldStack();
|
||||
testPreviewCardReleaseAll();
|
||||
testAllSoundsOffStopsTriggerOneShot();
|
||||
testAllNotesOffStillReleasesGateVoices();
|
||||
testMonoLegatoSameNoteRepressReattacks();
|
||||
@@ -2636,11 +2531,9 @@ int main() {
|
||||
testZeroAttackTakeoverNeverExceedsFullScale();
|
||||
testMonoRetrigTriggerZoneDeclicksRestart();
|
||||
testZeroAttackGateRetrigNoStep();
|
||||
testPreviewRetriggerDeclicksRestart();
|
||||
testPreviewDefaultOffKeepsHardCutBaseline();
|
||||
testPreviewReauditionDeclicksViaEngineSteal();
|
||||
testDeclickBoundedBlendNoOvershoot();
|
||||
testPreviewCardIsolatedFromPool();
|
||||
testPreviewCardReplaceStaleOffAndOutOfZone();
|
||||
testPreviewNoteObeysVoicing();
|
||||
|
||||
// GA3 — Preserve tail wind-down (writer freeze at source exhaustion).
|
||||
testPreserveTailFinalWindowGapFree();
|
||||
|
||||
Reference in New Issue
Block a user