fix(bridge): 3 bug âm thanh Qin/Nexus standalone

1) wavesynth tự phát khi mở app: probe calibrate() render vào live mix
   (renderAll process cùng instance) -> thêm calibratingCh_[16], renderAll
   bỏ qua channel đang calibrate; plugin_host panic (sustain off + all
   notes off + 128 noteOff) ngay sau preset apply để startup im lặng.
2) bấm GUI mở 2 cửa sổ Qin: sandbox VST2 child tự sở hữu top-level window
   -> ownsGuiWindow() + main.cpp forward OPEN_GUI control, không tạo native
   parent / không registry double-open.
3) play phát nhạc cụ sai: restore clobber — seed preset đè patch thật trong
   state file (ch3 63ba3667 -> 7f07473c) do saveStateJob chạy lúc child còn
   seed. Fix: queuePreset(waitApply=true) chờ child apply xong (presetOut
   memcmp), consume-before-apply (zero presetInLen trước khi apply), deferred
   double-load re-check trong main.cpp skip assign trùng path, watchdog
   respawn re-queue lastPreset_.
This commit is contained in:
2026-08-21 08:50:28 +07:00
parent b91e6d3875
commit a7b01d3484
7 changed files with 143 additions and 23 deletions
@@ -36,6 +36,10 @@ public:
// Headless engines (VST2 without an editor, SF2/SFZ): the bridge must
// not create/reuse a window for them.
virtual bool canOpenGUI() const { return true; }
// Sandbox hosts whose child owns its own top-level window (sandbox VST2):
// the bridge must not create a parent native window nor close other
// editors — attachView(nullptr) just forwards the OPEN_GUI control.
virtual bool ownsGuiWindow() const { return false; }
virtual void closeGUI() = 0;
// VST3 reopen-after-close: the bridge checks before openGUI whether the
// instance must be rebuilt; while it is, the engine sets reloading=true
@@ -177,6 +177,7 @@ private:
// Per-instrument scratch so engines that overwrite (not mix) stay additive.
std::vector<float> scratchL_, scratchR_;
// Channel-level reload flag (see isReloading) — indexed by MIDI channel.
bool calibratingCh_[16] = {}; // probe-render mute: renderAll skips channel during calibrate()
bool reloadingCh_[16] = {};
// G3.3: channel muted after a plugin fault inside processAudioBlock.
bool crashedCh_[16] = {};
@@ -188,4 +189,4 @@ private:
std::atomic<bool> isBypassed_{false};
};
#endif // NATIVE_INSTRUMENT_ENGINE_H
#endif // NATIVE_INSTRUMENT_ENGINE_H
+9 -2
View File
@@ -313,9 +313,16 @@ float InstrumentEngineManager::recalibrate(uint32_t channel, double sampleRate,
}
if (!inst) return 1.0f;
// calibrate() probe-renders (up to ~0.9s) - never hold mu_ (audio stall).
// Flag the channel so renderAll skips it: the probe notes would otherwise
// leak into the live mix (auto-play at startup, bug 1).
{
std::lock_guard<std::mutex> lock(mu_);
if (channel < 16) calibratingCh_[channel] = true;
}
const float mk = calibrate(inst, sampleRate, blockSize);
{
std::lock_guard<std::mutex> lock(mu_);
if (channel < 16) calibratingCh_[channel] = false;
makeup_[channel] = mk;
}
return mk;
@@ -421,7 +428,7 @@ bool InstrumentEngineManager::isCrashed(uint32_t channel) const {
// khong xac dinh sau fault; xu ly tiep se fault lai.
// Called with mu_ held by the real-time dispatch.
bool InstrumentEngineManager::channelQuiet(uint32_t ch) const {
if (ch >= 16 || reloadingCh_[ch] || crashedCh_[ch]) return true;
if (ch >= 16 || reloadingCh_[ch] || crashedCh_[ch] || calibratingCh_[ch]) return true;
return false;
}
@@ -684,4 +691,4 @@ void InstrumentEngineManager::guiIdleAll() {
for (auto& kv : channels_) {
kv.second->guiIdle();
}
}
}
+41 -6
View File
@@ -162,6 +162,14 @@ void SandboxVst2Host::watchdogLoop() {
if (stop_.load()) break;
if (spawnChild()) {
fails = 0;
// G4.6: respawned child starts from FACTORY state — re-queue the
// last preset (real patch or seed) so it does not publish/sound
// silent factory and clobber the state file.
if (!lastPreset_.empty()) {
queuePreset(lastPreset_);
std::cerr << "[SandboxVst2Host] preset re-queued after respawn ch="
<< channel_ << " (" << lastPreset_.size() << "B)" << std::endl;
}
std::cerr << "[SandboxVst2Host] ch=" << channel_ << " respawned" << std::endl;
}
}
@@ -253,24 +261,51 @@ void SandboxVst2Host::seedDefaultPreset() {
std::string b64;
b64.resize((size_t)sz);
if (fread(&b64[0], 1, (size_t)sz, f) == (size_t)sz && !b64.empty()) {
memcpy((void*)ipc_->presetIn, b64.data(), b64.size());
ipc_->presetIn[b64.size()] = 0;
ipc_->presetInLen = (uint32_t)b64.size();
lastPreset_ = b64;
queuePreset(b64);
std::cerr << "[SandboxVst2Host] default preset seeded ch=" << channel_
<< " (" << b64.size() << "B)" << std::endl;
}
fclose(f);
}
void SandboxVst2Host::loadSerializedState(const std::string& base64) {
if (!alive_.load() || !ipc_ || base64.empty()) return;
void SandboxVst2Host::queuePreset(const std::string& base64, bool waitApply) {
if (!ipc_ || base64.empty()) return;
size_t n = base64.size();
if (n >= PRESET_BLOB_SIZE) n = PRESET_BLOB_SIZE - 1;
memcpy((void*)ipc_->presetIn, base64.data(), n);
ipc_->presetIn[n] = 0;
ipc_->presetInLen = (uint32_t)n;
// waitApply: block until the child has APPLIED this preset (presetOut
// echoes the chunk) before returning. The restore path queues the real
// saved preset right after spawn; without this wait a saveStateJob can
// fire while the child is still applying the seed default and persist the
// seed over the real chunk (restore clobber). Qin round-trips
// byte-identical; 20s timeout guards a stuck child.
if (!waitApply) return;
if (ipc_->presetOutLen == (uint32_t)n &&
memcmp(ipc_->presetOut, base64.data(), n) == 0) return;
auto t0 = std::chrono::steady_clock::now();
while (std::chrono::steady_clock::now() - t0 < std::chrono::seconds(20)) {
if (ipc_->presetOutLen == (uint32_t)n &&
memcmp(ipc_->presetOut, base64.data(), n) == 0) break;
Sleep(50);
}
bool confirmed = ipc_->presetOutLen == (uint32_t)n &&
memcmp(ipc_->presetOut, base64.data(), n) == 0;
std::cerr << "[SandboxVst2Host] preset apply waited ch=" << channel_
<< " (" << base64.size() << "B)"
<< (confirmed ? " confirmed" : " TIMEOUT")
<< std::endl;
}
void SandboxVst2Host::loadSerializedState(const std::string& base64) {
if (base64.empty()) return;
lastPreset_ = base64; // keep for watchdog respawn re-queue
if (!alive_.load() || !ipc_) return;
queuePreset(base64, true); // wait for apply so early saves see the real chunk
std::cerr << "[SandboxVst2Host] presetIn queued ch=" << channel_
<< " (" << n << "B)" << std::endl;
<< " (" << base64.size() << "B)" << std::endl;
}
std::string SandboxVst2Host::serializeState() const {
+3
View File
@@ -36,6 +36,7 @@ public:
// The child owns its GUI window (plugin_host opens it on OPEN_GUI control).
bool openGUI(void* parentWindowHandle) override;
bool canOpenGUI() const override { return true; }
bool ownsGuiWindow() const override { return true; } // child owns its top-level window
bool attachView(void* parentWindowHandle) override; // bridge OPEN_GUI job calls this
bool hasAttachedView() const override { return guiOpen_.load(); }
void closeGUI() override;
@@ -55,9 +56,11 @@ private:
void watchdogLoop();
void pushControl(uint32_t type, uint32_t arg0 = 0, uint32_t arg1 = 0);
void seedDefaultPreset();
void queuePreset(const std::string& base64, bool waitApply = false);
std::string path_;
std::string shmName_;
std::string lastPreset_; // last preset queued (seed or real patch) — re-queued on watchdog respawn
double sampleRate_ = 44100.0;
uint32_t block_ = AUDIO_BLOCK_SIZE;
uint32_t channel_ = 0;
+35
View File
@@ -1129,6 +1129,24 @@ int main(int argc, char* argv[]) {
return;
}
}
// G4.6: child-own GUI (sandbox VST2 like Qin). The child process
// opens its OWN top-level window on control 4 — no native parent
// window to create, no g_guiWindows registry, no same-path
// silencing, no closing of other editors (separate process;
// nothing to serialize). Just forward the OPEN_GUI control.
if (auto* inst0 = instruments.get(guiCh)) {
if (inst0->ownsGuiWindow()) {
std::cerr << "[NativeBridge] GUI via child-own ch=" << guiCh
<< " plugin=" << arg2 << std::endl;
try {
inst0->attachView(nullptr);
} catch (...) {
std::cerr << "[NativeBridge] GUI child-own attach EXCEPTION ch=" << guiCh
<< std::endl;
}
return;
}
}
void* hwnd = (void*)arg1;
#ifdef _WIN32
HWND nativeHwnd = nullptr;
@@ -1577,6 +1595,23 @@ int main(int argc, char* argv[]) {
auto doAssign = std::function<void()>(
[&instruments, t, ch, path, sampleRate, block, spOld, spNew]() {
std::lock_guard<std::mutex> lg(g_loadMutex);
// G4.6: deferred double-load re-check. Restore's
// doRestore (also deferred via runOnMain) can assign
// the SAME path FIRST — a second assign here replaces
// the restored instance with a seed-only one (doAssign
// applies NO preset) and clobbers the real patch in
// the state file (ch3 63ba3667 -> 7f07473c observed).
// Skip like the job-level guard; undo the silence.
if (!path.empty() && !lower_plugin_path(ch).empty() &&
lower_plugin_path(ch) == lower_path_str(path)) {
std::cout << "[NativeBridge] LOAD skip (deferred re-check) ch=" << ch
<< " path=" << path << std::endl;
g_engine->setReloading(ch, false);
for (uint32_t y : spOld) unmute_if_not_closing(y, "load-skip");
for (uint32_t y : spNew) unmute_if_not_closing(y, "load-skip");
g_stateDirty = true;
return;
}
bool ok = false;
try {
ok = instruments.assign(ch, t, path, sampleRate, block);
+49 -14
View File
@@ -107,6 +107,19 @@ int main(int argc, char* argv[]) {
}
INativeInstrument& inst = vst2 ? (INativeInstrument&)instV2 : (INativeInstrument&)instV3;
// G4.6 auto-play fix: a loaded chunk can embed a stuck/sustained note
// (factory state, seed, or a patch captured mid-note). Release everything
// right after a preset apply so the channel starts silent instead of
// sounding on app open (STOP later releases it too, but startup must not
// play).
auto panicAfterPreset = [&inst, channel]() {
inst.controlChange(0, 64, 0); // sustain pedal off
inst.controlChange(0, 123, 0); // all notes off
for (uint32_t p = 0; p < 128; ++p) inst.noteOff(0, p, 0);
printf("[plugin_host] panic after preset ch=%u\n", channel);
fflush(stdout);
};
// Optional preset restore: Qin_RV and other jBridge romplers render SILENT
// from their factory state - they need their program/bank chunk. The bridge
// forwards presetBase64 (base64 text, one line) via a temp file.
@@ -145,15 +158,28 @@ int main(int argc, char* argv[]) {
// G4.5: publish the initial chunk so the bridge can persist it even
// without a preset round-trip yet (factory state = the silent chunk;
// the GUI or a presetIn apply replaces it later).
std::string lastOut;
inst.captureState();
{
std::string out = inst.serializeState();
if (!out.empty() && out.size() < PRESET_BLOB_SIZE) {
memcpy((void*)ipc->presetOut, out.data(), out.size());
ipc->presetOutLen = (uint32_t)out.size();
lastOut = out;
printf("[plugin_host] initial preset published ch=%u (%uB)\n", channel, ipc->presetOutLen);
fflush(stdout);
}
// G4.6 auto-play fix (clobber-safe): release stuck notes AFTER the real
// chunk is already published, then absorb the panic-mutated chunk so the
// periodic republish never echoes it back over presetOut. Startup stays
// silent AND the state file keeps the real patch.
panicAfterPreset();
inst.captureState();
{
std::string postPanic = inst.serializeState();
if (!postPanic.empty()) lastOut = postPanic;
}
}
// Heartbeat ~10Hz, independent of the audio loop (G3.1 pattern).
@@ -168,7 +194,6 @@ int main(int argc, char* argv[]) {
bool idle = false;
bool quit = false;
uint32_t lastCaptureTick = 0;
std::string lastOut;
for (;;) {
MSG msg;
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
@@ -213,19 +238,29 @@ int main(int argc, char* argv[]) {
fflush(stdout);
ipc->presetInLen = 0;
} else {
std::string b64(ipc->presetIn, ipc->presetInLen);
inst.loadSerializedState(b64);
printf("[plugin_host] preset applied from SHM ch=%u (%uB)\n", channel, ipc->presetInLen);
fflush(stdout);
ipc->presetInLen = 0;
// republish immediately so a just-after restore save has it
inst.captureState();
std::string out = inst.serializeState();
if (!out.empty() && out.size() < PRESET_BLOB_SIZE) {
memcpy((void*)ipc->presetOut, out.data(), out.size());
ipc->presetOutLen = (uint32_t)out.size();
lastOut = out;
}
// consume-before-apply: copy and zero presetInLen FIRST so the
// bridge can safely queue the next preset while this plugin
// applies the current one (prevents the restore clobber race).
uint32_t plen = ipc->presetInLen;
std::string b64(ipc->presetIn, plen);
ipc->presetInLen = 0;
inst.loadSerializedState(b64);
printf("[plugin_host] preset applied from SHM ch=%u (%uB)\n", channel, (unsigned)plen);
fflush(stdout);
// republish immediately so a just-after restore save has it
inst.captureState();
std::string out = inst.serializeState();
if (!out.empty() && out.size() < PRESET_BLOB_SIZE) {
memcpy((void*)ipc->presetOut, out.data(), out.size());
ipc->presetOutLen = (uint32_t)out.size();
lastOut = out;
}
panicAfterPreset();
// absorb the panic-mutated chunk so the ~2s periodic republish
// never overwrites presetOut with it
inst.captureState();
std::string postPanic = inst.serializeState();
if (!postPanic.empty()) lastOut = postPanic;
}
}
if (++lastCaptureTick >= 200) { // ~2s of loop passes