G2.1: save full instrument state to disk (path/type/preset/transport)

This commit is contained in:
2026-08-16 07:28:17 +07:00
parent 8d9011222a
commit 28441566c2
9 changed files with 212 additions and 2 deletions
+2 -1
View File
@@ -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<uint64_t>; 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)`.
---
Binary file not shown.
@@ -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;
@@ -3,6 +3,7 @@
#define NATIVE_INSTRUMENT_ENGINE_H
#include "INativeInstrument.h"
#include "StateStore.h"
#include <sfizz.hpp>
@@ -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<uint32_t, std::unique_ptr<INativeInstrument>> channels_;
std::map<uint32_t, std::string> paths_; // last assigned path per channel
std::map<uint32_t, InstrumentType> types_; // last assigned type per channel (G2.1)
// 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.
+3
View File
@@ -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;
@@ -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<std::mutex> 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<std::mutex> lock(mu_);
auto it = channels_.find(channel);
+27
View File
@@ -6,6 +6,8 @@
#include <cstring>
#include <fstream>
#include <sstream>
#include <cerrno>
#include <sys/stat.h>
#ifdef _WIN32
#include <windows.h>
@@ -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";
+38
View File
@@ -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<uint8_t> 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
+80 -1
View File
@@ -4,6 +4,7 @@
#include "INativeInstrument.h"
#include "SharedMemoryIPC.h"
#include "NativeInstrumentEngine.h"
#include "StateStore.h"
#ifdef _WIN32
#include <windows.h>
@@ -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<uint32_t, std::pair<void*, std::string>> 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<bool> g_stateDirty{false};
static std::atomic<bool> 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<uint64_t> 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<std::mutex> 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<std::chrono::milliseconds>(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<std::chrono::milliseconds>(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;