fix(vst): reopen heal without the editor — bounded main-thread retry timer, re-armable first-poll heal, unconditional preview-off consume + Mono/drain preview tests

This commit is contained in:
2026-07-28 10:56:40 -04:00
parent 81c4658711
commit 93e28f4ea5
3 changed files with 211 additions and 6 deletions
+44
View File
@@ -2253,6 +2253,48 @@ static void testPreviewNoteObeysVoicing() {
CHECK(approx(buf2[0], 1.0, 1e-6));
}
// PREVIEW JOINS THE MONO HELD STACK: a preview routed through the real note path is a mono
// stack entry like any host note — it TAKES the single voice on press (last-note priority)
// and its release FALLS BACK to the still-held host note instead of cutting to silence.
// Pins the processor's mailbox-drain contract for Mono the way testPreviewNoteObeysVoicing
// pins it for Poly steal.
static void testPreviewNoteJoinsMonoHeldStack() {
Keymap km = twoLevelKeymap();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(50, 127) == 0); // the host-MIDI note: zone A sounds
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
CHECK(eng.noteOn(70, 127) == 0); // the preview press: TAKES the voice
CHECK(eng.activeVoiceCount() == 1); // still mono — the preview is no side-car
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // preview release: FALLBACK to the held note
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOff(50); // host note up: gate off (flat release = instant)
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
CHECK(eng.activeVoiceCount() == 0);
}
// PREVIEW NOTE-OFF ROUTES TO THE DRAIN ENGINE: mirror of process()'s dual-engine off
// routing. A preview held across a reload leaves its ringing voice in the DISPLACED
// (draining) snapshot while the fresh live engine has no voice at that pitch. The off is
// sent to BOTH — exactly what the mailbox drain does: the fresh engine must safely no-op,
// the drain engine must release its voice (otherwise the old-snapshot preview would
// sustain until the next reload hard-cut it).
static void testPreviewNoteOffRoutesToDrainEngine() {
Keymap km = twoLevelKeymap();
VoiceEngine drainEng(2, km); // was live when the preview fired
VoiceEngine liveEng(2, km); // the post-reload fresh snapshot: no voices
CHECK(drainEng.noteOn(70, 127) != VoiceEngine::kNoVoice);
CHECK(approx(probeFrame(drainEng), 0.75, 1e-6)); // the preview rings in the old snapshot
CHECK(liveEng.activeVoiceCount() == 0);
// The preview release, drained to BOTH engines like a host note-off:
liveEng.noteOff(70);
drainEng.noteOff(70);
CHECK(approx(probeFrame(liveEng), 0.0, 1e-9)); // fresh engine: safe no-op, stays silent
CHECK(liveEng.activeVoiceCount() == 0);
CHECK(approx(probeFrame(drainEng), 0.0, 1e-9)); // flat release: gates off NOW
CHECK(drainEng.activeVoiceCount() == 0); // the old-snapshot voice released
}
// GA2 — bounded-blend overshoot regression: mid-ramp output must stay within full scale.
//
// Construction of the worst case (§1 reviewer finding): retrig a sine at a point where the
@@ -2534,6 +2576,8 @@ int main() {
testPreviewReauditionDeclicksViaEngineSteal();
testDeclickBoundedBlendNoOvershoot();
testPreviewNoteObeysVoicing();
testPreviewNoteJoinsMonoHeldStack();
testPreviewNoteOffRoutesToDrainEngine();
// GA3 — Preserve tail wind-down (writer freeze at source exhaustion).
testPreserveTailFinalWindowGapFree();