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
+30 -1
View File
@@ -240,7 +240,32 @@ public:
void previewNoteOn(int note);
void previewNoteOff(int note);
// Reopen-heal retry tick — the target of the module-internal Win32 heal timer (see
// armHealRetry below), NOT host-facing. Public only because the file-static TIMERPROC
// in the .cpp must reach it. Main thread; re-runs reloadFromBank (which disarms the
// timer itself on success) and disarms when the bounded retry budget runs out.
void healTick();
private:
// --- Reopen-heal retry (the NON-editor reload trigger) --------------------------
// A project-restored instance with intent (a selection or zones) can come up SILENT:
// REAPER runs track-FX setState before the extension's PROJEXTSTATE block is parsed,
// so the setState-time reload reads an empty bank. The editor's WM_TIMER poll heals
// that — but only if the user opens the editor; an instance played via host MIDI with
// the editor never attached stayed silent indefinitely. Mechanism: reloadFromBank
// itself detects "built NOTHING despite restored intent, bridge connected" (off the
// audio thread — it just tried) and arms a BOUNDED, HWND-less Win32 retry timer
// (SetTimer + TIMERPROC: fires on the arming thread's message pump — REAPER's main
// thread, where every reload path already runs). Each tick re-runs reloadFromBank,
// which disarms on success or when the intent is gone; the bound stops the churn for
// an instance whose WAV is genuinely missing. A deliberately-empty instance never
// arms (no intent). process() is untouched — fully RT-safe. Main-thread only.
// No-ops on non-Windows builds (the VST target is Windows-only).
void armHealRetry();
void disarmHealRetry();
std::uintptr_t healTimerId_ = 0; // 0 = not armed (main thread only)
int healRetriesLeft_ = 0; // remaining timer-tick retries (main thread only)
// Phase S drain retirement (FA1-review Major #2): if process() has published that the
// CURRENT drain instrument is fully idle (every engine voice silent),
// move it out of the drain slot into the graveyard and prune — so an edited-away snapshot
@@ -356,7 +381,11 @@ private:
// intent (a selection or zones) — the project-load ordering can run setState before the
// extension's PROJEXTSTATE block is parseable, so the bridge read came back empty — the
// first poll reloads instead of silently baselining, or the instrument would stay silent
// until some param change forced a reload. NOT read on the audio thread.
// until some param change forced a reload. If that heal reload STILL leaves nothing live,
// pollBankSync resets this back to the -1 sentinel so the next tick re-arms the heal —
// the retry is bounded by the intent check (a deliberately-empty instance never heals),
// and it also covers a bank whose generation counter was never bumped (0 != 0 can never
// fire the generation path). NOT read on the audio thread.
std::int64_t lastSeenBankGeneration_ = -1;
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the