diff --git a/20260815-tasks.md b/20260815-tasks.md index 7a29e92..47a8f00 100644 --- a/20260815-tasks.md +++ b/20260815-tasks.md @@ -150,7 +150,7 @@ Lý do: G2.2 phụ thuộc G2.1 nhưng giá trị thấp hơn G2.3; làm G2.3 s | ID | Cài đặt | Kiểm tra | Bugs bắt được | Fix | Trạng thái | |---|---|---|---|---|---| | G2.0 | StateStore.h/.cpp (JSON thủ công, atomic tmp+rename, UTF-8, API save/load/clear, defaultPath=%APPDATA%/SonicForgeDAW/state/bridge_state.json); them vao CMakeLists target daw_vst_bridge; test_state_store.cpp (round-trip self-check) | Build PASS (daw_vst_bridge.exe), round-trip PASS (save/load 2 inst: path Windows co space+backslash, presetBase64 co newline/quote/backslash; khong con .tmp; clear xoa file) | — (module moi, chua hook vao bridge) | — | ✅ (Win xong) | -| G2.1 | | | | | ⬜ | +| G2.1 | main.cpp: globals g_stateDirty/g_transportPlaying/g_lastSnapMutex/g_lastSnap, snapshotsEqual (bo playhead); stateFile = SF_STATE_FILE fallback defaultPath; playheadSamples -> std::atomic; saveStateJob (UiThread, skip khi isReloading, chi save khi snapshot doi); TRANSPORT set g_transportPlaying + dirty; cuoi LOAD dirty=true; periodic 2s dirty + debounce 300ms. Vst3Instrument.h: khai bao bankOf/programOf. StateStore.cpp: ensureParentDir() (CreateDirectoryA chain / mkdir) goi dau save(). | Build PASS (daw_vst_bridge.exe). Deploy PASS (PID moi: sonicforge-daw 9876, daw_engine 24632, daw_vst_bridge 7352). Verify live PASS: LOAD Nexus ch5 + PLAY -> bridge_state.json co 7 instrument (ch0-6 frontend da load), playing=true, presetBase64 non-empty (Nexus 3468/3488, EZkeys 24764); STOP -> playing=false. State round-trip test_state_store.cpp PASS. | test script ban dau ky vong dung 1 instrument (frontend da load 7) -> FAIL gia, chuc nang PASS | assert dung so instrument | ✅ (Win xong) | | G2.2 | | | | | ⬜ | | G2.3 | | | | | ⬜ | | G2.4 | | | | | ⬜ | @@ -160,6 +160,7 @@ Lý do: G2.2 phụ thuộc G2.1 nhưng giá trị thấp hơn G2.3; làm G2.3 s ### Nhật ký thực hiện - **G2.0** (2026-08-16, Windows): tao `native_bridge/include/StateStore.h` + `native_bridge/src/StateStore.cpp` (CRLF, JSON viet tay, atomic write .tmp→rename, escape \ " , load parser an toan). Them StateStore.cpp vao CMakeLists target daw_vst_bridge. Build bridge PASS. Test round-trip `native_bridge/debug/test_state_store.cpp` PASS (compile cl vcvars64 + chay). Commit `G2.0: StateStore (JSON state file + atomic write)`. +- **G2.1** (2026-08-16, Windows): save day du state ra disk. main.cpp: globals + snapshotsEqual + stateFile env fallback + playheadSamples atomic + saveStateJob (UiThread, skip isReloading, chi ghi khi snapshot doi) + hook TRANSPORT/LOAD + periodic 2s dirty debounce 300ms. Them khai bao bankOf/programOf vao Vst3Instrument.h (thieu declaration -> build fail). StateStore.cpp: ensureParentDir() (CreateDirectoryA chain, Linux mkdir) goi dau save() (fix C2001 do heredoc backslash mangle qua script fix_bs.py). Build PASS. Deploy PASS (PID 9876/24632/7352). Verify live PASS: LOAD Nexus ch5 + PLAY -> state file 7 inst + playing=true + presetBase64 day du; STOP -> playing=false. StateStore round-trip test PASS. Commit `G2.1: save full instrument state to disk (path/type/preset/transport)`. --- diff --git a/install/daw_vst_bridge.exe b/install/daw_vst_bridge.exe index 57fcc64..15b33fd 100644 Binary files a/install/daw_vst_bridge.exe and b/install/daw_vst_bridge.exe differ diff --git a/native_bridge/include/INativeInstrument.h b/native_bridge/include/INativeInstrument.h index b696c41..65f1072 100644 --- a/native_bridge/include/INativeInstrument.h +++ b/native_bridge/include/INativeInstrument.h @@ -60,6 +60,14 @@ public: // (GUI close/reopen). Non-VST engines: no-op (nothing to save). virtual void captureState() {} virtual void restoreState() {} + // G2.1: serialize the current preset to a portable string for the state + // file (VST3: base64 of captured component+controller state). Caller must + // call captureState() first (VST3 buffers are refreshed there). Non-VST + // engines: empty. + virtual std::string serializeState() const { return std::string(); } + // G2.1: last bank/program for a MIDI channel (SF2/SF3). Others: 0. + virtual uint32_t bankOf(uint32_t channel) const { return 0; } + virtual uint32_t programOf(uint32_t channel) const { return 0; } // Real-time Audio PCM Float32 rendering loop virtual void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) = 0; diff --git a/native_bridge/include/NativeInstrumentEngine.h b/native_bridge/include/NativeInstrumentEngine.h index 6aaec3a..2007652 100644 --- a/native_bridge/include/NativeInstrumentEngine.h +++ b/native_bridge/include/NativeInstrumentEngine.h @@ -3,6 +3,7 @@ #define NATIVE_INSTRUMENT_ENGINE_H #include "INativeInstrument.h" +#include "StateStore.h" #include @@ -31,6 +32,9 @@ public: void pitchBend(uint32_t channel, uint32_t bend14) override; bool openGUI(void* parentWindowHandle) override; void closeGUI() override; + // G2.1: expose last bank/program for the state snapshot. + uint32_t bankOf(uint32_t channel) const override; + uint32_t programOf(uint32_t channel) const override; void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override; private: @@ -40,6 +44,7 @@ private: // Per-channel bank select (CC0<<7 | CC32) — programChange phai dung bank // that (truoc day hardcode bank 0 -> preset o bank != 0 khong chon duoc). uint32_t bank_[16]; + uint32_t program_[16]; }; // sfizz (.sfz) @@ -97,6 +102,14 @@ public: // case-insensitive — caller must lowercase before comparing. std::string pathOf(uint32_t channel); + // G2.1: copy every assigned channel into a state snapshot (channel, + // type, path, VST3 preset blob -> base64, SF2/SF3 bank/program). + // MUST run on the channel worker (captureState enters the plugin); + // takes mu_ for the whole call so the audio loop cannot be inside + // process() on the same instance while its state is captured. + // Channels mid-reload are skipped (their old instance is dying). + bool snapshot(BridgeStateSnapshot& out); + // Mark a channel as rebuilding its VST instance (GUI reopen): the // real-time loop then skips processAudioBlock for that channel so VST // teardown never races the audio thread. @@ -128,6 +141,7 @@ private: mutable std::mutex mu_; std::map> channels_; std::map paths_; // last assigned path per channel + std::map types_; // last assigned type per channel (G2.1) // Per-instrument scratch so engines that overwrite (not mix) stay additive. std::vector scratchL_, scratchR_; // Channel-level reload flag (see isReloading) — indexed by MIDI channel. diff --git a/native_bridge/include/Vst3Instrument.h b/native_bridge/include/Vst3Instrument.h index 5da8f45..05e9bb9 100644 --- a/native_bridge/include/Vst3Instrument.h +++ b/native_bridge/include/Vst3Instrument.h @@ -32,6 +32,9 @@ public: // instance from scratch — without save/restore the plugin resets). void captureState(); void restoreState(); + // G2.1: base64 of [4B BE componentLen][component][4B BE controllerLen][controller] + // from the last captureState(). Empty when nothing captured. + std::string serializeState() const override; void resizeView(int w, int h) override; bool hasAttachedView() const override; void closeGUI() override; diff --git a/native_bridge/src/NativeInstrumentEngine.cpp b/native_bridge/src/NativeInstrumentEngine.cpp index 0300910..983cd55 100644 --- a/native_bridge/src/NativeInstrumentEngine.cpp +++ b/native_bridge/src/NativeInstrumentEngine.cpp @@ -43,6 +43,7 @@ bool FluidSynthInstrument::loadSoundFontFile(const std::string& path, double sam // Reset all channels to font preset 0 (spec §VII: bank0/prog0 piano) for (uint32_t ch = 0; ch < 16; ++ch) { bank_[ch] = 0; + program_[ch] = 0; fluid_synth_program_select(FS_SYNTH, ch, sfontId, 0, 0); } return true; @@ -55,6 +56,7 @@ bool FluidSynthInstrument::init(double sampleRate, uint32_t maxBlockSize) { void FluidSynthInstrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) { if (!synth || channel >= 16) return; bank_[channel] = bank; + program_[channel] = program; fluid_synth_program_select(FS_SYNTH, channel, sfontId, bank, program); } @@ -81,6 +83,7 @@ void FluidSynthInstrument::programChange(uint32_t channel, uint32_t program) { if (!synth || channel >= 16) return; // Dung bank da nhan tu CC0/CC32 — bank hardcode 0 lam preset o bank != 0 // khong duoc chon (fluid giu preset cu -> ra piano sai). + program_[channel] = program; fluid_synth_program_select(FS_SYNTH, channel, sfontId, bank_[channel], program); } @@ -96,6 +99,15 @@ bool FluidSynthInstrument::openGUI(void* parentWindowHandle) { void FluidSynthInstrument::closeGUI() {} +// G2.1: expose last bank/program for the state snapshot. +uint32_t FluidSynthInstrument::bankOf(uint32_t channel) const { + return channel < 16 ? bank_[channel] : 0; +} + +uint32_t FluidSynthInstrument::programOf(uint32_t channel) const { + return channel < 16 ? program_[channel] : 0; +} + void FluidSynthInstrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) { if (!synth) return; fluid_synth_write_float(FS_SYNTH, numSamples, outputL, 0, 1, outputR, 0, 1); @@ -191,6 +203,7 @@ bool InstrumentEngineManager::assign(uint32_t channel, InstrumentType type, if (it != channels_.end()) oldInst = std::move(it->second); channels_[channel] = std::move(inst); paths_[channel] = path; + types_[channel] = type; // Fresh instance is by construction not reloading — clear the channel // flag so real-time MIDI dispatch (which drops reloading channels) // flows to it again. @@ -221,6 +234,7 @@ void InstrumentEngineManager::unload(uint32_t channel) { if (it != channels_.end()) oldInst = std::move(it->second); channels_.erase(channel); paths_.erase(channel); + types_.erase(channel); reloadingCh_[channel] = false; } if (oldInst) { @@ -242,6 +256,32 @@ std::string InstrumentEngineManager::pathOf(uint32_t channel) { return it == paths_.end() ? std::string() : it->second; } +bool InstrumentEngineManager::snapshot(BridgeStateSnapshot& out) { + std::lock_guard lock(mu_); + out.instruments.clear(); + for (auto& kv : channels_) { + uint32_t ch = kv.first; + if (ch < 16 && reloadingCh_[ch]) continue; // old instance dying + InstrumentStateEntry e; + e.channel = ch; + auto tp = types_.find(ch); + e.type = tp == types_.end() ? 0 : (int)tp->second; + auto pp = paths_.find(ch); + e.path = pp == paths_.end() ? std::string() : pp->second; + if (INativeInstrument* i = kv.second.get()) { + // VST3: refresh the saved buffers then serialize them. Safe + // here: mu_ is held, so the audio loop cannot be inside + // process() on this instance while getState runs. + i->captureState(); + e.presetBase64 = i->serializeState(); + e.bank = i->bankOf(ch); + e.program = i->programOf(ch); + } + out.instruments.push_back(std::move(e)); + } + return true; +} + INativeInstrument* InstrumentEngineManager::get(uint32_t channel) { std::lock_guard lock(mu_); auto it = channels_.find(channel); diff --git a/native_bridge/src/StateStore.cpp b/native_bridge/src/StateStore.cpp index 2f04295..f0736ec 100644 --- a/native_bridge/src/StateStore.cpp +++ b/native_bridge/src/StateStore.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #ifdef _WIN32 #include @@ -37,6 +39,30 @@ std::string jsonEscape(const std::string& s) { return out; } +// Create the parent directory of `path` (all missing levels). Returns false +// only on a hard error (permission, invalid path). +bool ensureParentDir(const std::string& path) { + const size_t slash = path.find_last_of("/\\"); + if (slash == std::string::npos) return true; + const std::string dir = path.substr(0, slash); + if (dir.empty()) return true; +#ifdef _WIN32 + // CreateDirectoryA creates one level at a time - walk the chain so + // %APPDATA%\SonicForgeDAW\state (or a custom SF_STATE_FILE dir) is + // created even when none of it exists yet. + for (size_t i = 0; i < dir.size(); ++i) { + if ((dir[i] == '/' || dir[i] == '\\') && i > 0) + CreateDirectoryA(dir.substr(0, i).c_str(), nullptr); + } + if (CreateDirectoryA(dir.c_str(), nullptr)) return true; + return GetLastError() == ERROR_ALREADY_EXISTS; +#else + for (size_t i = 1; i < dir.size(); ++i) + if (dir[i] == '/') mkdir(dir.substr(0, i).c_str(), 0755); + return mkdir(dir.c_str(), 0755) == 0 || errno == EEXIST; +#endif +} + // Skip whitespace; returns pointer at first non-ws char. const char* skipWs(const char* p) { while (p && *p && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) ++p; @@ -144,6 +170,7 @@ std::string StateStore::defaultPath() { } bool StateStore::save(const BridgeStateSnapshot& snap, const std::string& path) { + if (!ensureParentDir(path)) return false; std::string json; json.reserve(256 + snap.instruments.size() * 192); json += "{\n"; diff --git a/native_bridge/src/Vst3Instrument.cpp b/native_bridge/src/Vst3Instrument.cpp index ba419ad..e357fcd 100644 --- a/native_bridge/src/Vst3Instrument.cpp +++ b/native_bridge/src/Vst3Instrument.cpp @@ -549,6 +549,44 @@ void Vst3Instrument::captureState() { #endif } +// G2.1: base64 encode (RFC 4648). Small, dependency-free. +static std::string base64Encode(const uint8_t* data, size_t len) { + static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve(((len + 2) / 3) * 4); + for (size_t i = 0; i < len; i += 3) { + uint32_t n = (uint32_t)data[i] << 16; + if (i + 1 < len) n |= (uint32_t)data[i + 1] << 8; + if (i + 2 < len) n |= (uint32_t)data[i + 2]; + out += tbl[(n >> 18) & 63]; + out += tbl[(n >> 12) & 63]; + out += (i + 1 < len) ? tbl[(n >> 6) & 63] : '='; + out += (i + 2 < len) ? tbl[n & 63] : '='; + } + return out; +} + +std::string Vst3Instrument::serializeState() const { +#ifndef HAVE_VST3SDK + return std::string(); +#else + if (savedComponentState_.empty() && savedControllerState_.empty()) + return std::string(); + std::vector blob; + auto append32 = [&blob](uint32_t v) { + blob.push_back((uint8_t)(v >> 24)); + blob.push_back((uint8_t)(v >> 16)); + blob.push_back((uint8_t)(v >> 8)); + blob.push_back((uint8_t)v); + }; + append32((uint32_t)savedComponentState_.size()); + blob.insert(blob.end(), savedComponentState_.begin(), savedComponentState_.end()); + append32((uint32_t)savedControllerState_.size()); + blob.insert(blob.end(), savedControllerState_.begin(), savedControllerState_.end()); + return base64Encode(blob.data(), blob.size()); +#endif +} + // G0.4: restore the captured state onto the freshly loaded instance. void Vst3Instrument::restoreState() { #ifndef HAVE_VST3SDK diff --git a/native_bridge/src/main.cpp b/native_bridge/src/main.cpp index 2cc605f..2a08b24 100644 --- a/native_bridge/src/main.cpp +++ b/native_bridge/src/main.cpp @@ -4,6 +4,7 @@ #include "INativeInstrument.h" #include "SharedMemoryIPC.h" #include "NativeInstrumentEngine.h" +#include "StateStore.h" #ifdef _WIN32 #include @@ -200,6 +201,27 @@ static std::mutex g_loadMutex; // so the GUI button works regardless of JS retry timing. static std::mutex g_pendingGuiMutex; static std::map> g_pendingGui; +// G2.1: state persistence. g_stateDirty is set by load/unload/transport/periodic +// marks; the main loop debounces and posts one save job to the UiThread, so disk +// IO never runs on the audio loop. Playing is tracked separately from +// transportStopped because the audio loop must not write files. +static std::atomic g_stateDirty{false}; +static std::atomic g_transportPlaying{false}; +static std::mutex g_lastSnapMutex; +static BridgeStateSnapshot g_lastSnap; +// Same instrument set and transport -> nothing to write. Playhead is excluded: +// comparing it would rewrite the file every ~2s while playing. +static bool snapshotsEqual(const BridgeStateSnapshot& a, const BridgeStateSnapshot& b) { + if (a.playing != b.playing || a.instruments.size() != b.instruments.size()) return false; + for (size_t i = 0; i < a.instruments.size(); ++i) { + const auto& x = a.instruments[i]; + const auto& y = b.instruments[i]; + if (x.channel != y.channel || x.type != y.type || x.path != y.path || + x.presetBase64 != y.presetBase64 || x.bank != y.bank || x.program != y.program) + return false; + } + return true; +} // B9: native Win32 window for the VST editor (replaces the WebView2 surface — // the HTML window was drawn ON TOP of the plugin GUI). MUST be created on the @@ -663,8 +685,12 @@ int main(int argc, char* argv[]) { std::cerr << "[NativeBridge] SHM block size fixed at " << AUDIO_BLOCK_SIZE << " (SF_BLOCK_SIZE=" << b << " ignored)" << std::endl; } + // G2.1: state file. The Rust app sets SF_STATE_FILE; fall back to the default. + std::string stateFile; + if (const char* e = std::getenv("SF_STATE_FILE")) stateFile = e; + if (stateFile.empty()) stateFile = StateStore::defaultPath(); const uint32_t block = AUDIO_BLOCK_SIZE; - uint64_t playheadSamples = 0; + std::atomic playheadSamples{0}; // V8 bug 3: khi STOP da xu ly, bo qua NOTE_ON (velocity>0) den sau - JS // note-on timer co the bay toi sau STOP (guardPlay tre do React re-render) // -> retrigger note -> VST loop am. Chi PLAY moi nhan note-on lai. @@ -995,6 +1021,30 @@ int main(int argc, char* argv[]) { }); }; + // G2.1: persist instrument state to disk. Runs on the UiThread (posted via + // uiWorker.post when g_stateDirty flips) so file IO never blocks the audio + // loop. Deferred while any channel is mid-reload (half-dead instance). + auto saveStateJob = [&]() { + for (uint32_t ch = 0; ch < 16; ++ch) { + if (instruments.isReloading(ch)) { g_stateDirty = true; return; } + } + BridgeStateSnapshot snap; + if (!instruments.snapshot(snap)) return; + snap.playing = g_transportPlaying.load(); + snap.playhead = (double)playheadSamples.load(); + { + std::lock_guard lock(g_lastSnapMutex); + if (snapshotsEqual(snap, g_lastSnap)) return; + g_lastSnap = snap; + } + if (StateStore::save(snap, stateFile)) + std::cout << "[NativeBridge] state saved: " << snap.instruments.size() + << " instruments, playing=" << (snap.playing ? "true" : "false") + << std::endl; + else + std::cerr << "[NativeBridge] state save FAILED: " << stateFile << std::endl; + }; + // 2. REAL-TIME AUDIO PROCESSING ENGINE LOOP while (true) { // CRASH FIX (0xc000041d): last-resort net — any C++ exception that @@ -1140,6 +1190,7 @@ int main(int argc, char* argv[]) { if (!ok) g_engine->setReloading(ch, false); for (uint32_t y : spOld) unmute_if_not_closing(y, "load"); for (uint32_t y : spNew) unmute_if_not_closing(y, "load"); + g_stateDirty = true; // G2.1: instrument changed }); } else if (c.type == 1) { // PANIC instruments.allNotesOff(); @@ -1154,13 +1205,18 @@ int main(int argc, char* argv[]) { if (auto* inst = instruments.get(ch)) inst->controlChange(ch, 64, 0); } instruments.allNotesOff(); + g_transportPlaying = false; + g_stateDirty = true; // G2.1: transport changed std::cout << "[NativeBridge] transport STOP — all notes off" << std::endl; } else if (c.arg0 == 1) { // PLAY transportStopped = false; + g_transportPlaying = true; + g_stateDirty = true; // G2.1: transport changed playheadSamples = c.arg1; std::cout << "[NativeBridge] transport PLAY playhead=" << playheadSamples << std::endl; } else if (c.arg0 == 2) { // SET_POSITION (seek while stopped) playheadSamples = c.arg1; + g_stateDirty = true; // G2.1: transport changed } } else if (c.type == 4) { // OPEN_GUI (A7): arg1 = parent HWND (0 → bridge tự tạo native window), arg2 = plugin id uint32_t guiCh = c.channel; @@ -1188,6 +1244,29 @@ int main(int argc, char* argv[]) { handleOpenGui(g.first, (uintptr_t)g.second.first, g.second.second); } + // G2.1: state persistence. The VST GUI can change the preset with no + // control/MIDI traffic, so mark dirty every ~2s while instruments are + // loaded; a 300ms debounce posts one save job to the UiThread. + { + static auto lastMark = std::chrono::steady_clock::now(); + auto nowMark = std::chrono::steady_clock::now(); + if (std::chrono::duration_cast(nowMark - lastMark).count() >= 2000) { + lastMark = nowMark; + for (uint32_t ch = 0; ch < 16; ++ch) { + if (instruments.get(ch)) { g_stateDirty = true; break; } + } + } + } + if (g_stateDirty.load()) { + static auto lastStatePost = std::chrono::steady_clock::now(); + auto nowPost = std::chrono::steady_clock::now(); + if (std::chrono::duration_cast(nowPost - lastStatePost).count() >= 300) { + lastStatePost = nowPost; + g_stateDirty = false; + uiWorker.post([&]() { saveStateJob(); }); + } + } + // B. Snapshot queued MIDI events (bounded copy, queue reset immediately) uint32_t nEvents = shmIPC->midiQueueCount > 64 ? 64 : shmIPC->midiQueueCount;