#include "core/model/bank_book.h" #include #include #include #include "core/json/json.h" // bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header, // compiled into the same bank_book target; the slot_map second-TU shape). The // registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private // static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time // duplicate-display-name coalesce below must fold names EXACTLY as the create/rename // uniqueness check does, or a parsed book could violate the in-model invariant. // // JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model // and view_mode_model. The book blob nests one bank object per bank, each carrying that // bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize), // so per-bank sample serialization stays owned by bank_model and is not duplicated // here. The book writer emits the bank envelope (id / displayName / ordinal) plus a // raw "index" member whose value is the BankModel blob verbatim; the parser splits // the book envelope, then hands each nested index blob straight to // BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped. // BYTE-IDENTICAL to the pre-extraction writer — the Q-W1 golden-literal test pins it. namespace reasampler { // =========================================================================== // JSON — writer // =========================================================================== namespace { // Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the // prior file-local writer carried, so the emitted blob is byte-identical. std::string intToStr(int v) { return json::numToStr(v); } using ObjWriter = json::Writer; } // namespace std::string BankBook::serialize() const { std::string out; { ObjWriter root(out); root.keyRaw("version", intToStr(1)); root.keyStr("activeBank", activeBankId_); // banks: array of { id, displayName, ordinal, index: }. // The pool rides in as bank-zero, persisted identically to any named bank. root.keyBegin("banks"); out += '['; for (std::size_t i = 0; i < banks_.size(); ++i) { if (i) out += ','; ObjWriter b(out); b.keyStr("id", banks_[i].id); b.keyStr("displayName", banks_[i].displayName); b.keyRaw("ordinal", intToStr(banks_[i].ordinal)); // The nested index is bank_model's own JSON, emitted verbatim so the // per-sample shape stays owned by BankModel::serialize (not duplicated). b.keyRaw("index", banks_[i].index.serialize()); // L7 display positions (gap-preserving). Absent on a pre-L7 blob; the // parser defaults such a bank's slots from insertion order on load. b.keyRaw("slots", banks_[i].slots.serialize()); } out += ']'; } // root closes here (see bank_model note on NRVO + deferred close) return out; } // =========================================================================== // JSON — parser (recursive descent; std::nullopt on any malformed input, never UB) // =========================================================================== namespace { // The book DOMAIN grammar over the shared core/json lexical layer (Q-W1). // parseBank parses one bank object; parseSlots the "slots" array ([{id, slot}, // ...]) into (id, slot) pairs (empty array valid; the pair-level defensive // repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root // blob, distinguishing the legacy shape (a bare bank_index object: has // "samples", no "banks") from the book shape (has "banks"): a legacy blob // yields a single pool bank carrying the migrated index and an empty active id // (⇒ pool). The member deserialize() adopts the result (duplicate-display-name // coalesce + ordinal normalize + active resolve — the coalesce lives THERE, not // here, because it folds through the private BankBook::nameKey these free // functions cannot reach). bool parseSlots(json::Reader& r, std::vector>& out); bool parseBank(json::Reader& r, Bank& b) { if (!r.consume('{')) return false; r.skipWs(); if (r.consume('}')) return false; // a bank object must at least carry an id bool haveId = false; bool haveIndex = false; do { std::string key; if (!r.parseKey(key)) return false; if (key == "id") { if (!r.parseString(b.id)) return false; haveId = true; } else if (key == "displayName") { if (!r.parseString(b.displayName)) return false; } else if (key == "ordinal") { if (!r.parseInt(b.ordinal)) return false; } else if (key == "index") { std::string raw; if (!r.captureValue(raw)) return false; auto idx = BankModel::deserialize(raw); if (!idx) return false; // a malformed nested index fails the whole parse b.index = std::move(*idx); haveIndex = true; } else if (key == "slots") { // L7 display positions. Absent on a pre-L7 blob (the else-branch skips // nothing because the key never appears); when present it drives the // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. std::vector> pairs; if (!parseSlots(r, pairs)) return false; b.slots = SlotMap::fromEntries(pairs); } else { if (!r.skipValue()) return false; // forward-compat unknown keys } } while (r.consume(',')); if (!r.consume('}')) return false; if (!haveId || b.id.empty()) return false; // id keys the registry if (!haveIndex) return false; // every bank persists its index return true; } bool parseSlots(json::Reader& r, std::vector>& out) { out.clear(); if (!r.consume('[')) return false; r.skipWs(); if (r.consume(']')) return true; // empty slot array — a bank with no positions yet do { if (!r.consume('{')) return false; std::string id; int slot = 0; bool haveId = false, haveSlot = false; do { std::string k; if (!r.parseKey(k)) return false; if (k == "id") { if (!r.parseString(id)) return false; haveId = true; } else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; } else { if (!r.skipValue()) return false; } // forward-compat } while (r.consume(',')); if (!r.consume('}')) return false; if (!haveId || !haveSlot) return false; // a slot entry needs both out.emplace_back(std::move(id), slot); } while (r.consume(',')); return r.consume(']'); } bool parseBook(json::Reader& r, const std::string& raw, std::vector& banks, std::string& activeBank) { banks.clear(); activeBank.clear(); if (!r.consume('{')) return false; r.skipWs(); if (r.consume('}')) return false; // an empty object is neither shape → malformed // Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a // "samples" key with no "banks" ⇒ legacy shape (promote into the pool). std::vector parsedBanks; bool sawBanks = false; bool sawSamples = false; do { std::string key; if (!r.parseKey(key)) return false; if (key == "banks") { sawBanks = true; if (!r.consume('[')) return false; r.skipWs(); if (!r.consume(']')) { do { Bank b; if (!parseBank(r, b)) return false; parsedBanks.push_back(std::move(b)); } while (r.consume(',')); if (!r.consume(']')) return false; } } else if (key == "activeBank") { if (!r.parseString(activeBank)) return false; } else if (key == "samples") { // Legacy marker. The legacy index is re-parsed from the whole input below // (BankModel::deserialize owns that shape); here we only skip the value to // keep the scan well-formed and note that we saw it. sawSamples = true; if (!r.skipValue()) return false; } else { if (!r.skipValue()) return false; // version, or unknown } } while (r.consume(',')); if (!r.consume('}')) return false; r.skipWs(); if (!r.eof()) return false; // trailing garbage // --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- if (!sawBanks) { if (!sawSamples) return false; // neither shape's marker → malformed auto legacy = BankModel::deserialize(raw); if (!legacy) return false; Bank pool; pool.id = kPoolBankId; pool.displayName = kPoolBankName; pool.ordinal = 0; pool.index = std::move(*legacy); banks.push_back(std::move(pool)); // { pool } with zero named banks activeBank.clear(); // ⇒ pool (default) after adoption return true; } // --- Book shape: the parsed banks ARE the book (pool folded in). --- // The pool must be present as bank-zero (serialize always emits it). Reject a // book blob that omits it rather than silently re-seeding — a book without its // pool is malformed, not a legacy blob. bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(), [](const Bank& b) { return b.isPool(); }); if (!hasPool) return false; // Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup). for (std::size_t i = 0; i < parsedBanks.size(); ++i) for (std::size_t j = i + 1; j < parsedBanks.size(); ++j) if (parsedBanks[i].id == parsedBanks[j].id) return false; // Force the pool's fixed display name — it is not user-mutable, so we do not // trust a persisted override for it (keeps kPoolBankName authoritative). for (auto& b : parsedBanks) if (b.isPool()) b.displayName = kPoolBankName; banks = std::move(parsedBanks); return true; } } // namespace void BankBook::adoptBanks(std::vector&& banks, const std::string& activeBank) { banks_ = std::move(banks); normalizeOrdinals(); // Resolve the active bank defensively: fall back to the pool if the persisted id // names no bank, so a corrupt active id never leaves a dangling capture target. activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId); } std::optional BankBook::deserialize(const std::string& blob) { std::vector banks; std::string activeBank; json::Reader r(blob); if (!parseBook(r, blob, banks, activeBank)) return std::nullopt; // --- Coalesce duplicate folded display names (B4 re-review fold-in). -------- // The in-model create/rename path enforces unique display names under nameKey, // but a hand-edited .rpp blob can smuggle in two banks whose names fold to the // same key ("Drums" and " drums "). Rejecting the whole book over one collision // would degrade the user's entire library to empty, so instead we AUTO- // DISAMBIGUATE the later duplicate deterministically: scan in parse order, and // the first time a folded key repeats, suffix that bank's display name (" 2", // " 3", …) until its folded key is unique among all names seen so far. The FIRST // bank to carry a key keeps its name verbatim; only subsequent collisions are // renamed. No bank or sample is lost, and ids are untouched. The pool is included // in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool" // is disambiguated away from it, never the reverse. // // Hosted HERE (a static member, Q-W5) rather than in the free parseBook because // it folds through the PRIVATE BankBook::nameKey — the same rule the // create/rename uniqueness check applies. Runs after parseBook on BOTH shapes; // the legacy path yields { pool } alone, where the scan is a trivial no-op. { std::vector seenKeys; seenKeys.reserve(banks.size()); for (auto& b : banks) { if (b.isPool()) { // pool's name is fixed; reserve its key seenKeys.push_back(nameKey(b.displayName)); continue; } const auto taken = [&](const std::string& k) { return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end(); }; std::string key = nameKey(b.displayName); if (taken(key)) { // Suffix with an ascending integer until the folded key is free. Guard // against a pathological blob whose base name already ends in a number // by folding the candidate each attempt (nameKey normalizes it). const std::string base = b.displayName; for (int n = 2;; ++n) { const std::string candidate = base + " " + std::to_string(n); const std::string candKey = nameKey(candidate); if (!taken(candKey)) { b.displayName = candidate; key = candKey; break; } } } seenKeys.push_back(key); } } BankBook book; book.adoptBanks(std::move(banks), activeBank); return book; } } // namespace reasampler