310 lines
14 KiB
C++
310 lines
14 KiB
C++
#include "core/model/bank_book.h"
|
|
|
|
#include <algorithm>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#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: <BankModel blob> }.
|
|
// 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<std::pair<std::string, int>>& 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<std::pair<std::string, int>> 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<std::pair<std::string, int>>& 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<Bank>& 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<Bank> 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<Bank>&& 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> BankBook::deserialize(const std::string& blob) {
|
|
std::vector<Bank> 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<std::string> 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
|