Files
reasampler/src/bank_model.cpp
T
daniel 2862e1c865 Implement M1 bank_model: Sample + BankIndex, hash-dedup, tiers, JSON round-trip
Pure dependency-free core with self-contained JSON writer/parser. Absolute
paths (incl. drive-relative) rejected at the add boundary; \uXXXX decoded to
UTF-8 with surrogate pairs; malformed input returns nullopt.
2026-07-21 22:54:21 -04:00

694 lines
24 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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;
}
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