20018c1df2
Pure provenance core (recipe fingerprint, FX-chain identity, parent detection) + shell reads; capture stamps provenance on resample-from-sample; re-capture regenerates a provenanced sample from its source, never touching the timeline. Adds BankIndex/BankBook in-place update. CTest-covered.
705 lines
24 KiB
C++
705 lines
24 KiB
C++
#include "bank_model.h"
|
||
|
||
#include <cctype>
|
||
#include <cerrno>
|
||
#include <cstdio>
|
||
#include <cstdlib>
|
||
#include <cstring>
|
||
|
||
// bank_model implementation.
|
||
//
|
||
// JSON is hand-rolled and self-contained (brief: keep the pure core
|
||
// dependency-free — no third-party JSON lib, no WDL coupling). The field set is
|
||
// a flat struct of primitives, strings, one enum, a small string array, and a
|
||
// few optionals, so a compact writer + recursive-descent parser is the simplest
|
||
// thing that works. Doubles are emitted with 17 significant digits (%.17g), the
|
||
// shortest form that round-trips every IEEE-754 double exactly, so the
|
||
// deserialize(serialize(x)) == x invariant holds bit-for-bit.
|
||
|
||
namespace reasampler {
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// equality
|
||
// ---------------------------------------------------------------------------
|
||
|
||
bool SourceRange::operator==(const SourceRange& o) const {
|
||
return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
|
||
startPpq == o.startPpq && endPpq == o.endPpq;
|
||
}
|
||
|
||
bool Provenance::operator==(const Provenance& o) const {
|
||
return parentSampleId == o.parentSampleId && fxChainSnapshot == o.fxChainSnapshot;
|
||
}
|
||
|
||
bool Levels::operator==(const Levels& o) const {
|
||
return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs;
|
||
}
|
||
|
||
bool Sample::operator==(const Sample& o) const {
|
||
return id == o.id && displayName == o.displayName && relativePath == o.relativePath &&
|
||
sourceMode == o.sourceMode && sourceRange == o.sourceRange &&
|
||
trackGuids == o.trackGuids && wetDry == o.wetDry &&
|
||
channelCount == o.channelCount && sampleRate == o.sampleRate &&
|
||
lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats &&
|
||
captureTempo == o.captureTempo && key == o.key && levels == o.levels &&
|
||
clipped == o.clipped && tier == o.tier && contentHash == o.contentHash &&
|
||
provenance == o.provenance && createdTimestamp == o.createdTimestamp;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// path invariant
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// DECISION: reject absolute paths rather than normalize them. The pure model has
|
||
// no knowledge of the project root, so it cannot correctly relativize an absolute
|
||
// path — any "normalization" would be a guess that could point at the wrong file.
|
||
// Rejecting at the boundary is honest and deterministic; the capture backend (M3)
|
||
// is responsible for handing us an already-relative path. Covers POSIX ("/x"),
|
||
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share")
|
||
// forms. Any leading <alpha>: is rejected regardless of the character that follows —
|
||
// drive-relative paths ("C:foo.wav") resolve against the drive's current directory,
|
||
// not the project root, so they violate the relative-paths-only invariant just as
|
||
// much as "C:\foo.wav" does.
|
||
static bool isAbsolutePath(const std::string& p) {
|
||
if (p.empty()) return false;
|
||
if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC
|
||
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
|
||
return true; // Windows drive (C:\, C:/, C:foo, C:)
|
||
return false;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BankIndex
|
||
// ---------------------------------------------------------------------------
|
||
|
||
AddResult BankIndex::add(const Sample& sample) {
|
||
if (sample.id.empty()) return AddResult::RejectedEmptyId;
|
||
if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath;
|
||
|
||
if (findByHash(sample.contentHash) != nullptr)
|
||
return AddResult::Collapsed;
|
||
|
||
samples_.push_back(sample);
|
||
return AddResult::Added;
|
||
}
|
||
|
||
bool BankIndex::remove(const std::string& id) {
|
||
for (auto it = samples_.begin(); it != samples_.end(); ++it) {
|
||
if (it->id == id) {
|
||
samples_.erase(it);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool BankIndex::updateInPlace(const std::string& id, const Sample& updated) {
|
||
if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds
|
||
for (auto& s : samples_) {
|
||
if (s.id == id) {
|
||
s = updated; // replace in place — position (insertion order) preserved
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const Sample* BankIndex::query(const std::string& id) const {
|
||
for (const auto& s : samples_)
|
||
if (s.id == id) return &s;
|
||
return nullptr;
|
||
}
|
||
|
||
const Sample* BankIndex::findByHash(const std::string& contentHash) const {
|
||
if (contentHash.empty()) return nullptr; // empty hashes never dedup
|
||
for (const auto& s : samples_)
|
||
if (s.contentHash == contentHash) return &s;
|
||
return nullptr;
|
||
}
|
||
|
||
bool BankIndex::moveTier(const std::string& id, Tier tier) {
|
||
for (auto& s : samples_) {
|
||
if (s.id == id) {
|
||
s.tier = tier;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
std::vector<Sample> BankIndex::byTier(Tier tier) const {
|
||
std::vector<Sample> out;
|
||
for (const auto& s : samples_)
|
||
if (s.tier == tier) out.push_back(s);
|
||
return out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// JSON writer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
namespace {
|
||
|
||
void writeEscaped(std::string& out, const std::string& s) {
|
||
out += '"';
|
||
for (char c : s) {
|
||
switch (c) {
|
||
case '"': out += "\\\""; break;
|
||
case '\\': out += "\\\\"; break;
|
||
case '\b': out += "\\b"; break;
|
||
case '\f': out += "\\f"; break;
|
||
case '\n': out += "\\n"; break;
|
||
case '\r': out += "\\r"; break;
|
||
case '\t': out += "\\t"; break;
|
||
default:
|
||
if (static_cast<unsigned char>(c) < 0x20) {
|
||
char buf[8];
|
||
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(c));
|
||
out += buf;
|
||
} else {
|
||
out += c;
|
||
}
|
||
}
|
||
}
|
||
out += '"';
|
||
}
|
||
|
||
std::string numToStr(double v) {
|
||
char buf[32];
|
||
std::snprintf(buf, sizeof(buf), "%.17g", v);
|
||
return buf;
|
||
}
|
||
|
||
std::string numToStr(std::int64_t v) {
|
||
char buf[32];
|
||
std::snprintf(buf, sizeof(buf), "%lld", static_cast<long long>(v));
|
||
return buf;
|
||
}
|
||
|
||
std::string numToStr(int v) { return numToStr(static_cast<std::int64_t>(v)); }
|
||
|
||
class ObjWriter {
|
||
public:
|
||
explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; }
|
||
~ObjWriter() { out_ += '}'; }
|
||
|
||
void keyRaw(const char* key, const std::string& rawValue) {
|
||
sep();
|
||
writeEscaped(out_, key);
|
||
out_ += ':';
|
||
out_ += rawValue;
|
||
}
|
||
void keyStr(const char* key, const std::string& value) {
|
||
sep();
|
||
writeEscaped(out_, key);
|
||
out_ += ':';
|
||
writeEscaped(out_, value);
|
||
}
|
||
// Begin a nested value; caller writes the value immediately after.
|
||
void keyBegin(const char* key) {
|
||
sep();
|
||
writeEscaped(out_, key);
|
||
out_ += ':';
|
||
}
|
||
|
||
private:
|
||
void sep() {
|
||
if (first_) first_ = false; else out_ += ',';
|
||
}
|
||
std::string& out_;
|
||
bool first_ = true;
|
||
};
|
||
|
||
void writeStringArray(std::string& out, const std::vector<std::string>& v) {
|
||
out += '[';
|
||
for (std::size_t i = 0; i < v.size(); ++i) {
|
||
if (i) out += ',';
|
||
writeEscaped(out, v[i]);
|
||
}
|
||
out += ']';
|
||
}
|
||
|
||
void writeSample(std::string& out, const Sample& s) {
|
||
ObjWriter w(out);
|
||
w.keyStr("id", s.id);
|
||
w.keyStr("displayName", s.displayName);
|
||
w.keyStr("relativePath", s.relativePath);
|
||
w.keyRaw("sourceMode", numToStr(static_cast<int>(s.sourceMode)));
|
||
|
||
w.keyBegin("sourceRange");
|
||
{
|
||
ObjWriter r(out);
|
||
r.keyRaw("startSeconds", numToStr(s.sourceRange.startSeconds));
|
||
r.keyRaw("endSeconds", numToStr(s.sourceRange.endSeconds));
|
||
r.keyRaw("startPpq", numToStr(s.sourceRange.startPpq));
|
||
r.keyRaw("endPpq", numToStr(s.sourceRange.endPpq));
|
||
}
|
||
|
||
w.keyBegin("trackGuids");
|
||
writeStringArray(out, s.trackGuids);
|
||
|
||
w.keyRaw("wetDry", numToStr(s.wetDry));
|
||
w.keyRaw("channelCount", numToStr(s.channelCount));
|
||
w.keyRaw("sampleRate", numToStr(s.sampleRate));
|
||
w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds));
|
||
w.keyRaw("lengthBeats", numToStr(s.lengthBeats));
|
||
w.keyRaw("captureTempo", numToStr(s.captureTempo));
|
||
|
||
// Optionals are emitted as null when absent so present/absent round-trips.
|
||
w.keyBegin("key");
|
||
if (s.key) writeEscaped(out, *s.key); else out += "null";
|
||
|
||
w.keyBegin("levels");
|
||
{
|
||
ObjWriter l(out);
|
||
l.keyRaw("peakDb", numToStr(s.levels.peakDb));
|
||
l.keyRaw("rmsDb", numToStr(s.levels.rmsDb));
|
||
l.keyRaw("lufs", numToStr(s.levels.lufs));
|
||
}
|
||
|
||
w.keyRaw("clipped", s.clipped ? "true" : "false");
|
||
w.keyRaw("tier", numToStr(static_cast<int>(s.tier)));
|
||
w.keyStr("contentHash", s.contentHash);
|
||
|
||
w.keyBegin("provenance");
|
||
if (s.provenance) {
|
||
ObjWriter p(out);
|
||
p.keyStr("parentSampleId", s.provenance->parentSampleId);
|
||
p.keyStr("fxChainSnapshot", s.provenance->fxChainSnapshot);
|
||
} else {
|
||
out += "null";
|
||
}
|
||
|
||
w.keyRaw("createdTimestamp", numToStr(s.createdTimestamp));
|
||
}
|
||
|
||
} // namespace
|
||
|
||
std::string BankIndex::serialize() const {
|
||
std::string out;
|
||
{
|
||
ObjWriter root(out);
|
||
root.keyRaw("version", numToStr(1));
|
||
root.keyBegin("samples");
|
||
out += '[';
|
||
for (std::size_t i = 0; i < samples_.size(); ++i) {
|
||
if (i) out += ',';
|
||
writeSample(out, samples_[i]);
|
||
}
|
||
out += ']';
|
||
} // root closes the object here — not deferred to function return (NRVO would
|
||
// otherwise let the caller observe `out` before the closing brace is appended)
|
||
return out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// JSON parser (recursive descent). Returns false on any malformed input; never
|
||
// reads out of bounds. Only supports the subset our writer emits.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
namespace {
|
||
|
||
class Parser {
|
||
public:
|
||
explicit Parser(const std::string& s) : s_(s) {}
|
||
|
||
bool parseIndex(BankIndex& out);
|
||
|
||
private:
|
||
const std::string& s_;
|
||
std::size_t pos_ = 0;
|
||
|
||
bool eof() const { return pos_ >= s_.size(); }
|
||
char peek() const { return s_[pos_]; }
|
||
|
||
void skipWs() {
|
||
while (!eof()) {
|
||
char c = s_[pos_];
|
||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
|
||
else break;
|
||
}
|
||
}
|
||
|
||
bool consume(char c) {
|
||
skipWs();
|
||
if (eof() || s_[pos_] != c) return false;
|
||
++pos_;
|
||
return true;
|
||
}
|
||
|
||
bool parseString(std::string& out);
|
||
bool parseRawScalar(std::string& out); // number / true / false / null token
|
||
bool parseDouble(double& out);
|
||
bool parseInt64(std::int64_t& out);
|
||
bool parseInt(int& out);
|
||
bool parseBool(bool& out);
|
||
bool expectNullOr(bool& wasNull); // peeks for `null`; consumes if present
|
||
|
||
bool parseSample(Sample& out);
|
||
bool parseKey(std::string& key); // an object member key + ':'
|
||
bool skipValue(); // for forward-compat unknown keys
|
||
};
|
||
|
||
// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX
|
||
// for control chars). Positioned at the opening quote after whitespace.
|
||
bool Parser::parseString(std::string& out) {
|
||
skipWs();
|
||
if (eof() || s_[pos_] != '"') return false;
|
||
++pos_;
|
||
out.clear();
|
||
while (!eof()) {
|
||
char c = s_[pos_++];
|
||
if (c == '"') return true;
|
||
if (c == '\\') {
|
||
if (eof()) return false;
|
||
char e = s_[pos_++];
|
||
switch (e) {
|
||
case '"': out += '"'; break;
|
||
case '\\': out += '\\'; break;
|
||
case '/': out += '/'; break;
|
||
case 'b': out += '\b'; break;
|
||
case 'f': out += '\f'; break;
|
||
case 'n': out += '\n'; break;
|
||
case 'r': out += '\r'; break;
|
||
case 't': out += '\t'; break;
|
||
case 'u': {
|
||
// Decode a \uXXXX escape to its code point.
|
||
auto readHex4 = [&](unsigned int& cp) -> bool {
|
||
if (pos_ + 4 > s_.size()) return false;
|
||
cp = 0;
|
||
for (int i = 0; i < 4; ++i) {
|
||
char h = s_[pos_++];
|
||
cp <<= 4;
|
||
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
|
||
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
|
||
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
|
||
else return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
unsigned int hi = 0;
|
||
if (!readHex4(hi)) return false;
|
||
|
||
unsigned int codePoint = hi;
|
||
if (hi >= 0xD800 && hi <= 0xDBFF) {
|
||
// High surrogate — must be followed by \uDC00–\uDFFF.
|
||
if (pos_ + 6 > s_.size()) return false;
|
||
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
|
||
pos_ += 2;
|
||
unsigned int lo = 0;
|
||
if (!readHex4(lo)) return false;
|
||
if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate
|
||
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
|
||
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
|
||
return false; // unpaired low surrogate — malformed
|
||
}
|
||
|
||
// Encode codePoint as UTF-8.
|
||
if (codePoint <= 0x7F) {
|
||
out += static_cast<char>(codePoint);
|
||
} else if (codePoint <= 0x7FF) {
|
||
out += static_cast<char>(0xC0 | (codePoint >> 6));
|
||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||
} else if (codePoint <= 0xFFFF) {
|
||
out += static_cast<char>(0xE0 | (codePoint >> 12));
|
||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||
} else {
|
||
out += static_cast<char>(0xF0 | (codePoint >> 18));
|
||
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
|
||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||
}
|
||
break;
|
||
}
|
||
default: return false;
|
||
}
|
||
} else {
|
||
out += c;
|
||
}
|
||
}
|
||
return false; // unterminated string
|
||
}
|
||
|
||
// Reads a bare token (number, true, false, null) up to the next structural char.
|
||
bool Parser::parseRawScalar(std::string& out) {
|
||
skipWs();
|
||
std::size_t start = pos_;
|
||
while (!eof()) {
|
||
char c = s_[pos_];
|
||
if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' ||
|
||
c == '\n' || c == '\r')
|
||
break;
|
||
++pos_;
|
||
}
|
||
if (pos_ == start) return false;
|
||
out.assign(s_, start, pos_ - start);
|
||
return true;
|
||
}
|
||
|
||
bool Parser::parseDouble(double& out) {
|
||
std::string tok;
|
||
if (!parseRawScalar(tok)) return false;
|
||
const char* b = tok.c_str();
|
||
char* end = nullptr;
|
||
errno = 0;
|
||
double v = std::strtod(b, &end);
|
||
if (end != b + tok.size()) return false;
|
||
if (errno == ERANGE) return false; // overflow / underflow → malformed
|
||
out = v;
|
||
return true;
|
||
}
|
||
|
||
bool Parser::parseInt64(std::int64_t& out) {
|
||
std::string tok;
|
||
if (!parseRawScalar(tok)) return false;
|
||
const char* b = tok.c_str();
|
||
char* end = nullptr;
|
||
errno = 0;
|
||
long long v = std::strtoll(b, &end, 10);
|
||
if (end != b + tok.size()) return false;
|
||
if (errno == ERANGE) return false; // overflow → malformed
|
||
out = static_cast<std::int64_t>(v);
|
||
return true;
|
||
}
|
||
|
||
bool Parser::parseInt(int& out) {
|
||
std::int64_t v = 0;
|
||
if (!parseInt64(v)) return false;
|
||
out = static_cast<int>(v);
|
||
return true;
|
||
}
|
||
|
||
bool Parser::parseBool(bool& out) {
|
||
std::string tok;
|
||
if (!parseRawScalar(tok)) return false;
|
||
if (tok == "true") { out = true; return true; }
|
||
if (tok == "false") { out = false; return true; }
|
||
return false;
|
||
}
|
||
|
||
// If the next value is the `null` token, consumes it and sets wasNull=true.
|
||
// Otherwise leaves the position untouched and sets wasNull=false. Returns false
|
||
// only on eof.
|
||
bool Parser::expectNullOr(bool& wasNull) {
|
||
skipWs();
|
||
if (eof()) return false;
|
||
if (s_.compare(pos_, 4, "null") == 0) {
|
||
pos_ += 4;
|
||
wasNull = true;
|
||
} else {
|
||
wasNull = false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool Parser::parseKey(std::string& key) {
|
||
if (!parseString(key)) return false;
|
||
if (!consume(':')) return false;
|
||
return true;
|
||
}
|
||
|
||
// Skips one JSON value (object / array / string / scalar) for forward-compat
|
||
// with keys we don't recognize. Assumes position is at the start of the value.
|
||
bool Parser::skipValue() {
|
||
skipWs();
|
||
if (eof()) return false;
|
||
char c = s_[pos_];
|
||
if (c == '"') {
|
||
std::string tmp;
|
||
return parseString(tmp);
|
||
}
|
||
if (c == '{' || c == '[') {
|
||
char open = c, close = (c == '{') ? '}' : ']';
|
||
++pos_;
|
||
int depth = 1;
|
||
while (!eof() && depth > 0) {
|
||
char d = s_[pos_];
|
||
if (d == '"') {
|
||
std::string tmp;
|
||
if (!parseString(tmp)) return false;
|
||
continue;
|
||
}
|
||
if (d == open) ++depth;
|
||
else if (d == close) --depth;
|
||
++pos_;
|
||
}
|
||
return depth == 0;
|
||
}
|
||
std::string tmp;
|
||
return parseRawScalar(tmp);
|
||
}
|
||
|
||
bool Parser::parseSample(Sample& s) {
|
||
if (!consume('{')) return false;
|
||
skipWs();
|
||
if (consume('}')) return true; // empty object (shouldn't happen, but valid)
|
||
|
||
do {
|
||
std::string key;
|
||
if (!parseKey(key)) return false;
|
||
|
||
if (key == "id") {
|
||
if (!parseString(s.id)) return false;
|
||
} else if (key == "displayName") {
|
||
if (!parseString(s.displayName)) return false;
|
||
} else if (key == "relativePath") {
|
||
if (!parseString(s.relativePath)) return false;
|
||
} else if (key == "sourceMode") {
|
||
int v = 0;
|
||
if (!parseInt(v)) return false;
|
||
// Valid range: MasterMix(0) .. Realtime(5).
|
||
if (v < static_cast<int>(SourceMode::MasterMix) ||
|
||
v > static_cast<int>(SourceMode::Realtime))
|
||
return false;
|
||
s.sourceMode = static_cast<SourceMode>(v);
|
||
} else if (key == "sourceRange") {
|
||
if (!consume('{')) return false;
|
||
do {
|
||
std::string rk;
|
||
if (!parseKey(rk)) return false;
|
||
double dv = 0.0;
|
||
if (!parseDouble(dv)) return false;
|
||
if (rk == "startSeconds") s.sourceRange.startSeconds = dv;
|
||
else if (rk == "endSeconds") s.sourceRange.endSeconds = dv;
|
||
else if (rk == "startPpq") s.sourceRange.startPpq = dv;
|
||
else if (rk == "endPpq") s.sourceRange.endPpq = dv;
|
||
} while (consume(','));
|
||
if (!consume('}')) return false;
|
||
} else if (key == "trackGuids") {
|
||
if (!consume('[')) return false;
|
||
skipWs();
|
||
if (!consume(']')) {
|
||
do {
|
||
std::string g;
|
||
if (!parseString(g)) return false;
|
||
s.trackGuids.push_back(g);
|
||
} while (consume(','));
|
||
if (!consume(']')) return false;
|
||
}
|
||
} else if (key == "wetDry") {
|
||
if (!parseDouble(s.wetDry)) return false;
|
||
} else if (key == "channelCount") {
|
||
if (!parseInt(s.channelCount)) return false;
|
||
} else if (key == "sampleRate") {
|
||
if (!parseInt(s.sampleRate)) return false;
|
||
} else if (key == "lengthSeconds") {
|
||
if (!parseDouble(s.lengthSeconds)) return false;
|
||
} else if (key == "lengthBeats") {
|
||
if (!parseDouble(s.lengthBeats)) return false;
|
||
} else if (key == "captureTempo") {
|
||
if (!parseDouble(s.captureTempo)) return false;
|
||
} else if (key == "key") {
|
||
bool wasNull = false;
|
||
if (!expectNullOr(wasNull)) return false;
|
||
if (wasNull) {
|
||
s.key.reset();
|
||
} else {
|
||
std::string k;
|
||
if (!parseString(k)) return false;
|
||
s.key = k;
|
||
}
|
||
} else if (key == "levels") {
|
||
if (!consume('{')) return false;
|
||
do {
|
||
std::string lk;
|
||
if (!parseKey(lk)) return false;
|
||
double dv = 0.0;
|
||
if (!parseDouble(dv)) return false;
|
||
if (lk == "peakDb") s.levels.peakDb = dv;
|
||
else if (lk == "rmsDb") s.levels.rmsDb = dv;
|
||
else if (lk == "lufs") s.levels.lufs = dv;
|
||
} while (consume(','));
|
||
if (!consume('}')) return false;
|
||
} else if (key == "clipped") {
|
||
if (!parseBool(s.clipped)) return false;
|
||
} else if (key == "tier") {
|
||
int v = 0;
|
||
if (!parseInt(v)) return false;
|
||
// Valid range: Scratch(0) .. Archive(1).
|
||
if (v < static_cast<int>(Tier::Scratch) ||
|
||
v > static_cast<int>(Tier::Archive))
|
||
return false;
|
||
s.tier = static_cast<Tier>(v);
|
||
} else if (key == "contentHash") {
|
||
if (!parseString(s.contentHash)) return false;
|
||
} else if (key == "provenance") {
|
||
bool wasNull = false;
|
||
if (!expectNullOr(wasNull)) return false;
|
||
if (wasNull) {
|
||
s.provenance.reset();
|
||
} else {
|
||
if (!consume('{')) return false;
|
||
Provenance p;
|
||
do {
|
||
std::string pk;
|
||
if (!parseKey(pk)) return false;
|
||
std::string pv;
|
||
if (!parseString(pv)) return false;
|
||
if (pk == "parentSampleId") p.parentSampleId = pv;
|
||
else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv;
|
||
} while (consume(','));
|
||
if (!consume('}')) return false;
|
||
s.provenance = p;
|
||
}
|
||
} else if (key == "createdTimestamp") {
|
||
if (!parseInt64(s.createdTimestamp)) return false;
|
||
} else {
|
||
if (!skipValue()) return false; // forward-compat: ignore unknown
|
||
}
|
||
} while (consume(','));
|
||
|
||
return consume('}');
|
||
}
|
||
|
||
bool Parser::parseIndex(BankIndex& out) {
|
||
if (!consume('{')) return false;
|
||
skipWs();
|
||
if (consume('}')) return true; // empty object — vacuously an empty index
|
||
|
||
std::vector<Sample> parsed;
|
||
do {
|
||
std::string key;
|
||
if (!parseKey(key)) return false;
|
||
|
||
if (key == "samples") {
|
||
if (!consume('[')) return false;
|
||
skipWs();
|
||
if (!consume(']')) {
|
||
do {
|
||
Sample s;
|
||
if (!parseSample(s)) return false;
|
||
parsed.push_back(std::move(s));
|
||
} while (consume(','));
|
||
if (!consume(']')) return false;
|
||
}
|
||
} else {
|
||
if (!skipValue()) return false; // version, or unknown keys
|
||
}
|
||
} while (consume(','));
|
||
|
||
if (!consume('}')) return false;
|
||
|
||
// Trailing garbage after the root object is malformed.
|
||
skipWs();
|
||
if (!eof()) return false;
|
||
|
||
// Rebuild via add() so the same invariants (relative-path, dedup) that guard
|
||
// live inserts also guard deserialized data. Rejected/collapsed entries are
|
||
// dropped silently — a well-formed serialized index never triggers them.
|
||
for (auto& s : parsed) out.add(s);
|
||
return true;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
std::optional<BankIndex> BankIndex::deserialize(const std::string& json) {
|
||
BankIndex idx;
|
||
Parser p(json);
|
||
if (!p.parseIndex(idx)) return std::nullopt;
|
||
return idx;
|
||
}
|
||
|
||
} // namespace reasampler
|