#include "bank_model.h" #include #include #include #include #include // 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 : 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(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 BankIndex::byTier(Tier tier) const { std::vector 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(c) < 0x20) { char buf[8]; std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(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(v)); return buf; } std::string numToStr(int v) { return numToStr(static_cast(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& 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(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(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(h - '0'); else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); else if (h >= 'A' && h <= 'F') cp |= static_cast(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(codePoint); } else if (codePoint <= 0x7FF) { out += static_cast(0xC0 | (codePoint >> 6)); out += static_cast(0x80 | (codePoint & 0x3F)); } else if (codePoint <= 0xFFFF) { out += static_cast(0xE0 | (codePoint >> 12)); out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); out += static_cast(0x80 | (codePoint & 0x3F)); } else { out += static_cast(0xF0 | (codePoint >> 18)); out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); out += static_cast(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(v); return true; } bool Parser::parseInt(int& out) { std::int64_t v = 0; if (!parseInt64(v)) return false; out = static_cast(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(SourceMode::MasterMix) || v > static_cast(SourceMode::Realtime)) return false; s.sourceMode = static_cast(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(Tier::Scratch) || v > static_cast(Tier::Archive)) return false; s.tier = static_cast(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 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::deserialize(const std::string& json) { BankIndex idx; Parser p(json); if (!p.parseIndex(idx)) return std::nullopt; return idx; } } // namespace reasampler