G2.0: StateStore (JSON state file + atomic write)
This commit is contained in:
@@ -58,6 +58,7 @@ add_executable(daw_vst_bridge
|
||||
src/NativeInstrumentEngine.cpp
|
||||
src/SharedMemoryIPC.cpp
|
||||
src/Vst3Instrument.cpp
|
||||
src/StateStore.cpp
|
||||
)
|
||||
|
||||
# VST3 SDK hosting: sdk_hosting lacks the platform module loader — module_win32.cpp
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// native_bridge/debug/test_state_store.cpp
|
||||
// G2.0 self-check: StateStore save/load/clear round-trip (not part of bridge).
|
||||
// Build: cl /nologo /EHsc /std:c++17 /I native_bridge/include \
|
||||
// debug/test_state_store.cpp native_bridge/src/StateStore.cpp
|
||||
#include "StateStore.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
int main() {
|
||||
const char* path = "state_roundtrip_test.json";
|
||||
|
||||
BridgeStateSnapshot snap;
|
||||
snap.playing = true;
|
||||
snap.playhead = 123.456789;
|
||||
|
||||
InstrumentStateEntry a;
|
||||
a.channel = 0; a.type = 0; // VST3
|
||||
a.path = "C:\\Program Files\\Common Files\\VST3\\Nexus.vst3";
|
||||
a.presetBase64 = "AB+//w=="; // base64 chars incl + / =
|
||||
a.bank = 0; a.program = 2;
|
||||
|
||||
InstrumentStateEntry b;
|
||||
b.channel = 3; b.type = 2; // SF2/SF3
|
||||
b.path = "C:\\sounds\\my bank.sf2";
|
||||
b.presetBase64 = "line1\nline2\t\"quoted\"\\tail";
|
||||
b.bank = 1; b.program = 5;
|
||||
|
||||
snap.instruments.push_back(a);
|
||||
snap.instruments.push_back(b);
|
||||
|
||||
assert(StateStore::save(snap, path));
|
||||
|
||||
// Atomic write: no .tmp leftover after save.
|
||||
FILE* tmp = std::fopen((std::string(path) + ".tmp").c_str(), "rb");
|
||||
assert(tmp == nullptr);
|
||||
if (tmp) std::fclose(tmp);
|
||||
|
||||
BridgeStateSnapshot out;
|
||||
assert(StateStore::load(out, path));
|
||||
assert(out.playing == true);
|
||||
assert(out.playhead == 123.456789);
|
||||
assert(out.instruments.size() == 2);
|
||||
assert(out.instruments[0].channel == 0);
|
||||
assert(out.instruments[0].type == 0);
|
||||
assert(out.instruments[0].path == "C:\\Program Files\\Common Files\\VST3\\Nexus.vst3");
|
||||
assert(out.instruments[0].presetBase64 == "AB+//w==");
|
||||
assert(out.instruments[0].bank == 0 && out.instruments[0].program == 2);
|
||||
assert(out.instruments[1].channel == 3);
|
||||
assert(out.instruments[1].type == 2);
|
||||
assert(out.instruments[1].path == "C:\\sounds\\my bank.sf2");
|
||||
assert(out.instruments[1].presetBase64 == "line1\nline2\t\"quoted\"\\tail");
|
||||
assert(out.instruments[1].bank == 1 && out.instruments[1].program == 5);
|
||||
|
||||
// Missing file -> load false, no crash.
|
||||
assert(!StateStore::load(out, "no_such_file.json"));
|
||||
|
||||
// clear removes the file.
|
||||
assert(StateStore::clear(path));
|
||||
assert(!StateStore::load(out, path));
|
||||
|
||||
std::printf("state_store round-trip PASS\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// native_bridge/include/StateStore.h
|
||||
// G2: persist bridge instrument state to a JSON file so a bridge respawn
|
||||
// (crash, kill, restart) can reload instruments and continue playing.
|
||||
#ifndef STATE_STORE_H
|
||||
#define STATE_STORE_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// One instrument slot persisted to disk.
|
||||
struct InstrumentStateEntry {
|
||||
uint32_t channel = 0;
|
||||
int type = 0; // InstrumentType as int (0=VST3, 1=VST2, 2=SF2/SF3, 3=SFZ)
|
||||
std::string path; // absolute plugin/soundfont path
|
||||
std::string presetBase64; // VST3 serialized preset (empty for non-VST)
|
||||
uint32_t bank = 0; // last bank select (SF2/SF3)
|
||||
uint32_t program = 0; // last program change
|
||||
};
|
||||
|
||||
// Whole-bridge snapshot written to disk.
|
||||
struct BridgeStateSnapshot {
|
||||
std::vector<InstrumentStateEntry> instruments;
|
||||
bool playing = false;
|
||||
double playhead = 0.0;
|
||||
};
|
||||
|
||||
// Hand-rolled JSON persistence (no external JSON dependency).
|
||||
// File format: {"version":1,"playing":bool,"playhead":num,"instruments":[...]}
|
||||
// Writes are atomic: temp file + rename (crash mid-write never corrupts state).
|
||||
class StateStore {
|
||||
public:
|
||||
// %APPDATA%/SonicForgeDAW/state/bridge_state.json (UTF-8).
|
||||
static std::string defaultPath();
|
||||
|
||||
// Serialize snapshot to `path` (atomic). Returns false on IO error.
|
||||
static bool save(const BridgeStateSnapshot& snap, const std::string& path);
|
||||
// Read `path` into `out`; returns false if missing or unparsable.
|
||||
static bool load(BridgeStateSnapshot& out, const std::string& path);
|
||||
// Remove the state file (and any stale .tmp). Returns false if missing.
|
||||
static bool clear(const std::string& path);
|
||||
};
|
||||
|
||||
#endif // STATE_STORE_H
|
||||
@@ -0,0 +1,261 @@
|
||||
// native_bridge/src/StateStore.cpp
|
||||
#include "StateStore.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
// Escape a string for JSON output (keeps UTF-8 bytes >= 0x20 as-is).
|
||||
std::string jsonEscape(const std::string& s) {
|
||||
std::string out;
|
||||
out.reserve(s.size() + 16);
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof(buf), "\\u%04x", c);
|
||||
out += buf;
|
||||
} else {
|
||||
out += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 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;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Find the value after `"key":` starting at `from`. Returns pointer at the
|
||||
// first value char (after ws), or nullptr.
|
||||
const char* findValue(const std::string& s, const char* key, size_t from = 0) {
|
||||
size_t keyLen = std::strlen(key);
|
||||
size_t pos = s.find(key, from);
|
||||
while (pos != std::string::npos) {
|
||||
if (pos > 0 && s[pos - 1] == '"') {
|
||||
size_t colon = s.find(':', pos + keyLen);
|
||||
if (colon != std::string::npos)
|
||||
return skipWs(s.c_str() + colon + 1);
|
||||
}
|
||||
pos = s.find(key, pos + 1);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Parse a quoted JSON string starting at *pp (which must point at '"').
|
||||
// Advances *pp past the closing quote. Returns false on malformed input.
|
||||
bool parseString(const char** pp, std::string& out) {
|
||||
const char* p = *pp;
|
||||
if (!p || *p != '"') return false;
|
||||
++p;
|
||||
out.clear();
|
||||
while (*p && *p != '"') {
|
||||
if (*p == '\\') {
|
||||
++p;
|
||||
switch (*p) {
|
||||
case '"': out += '"'; ++p; break;
|
||||
case '\\': out += '\\'; ++p; break;
|
||||
case '/': out += '/'; ++p; break;
|
||||
case 'n': out += '\n'; ++p; break;
|
||||
case 'r': out += '\r'; ++p; break;
|
||||
case 't': out += '\t'; ++p; break;
|
||||
case 'u': {
|
||||
unsigned v = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
++p;
|
||||
if (!*p) return false;
|
||||
char c = *p;
|
||||
v <<= 4;
|
||||
if (c >= '0' && c <= '9') v |= (unsigned)(c - '0');
|
||||
else if (c >= 'a' && c <= 'f') v |= (unsigned)(c - 'a' + 10);
|
||||
else if (c >= 'A' && c <= 'F') v |= (unsigned)(c - 'A' + 10);
|
||||
else return false;
|
||||
}
|
||||
out += (char)(v & 0xFF);
|
||||
break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
} else {
|
||||
out += *p;
|
||||
++p;
|
||||
}
|
||||
}
|
||||
if (*p != '"') return false;
|
||||
*pp = p + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse an integer until delimiter , } ] ws. Returns false on empty.
|
||||
bool parseIntAt(const char** pp, long& out) {
|
||||
const char* p = skipWs(*pp);
|
||||
if (!p || !*p) return false;
|
||||
long sign = 1;
|
||||
if (*p == '-') { sign = -1; ++p; }
|
||||
if (*p < '0' || *p > '9') return false;
|
||||
long v = 0;
|
||||
while (*p >= '0' && *p <= '9') { v = v * 10 + (*p - '0'); ++p; }
|
||||
out = sign * v;
|
||||
*pp = p;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse a double until delimiter , } ] ws.
|
||||
bool parseDoubleAt(const char** pp, double& out) {
|
||||
const char* p = skipWs(*pp);
|
||||
const char* start = p;
|
||||
while (*p && *p != ',' && *p != '}' && *p != ']' &&
|
||||
*p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') ++p;
|
||||
if (p == start) return false;
|
||||
out = std::atof(std::string(start, p - start).c_str());
|
||||
*pp = p;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string StateStore::defaultPath() {
|
||||
#ifdef _WIN32
|
||||
const char* appData = std::getenv("APPDATA");
|
||||
std::string base = appData && *appData ? appData : ".";
|
||||
return base + "\\SonicForgeDAW\\state\\bridge_state.json";
|
||||
#else
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string base = home && *home ? home : ".";
|
||||
return base + "/.sonicforge/state/bridge_state.json";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool StateStore::save(const BridgeStateSnapshot& snap, const std::string& path) {
|
||||
std::string json;
|
||||
json.reserve(256 + snap.instruments.size() * 192);
|
||||
json += "{\n";
|
||||
json += " \"version\": 1,\n";
|
||||
json += " \"playing\": ";
|
||||
json += snap.playing ? "true" : "false";
|
||||
json += ",\n";
|
||||
json += " \"playhead\": ";
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "%.6f", snap.playhead);
|
||||
json += buf;
|
||||
json += ",\n";
|
||||
json += " \"instruments\": [\n";
|
||||
for (size_t i = 0; i < snap.instruments.size(); ++i) {
|
||||
const InstrumentStateEntry& e = snap.instruments[i];
|
||||
json += " {\n";
|
||||
json += " \"channel\": " + std::to_string(e.channel) + ",\n";
|
||||
json += " \"type\": " + std::to_string(e.type) + ",\n";
|
||||
json += " \"path\": \"" + jsonEscape(e.path) + "\",\n";
|
||||
json += " \"presetBase64\": \"" + jsonEscape(e.presetBase64) + "\",\n";
|
||||
json += " \"bank\": " + std::to_string(e.bank) + ",\n";
|
||||
json += " \"program\": " + std::to_string(e.program) + "\n";
|
||||
json += " }";
|
||||
if (i + 1 < snap.instruments.size()) json += ",";
|
||||
json += "\n";
|
||||
}
|
||||
json += " ]\n";
|
||||
json += "}\n";
|
||||
|
||||
// Atomic write: temp file in same dir, then rename over the target.
|
||||
const std::string tmp = path + ".tmp";
|
||||
{
|
||||
std::ofstream f(tmp, std::ios::binary | std::ios::trunc);
|
||||
if (!f) return false;
|
||||
f.write(json.data(), (std::streamsize)json.size());
|
||||
f.flush();
|
||||
if (!f) return false;
|
||||
f.close();
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExA(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) {
|
||||
// Fallback: direct write if rename failed (e.g. AV lock on target).
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
if (!f) return false;
|
||||
f.write(json.data(), (std::streamsize)json.size());
|
||||
return (bool)f;
|
||||
}
|
||||
#else
|
||||
if (std::rename(tmp.c_str(), path.c_str()) != 0) return false;
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StateStore::load(BridgeStateSnapshot& out, const std::string& path) {
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) return false;
|
||||
std::ostringstream ss;
|
||||
ss << f.rdbuf();
|
||||
const std::string json = ss.str();
|
||||
if (json.empty()) return false;
|
||||
|
||||
BridgeStateSnapshot snap;
|
||||
const char* p;
|
||||
|
||||
if ((p = findValue(json, "playing")) != nullptr) {
|
||||
p = skipWs(p);
|
||||
if (std::strncmp(p, "true", 4) == 0) snap.playing = true;
|
||||
else if (std::strncmp(p, "false", 5) == 0) snap.playing = false;
|
||||
else return false;
|
||||
}
|
||||
if ((p = findValue(json, "playhead")) != nullptr) {
|
||||
double v = 0.0;
|
||||
if (!parseDoubleAt(&p, v)) return false;
|
||||
snap.playhead = v;
|
||||
}
|
||||
|
||||
// Parse the instruments array object by object.
|
||||
const char* arr = findValue(json, "instruments");
|
||||
if (arr && *arr == '[') {
|
||||
const char* q = arr + 1;
|
||||
while (*q && *q != ']') {
|
||||
q = skipWs(q);
|
||||
if (*q == ',') { ++q; continue; }
|
||||
if (*q != '{') { if (*q == ']') break; return false; }
|
||||
// Our writer never nests braces inside an entry.
|
||||
const char* objEnd = std::strchr(q, '}');
|
||||
if (!objEnd) return false;
|
||||
std::string obj(q, objEnd - q + 1);
|
||||
q = objEnd + 1;
|
||||
|
||||
InstrumentStateEntry e;
|
||||
if ((p = findValue(obj, "channel"))) { long v; if (parseIntAt(&p, v)) e.channel = (uint32_t)v; }
|
||||
if ((p = findValue(obj, "type"))) { long v; if (parseIntAt(&p, v)) e.type = (int)v; }
|
||||
if ((p = findValue(obj, "path"))) { std::string s; if (parseString(&p, s)) e.path = s; }
|
||||
if ((p = findValue(obj, "presetBase64"))){ std::string s; if (parseString(&p, s)) e.presetBase64 = s; }
|
||||
if ((p = findValue(obj, "bank"))) { long v; if (parseIntAt(&p, v)) e.bank = (uint32_t)v; }
|
||||
if ((p = findValue(obj, "program"))) { long v; if (parseIntAt(&p, v)) e.program = (uint32_t)v; }
|
||||
snap.instruments.push_back(e);
|
||||
}
|
||||
}
|
||||
out = snap;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StateStore::clear(const std::string& path) {
|
||||
#ifdef _WIN32
|
||||
bool ok = DeleteFileA(path.c_str()) != 0;
|
||||
DeleteFileA((path + ".tmp").c_str());
|
||||
return ok;
|
||||
#else
|
||||
bool ok = std::remove(path.c_str()) == 0;
|
||||
std::remove((path + ".tmp").c_str());
|
||||
return ok;
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user