#include "core/model/bank_model.h" #include #include "core/json/json.h" // bank_model implementation. JSON rides on the shared core/json lexical layer; // only the Sample/index DOMAIN grammar lives here. Doubles are emitted with 17 // significant digits (%.17g), the shortest form that round-trips every // IEEE-754 double exactly, so deserialize(serialize(x)) == x holds bit-for-bit. namespace reasampler::model { // -- 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 LoopPoints::operator==(const LoopPoints& o) const { return start == o.start && end == o.end; } 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 && captureTimeSigNum == o.captureTimeSigNum && captureTimeSigDenom == o.captureTimeSigDenom && key == o.key && rootNote == o.rootNote && loop == o.loop && levels == o.levels && clipped == o.clipped && tier == o.tier && contentHash == o.contentHash && provenance == o.provenance && createdTimestamp == o.createdTimestamp; } // -- path invariant ------------------------------------------------------- // Rejects absolute paths rather than normalizing them: the pure model has no // knowledge of the project root, so any "normalization" would be a guess that // could point at the wrong file. Covers POSIX ("/x"), Windows drive ("C:\x", // "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") forms. Any leading // : is rejected regardless of what follows — drive-relative paths // ("C:foo.wav") resolve against the drive's current directory, not the project // root, so they violate relative-paths-only 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; } // -- BankModel ------------------------------------------------------------ AddResult BankModel::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 BankModel::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 BankModel::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* BankModel::query(const std::string& id) const { for (const auto& s : samples_) if (s.id == id) return &s; return nullptr; } const Sample* BankModel::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 BankModel::moveTier(const std::string& id, Tier tier) { for (auto& s : samples_) { if (s.id == id) { s.tier = tier; return true; } } return false; } std::vector BankModel::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 { using json::numToStr; using json::writeEscaped; using json::writeStringArray; using ObjWriter = json::Writer; 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)); w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum)); w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom)); // 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"; // Emitted as null when absent (same shape as `key`/`provenance`) so JSON that // lacks these keys entirely parses to empty optionals and re-serializes // without invention. w.keyBegin("rootNote"); if (s.rootNote) out += numToStr(*s.rootNote); else out += "null"; w.keyBegin("loop"); if (s.loop) { ObjWriter lp(out); lp.keyRaw("start", numToStr(s.loop->start)); lp.keyRaw("end", numToStr(s.loop->end)); } 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 BankModel::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 over the shared json::Reader) ----------- // Returns false on any malformed input; never reads out of bounds. Only // supports the subset our writer emits. namespace { bool parseSample(json::Reader& r, Sample& s) { if (!r.consume('{')) return false; r.skipWs(); if (r.consume('}')) return true; // empty object (shouldn't happen, but valid) do { std::string key; if (!r.parseKey(key)) return false; if (key == "id") { if (!r.parseString(s.id)) return false; } else if (key == "displayName") { if (!r.parseString(s.displayName)) return false; } else if (key == "relativePath") { if (!r.parseString(s.relativePath)) return false; } else if (key == "sourceMode") { int v = 0; if (!r.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 (!r.consume('{')) return false; do { std::string rk; if (!r.parseKey(rk)) return false; double dv = 0.0; if (!r.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 (r.consume(',')); if (!r.consume('}')) return false; } else if (key == "trackGuids") { if (!r.consume('[')) return false; r.skipWs(); if (!r.consume(']')) { do { std::string g; if (!r.parseString(g)) return false; s.trackGuids.push_back(g); } while (r.consume(',')); if (!r.consume(']')) return false; } } else if (key == "wetDry") { if (!r.parseDouble(s.wetDry)) return false; } else if (key == "channelCount") { if (!r.parseInt(s.channelCount)) return false; } else if (key == "sampleRate") { if (!r.parseInt(s.sampleRate)) return false; } else if (key == "lengthSeconds") { if (!r.parseDouble(s.lengthSeconds)) return false; } else if (key == "lengthBeats") { if (!r.parseDouble(s.lengthBeats)) return false; } else if (key == "captureTempo") { if (!r.parseDouble(s.captureTempo)) return false; } else if (key == "captureTimeSigNum") { if (!r.parseInt(s.captureTimeSigNum)) return false; } else if (key == "captureTimeSigDenom") { if (!r.parseInt(s.captureTimeSigDenom)) return false; } else if (key == "key") { bool wasNull = false; if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.key.reset(); } else { std::string k; if (!r.parseString(k)) return false; s.key = k; } } else if (key == "rootNote") { bool wasNull = false; if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.rootNote.reset(); } else { int v = 0; if (!r.parseInt(v)) return false; // Valid MIDI note range: 0..127 inclusive (boundaries valid). if (v < 0 || v > 127) return false; s.rootNote = v; } } else if (key == "loop") { bool wasNull = false; if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.loop.reset(); } else { if (!r.consume('{')) return false; LoopPoints lp; do { std::string lk; if (!r.parseKey(lk)) return false; std::int64_t lv = 0; if (!r.parseInt64(lv)) return false; if (lk == "start") lp.start = lv; else if (lk == "end") lp.end = lv; } while (r.consume(',')); if (!r.consume('}')) return false; // Invariant: 0 <= start <= end. start == end is a valid zero-length // marker; a negative index or start > end is malformed, not silently // clamped (mirrors the enum-range rejection above). if (lp.start < 0 || lp.end < lp.start) return false; s.loop = lp; } } else if (key == "levels") { if (!r.consume('{')) return false; do { std::string lk; if (!r.parseKey(lk)) return false; double dv = 0.0; if (!r.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 (r.consume(',')); if (!r.consume('}')) return false; } else if (key == "clipped") { if (!r.parseBool(s.clipped)) return false; } else if (key == "tier") { int v = 0; if (!r.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 (!r.parseString(s.contentHash)) return false; } else if (key == "provenance") { bool wasNull = false; if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.provenance.reset(); } else { if (!r.consume('{')) return false; Provenance p; do { std::string pk; if (!r.parseKey(pk)) return false; std::string pv; if (!r.parseString(pv)) return false; if (pk == "parentSampleId") p.parentSampleId = pv; else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv; } while (r.consume(',')); if (!r.consume('}')) return false; s.provenance = p; } } else if (key == "createdTimestamp") { if (!r.parseInt64(s.createdTimestamp)) return false; } else { if (!r.skipValue()) return false; // forward-compat: ignore unknown } } while (r.consume(',')); return r.consume('}'); } bool parseIndex(json::Reader& r, BankModel& out) { if (!r.consume('{')) return false; r.skipWs(); if (r.consume('}')) return true; // empty object — vacuously an empty index std::vector parsed; do { std::string key; if (!r.parseKey(key)) return false; if (key == "samples") { if (!r.consume('[')) return false; r.skipWs(); if (!r.consume(']')) { do { Sample s; if (!parseSample(r, s)) return false; parsed.push_back(std::move(s)); } while (r.consume(',')); if (!r.consume(']')) return false; } } else { if (!r.skipValue()) return false; // version, or unknown keys } } while (r.consume(',')); if (!r.consume('}')) return false; // Trailing garbage after the root object is malformed. r.skipWs(); if (!r.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 BankModel::deserialize(const std::string& blob) { BankModel idx; json::Reader r(blob); if (!parseIndex(r, idx)) return std::nullopt; return idx; } } // namespace reasampler::model