#include "bank_book.h" #include #include #include // bank_book implementation. // // JSON is hand-rolled and self-contained, matching the house style of bank_model // and view_mode_model (brief: keep the pure core dependency-free — no third-party // JSON lib). The book blob nests one bank object per bank, each carrying that // bank's BankIndex serialized by bank_model's OWN writer (BankIndex::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 BankIndex blob verbatim; the parser splits // the book envelope, then hands each nested index blob straight to // BankIndex::deserialize. Ints use %d; strings are escaped by writeEscaped. namespace reasampler { // =========================================================================== // SlotMap — the L7 gap-preserving display-position carrier (pure). See bank_book.h. // The invariant: entries_ is kept sorted ascending by slot, one id per slot, one // slot per id. Every mutator restores it; queries assume it. // =========================================================================== void SlotMap::sortBySlot() { std::stable_sort(entries_.begin(), entries_.end(), [](const Entry& a, const Entry& b) { return a.slot < b.slot; }); } int SlotMap::slotOf(const std::string& id) const { for (const auto& e : entries_) if (e.id == id) return e.slot; return -1; } std::string SlotMap::idAt(int slot) const { for (const auto& e : entries_) if (e.slot == slot) return e.id; return {}; } int SlotMap::maxSlot() const { int m = -1; for (const auto& e : entries_) if (e.slot > m) m = e.slot; return m; } std::vector SlotMap::orderedIds() const { // entries_ is sorted ascending by slot, so a straight walk is display order. std::vector out; out.reserve(entries_.size()); for (const auto& e : entries_) out.push_back(e.id); return out; } void SlotMap::append(const std::string& id) { if (id.empty()) return; remove(id); // an existing id is re-appended, not left in place entries_.push_back(Entry{id, maxSlot() + 1}); // next free slot after the last occupied sortBySlot(); } bool SlotMap::remove(const std::string& id) { for (auto it = entries_.begin(); it != entries_.end(); ++it) { if (it->id == id) { entries_.erase(it); // leaves the slot empty — no re-pack return true; } } return false; } bool SlotMap::reorder(const std::string& id, int targetSlot) { if (slotOf(id) < 0) return false; // not mapped -> no mutation if (targetSlot < 0) targetSlot = 0; if (slotOf(id) == targetSlot) return false; // already there — true no-op // Detach the moving id first so the occupancy test below sees the post-move world. remove(id); const bool occupied = !idAt(targetSlot).empty(); if (occupied) { // Insert-before-and-shift: every occupant at slot >= targetSlot shifts up by one, // preserving relative order and interior gaps above the target. The moving id then // takes targetSlot cleanly. for (auto& e : entries_) if (e.slot >= targetSlot) ++e.slot; } entries_.push_back(Entry{id, targetSlot}); sortBySlot(); return true; } void SlotMap::resetDense(const std::vector& ids) { entries_.clear(); int slot = 0; for (const auto& id : ids) { if (id.empty()) continue; if (slotOf(id) >= 0) continue; // skip a duplicate id (one slot per id) entries_.push_back(Entry{id, slot++}); } // Already ascending by construction; no sort needed. } void SlotMap::reconcile(const std::vector& liveIds) { // Drop markers whose sample left the index. entries_.erase( std::remove_if(entries_.begin(), entries_.end(), [&](const Entry& e) { return std::find(liveIds.begin(), liveIds.end(), e.id) == liveIds.end(); }), entries_.end()); // Append live ids that have no mapping yet (out-of-band index growth), in liveIds // order, each to the next free slot after the current frontier. for (const auto& id : liveIds) if (slotOf(id) < 0) append(id); sortBySlot(); } bool SlotMap::operator==(const SlotMap& o) const { return entries_ == o.entries_; } SlotMap SlotMap::fromEntries(const std::vector>& pairs) { SlotMap m; for (const auto& [id, slot] : pairs) { if (id.empty() || slot < 0) continue; // drop malformed pair if (m.slotOf(id) >= 0) continue; // duplicate id: first wins if (!m.idAt(slot).empty()) continue; // slot taken: never double-occupy m.entries_.push_back(Entry{id, slot}); } m.sortBySlot(); return m; } // SlotMap::serialize is defined in the JSON writer section below (it reuses the // file-local ObjWriter / intToStr helpers). // --------------------------------------------------------------------------- // BankBook — construction + bank lookup // --------------------------------------------------------------------------- BankBook::BankBook() { Bank pool; pool.id = kPoolBankId; pool.displayName = kPoolBankName; pool.ordinal = 0; banks_.push_back(std::move(pool)); activeBankId_ = kPoolBankId; } Bank* BankBook::bank(const std::string& id) { for (auto& b : banks_) if (b.id == id) return &b; return nullptr; } const Bank* BankBook::bank(const std::string& id) const { for (const auto& b : banks_) if (b.id == id) return &b; return nullptr; } BankIndex* BankBook::index(const std::string& id) { Bank* b = bank(id); return b ? &b->index : nullptr; } const BankIndex* BankBook::index(const std::string& id) const { const Bank* b = bank(id); return b ? &b->index : nullptr; } Bank& BankBook::pool() { // The pool is seeded on construction and is un-deletable, so it always exists. return *bank(kPoolBankId); } const Bank& BankBook::pool() const { return *bank(kPoolBankId); } // --------------------------------------------------------------------------- // Ordinal normalization // --------------------------------------------------------------------------- void BankBook::normalizeOrdinals() { // Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a // contiguous 0..N-1. Stability preserves the caller's relative order among banks // that share (or, after a reorder shuffle, tie on) an ordinal. std::stable_sort(banks_.begin(), banks_.end(), [](const Bank& a, const Bank& b) { if (a.isPool() != b.isPool()) return a.isPool(); // pool always first return a.ordinal < b.ordinal; }); for (std::size_t i = 0; i < banks_.size(); ++i) banks_[i].ordinal = static_cast(i); } // --------------------------------------------------------------------------- // Display-name uniqueness (trimmed + case-insensitive, ASCII) // --------------------------------------------------------------------------- namespace { // Folds a display name to its uniqueness key: strip leading/trailing ASCII // whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one // key and cannot coexist. ASCII-only by design — the pure core carries no locale // facility and must not grow one; bank names are short user labels, not full Unicode // case-folding candidates. std::string nameKey(const std::string& s) { std::size_t b = 0, e = s.size(); auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; while (b < e && isWs(s[b])) ++b; while (e > b && isWs(s[e - 1])) --e; std::string out; out.reserve(e - b); for (std::size_t i = b; i < e; ++i) { char c = s[i]; if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); out += c; } return out; } } // namespace // True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The // exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name. bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const { const std::string key = nameKey(name); for (const auto& b : banks_) if (b.id != exceptId && nameKey(b.displayName) == key) return true; return false; } // --------------------------------------------------------------------------- // Bank lifecycle // --------------------------------------------------------------------------- bool BankBook::createBank(const std::string& id, const std::string& displayName) { if (id.empty()) return false; // ids key the registry if (id == kPoolBankId) return false; // reserved pool id if (bank(id) != nullptr) return false; // duplicate id // Display names are unique (trimmed + case-insensitive); the pool's "Pool" is a // reserved name and is caught here like any other collision. if (displayNameTaken(displayName, /*exceptId=*/id)) return false; Bank b; b.id = id; b.displayName = displayName; b.ordinal = static_cast(banks_.size()); // append; normalize compacts it banks_.push_back(std::move(b)); normalizeOrdinals(); return true; } bool BankBook::renameBank(const std::string& id, const std::string& displayName) { if (id == kPoolBankId) return false; // pool is un-renamable Bank* b = bank(id); if (b == nullptr) return false; // Reject a name already used by a DIFFERENT bank. Renaming a bank to its own // current name (or a case/space variant of it) is a no-op success, not a // rejection — exceptId=id excludes the bank itself from the collision scan. if (displayNameTaken(displayName, /*exceptId=*/id)) return false; b->displayName = displayName; return true; } bool BankBook::deleteBank(const std::string& id) { if (id == kPoolBankId) return false; // pool is un-deletable auto it = std::find_if(banks_.begin(), banks_.end(), [&](const Bank& b) { return b.id == id; }); if (it == banks_.end()) return false; banks_.erase(it); // If the active bank was the one deleted, fall back to the pool (invariant: the // active id always names a live bank). if (activeBankId_ == id) activeBankId_ = kPoolBankId; normalizeOrdinals(); return true; } bool BankBook::reorderBank(const std::string& id, int newOrdinal) { if (id == kPoolBankId) return false; // pool is pinned at ordinal 0 if (bank(id) == nullptr) return false; // Work on the named banks as an ordered list (banks_ is already ordinal-sorted // with the pool first, so named banks are banks_[1..]). Pull the target out and // re-insert it at the requested position, clamped into the named-bank range // [1..N], then rewrite ordinals contiguously. This is O(N) and obviously correct. std::vector named; named.reserve(banks_.size()); for (auto& b : banks_) if (!b.isPool()) named.push_back(std::move(b)); auto it = std::find_if(named.begin(), named.end(), [&](const Bank& b) { return b.id == id; }); Bank moved = std::move(*it); named.erase(it); // Named ordinals are 1..N; convert to a 0-based insertion index into `named`. const int hi = static_cast(named.size()); // insert-at range is [0..size] int insertAt = std::max(0, std::min(newOrdinal - 1, hi)); named.insert(named.begin() + insertAt, std::move(moved)); // Rebuild banks_: pool first, then the reordered named banks. Assign ordinals // directly by position here — NOT via normalizeOrdinals(), whose stable_sort keys // on the (now stale) ordinals and would undo the reinsertion order. std::vector rebuilt; rebuilt.reserve(named.size() + 1); rebuilt.push_back(std::move(pool())); for (auto& b : named) rebuilt.push_back(std::move(b)); banks_ = std::move(rebuilt); for (std::size_t i = 0; i < banks_.size(); ++i) banks_[i].ordinal = static_cast(i); return true; } bool BankBook::evacuate(const std::string& id) { if (id == kPoolBankId) return false; // pool is un-evacuable (it is the target) Bank* src = bank(id); if (src == nullptr) return false; // Move every member into the pool, index-only, observing destination collapse. // Snapshot the members first, then clear the source — BankIndex has no bulk move, // and adding into the pool must not alias the vector we are draining. BankIndex& poolIndex = pool().index; const std::vector members = src->index.all(); // copy for (const auto& s : members) poolIndex.add(s); // Added or Collapsed; either way the pool now holds the hash src->index = BankIndex{}; // leave the evacuated bank empty return true; } // --------------------------------------------------------------------------- // Active bank // --------------------------------------------------------------------------- bool BankBook::setActiveBank(const std::string& id) { if (bank(id) == nullptr) return false; // unknown id never corrupts state activeBankId_ = id; return true; } BankIndex& BankBook::activeIndex() { // activeBankId_ always names a live bank; it falls back to the pool on delete. return bank(activeBankId_)->index; } const BankIndex& BankBook::activeIndex() const { return bank(activeBankId_)->index; } // --------------------------------------------------------------------------- // Sample movement (index-only) // --------------------------------------------------------------------------- namespace { // Adds `s` to `dest` and maps the BankIndex outcome onto the transfer outcome for // the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes // (absolute path / empty id) cannot occur here: the sample already passed add() on // the source side, so its path and id are already valid. TransferResult applyDestAdd(BankIndex& dest, const Sample& s, TransferResult gained) { return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained; } } // namespace TransferResult BankBook::moveSample(const std::string& sampleId, const std::string& fromBankId, const std::string& toBankId) { Bank* from = bank(fromBankId); Bank* to = bank(toBankId); if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; if (fromBankId == toBankId) return TransferResult::RejectedSameBank; const Sample* s = from->index.query(sampleId); if (s == nullptr) return TransferResult::RejectedSampleAbsent; // Copy the sample out before removing it: query returns a pointer into the // source vector that remove() invalidates. const Sample moved = *s; from->index.remove(sampleId); // source loses the entry unconditionally on a move return applyDestAdd(to->index, moved, TransferResult::Moved); } TransferResult BankBook::copySample(const std::string& sampleId, const std::string& fromBankId, const std::string& toBankId) { Bank* from = bank(fromBankId); Bank* to = bank(toBankId); if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; if (fromBankId == toBankId) return TransferResult::RejectedSameBank; const Sample* s = from->index.query(sampleId); if (s == nullptr) return TransferResult::RejectedSampleAbsent; const Sample copy = *s; // source entry is left intact return applyDestAdd(to->index, copy, TransferResult::Copied); } // --------------------------------------------------------------------------- // Sample removal (index-only) + the last-reference query // --------------------------------------------------------------------------- RemoveResult BankBook::removeSample(const std::string& sampleId, const std::string& fromBankId, RemoveScope scope) { if (scope == RemoveScope::AllBanks) { // Latent seam: purge the id from every bank that holds it. fromBankId is // ignored (the id is dropped book-wide). Removed iff at least one drop landed. bool any = false; for (auto& b : banks_) if (b.index.remove(sampleId)) { b.slots.remove(sampleId); // keep SlotMap in sync: leave an empty gap any = true; } return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent; } // ThisBank (default, the only surfaced verb): drop from the one named source bank. Bank* from = bank(fromBankId); if (from == nullptr) return RemoveResult::RejectedUnknownBank; if (!from->index.remove(sampleId)) return RemoveResult::RejectedSampleAbsent; from->slots.remove(sampleId); // keep SlotMap in sync: the removed sample's slot becomes a gap return RemoveResult::Removed; } bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) { for (auto& b : banks_) if (b.index.query(sampleId) != nullptr) return b.index.updateInPlace(sampleId, updated); return false; // no bank holds the id } // --------------------------------------------------------------------------- // Sample display order (L7) — SlotMap driven, index membership untouched // --------------------------------------------------------------------------- namespace { // The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed. std::vector indexIds(const BankIndex& idx) { std::vector ids; for (const auto& s : idx.all()) ids.push_back(s.id); return ids; } // Squares one bank's SlotMap with its index membership. A map with NO overlap with the // index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from // insertion order; an existing map is reconciled (drop stale markers, append unmapped). void reconcileBankSlots(Bank& b) { const std::vector live = indexIds(b.index); if (b.slots.empty()) { b.slots.resetDense(live); // migration / first-population default: dense, no gaps return; } b.slots.reconcile(live); // partial map: keep positions, drop stale, append new } } // namespace void BankBook::reconcileSlots() { for (auto& b : banks_) reconcileBankSlots(b); } std::vector BankBook::orderedSampleIds(const std::string& bankId) { Bank* b = bank(bankId); if (b == nullptr) return {}; reconcileBankSlots(*b); // ensure the map covers all live members return b->slots.orderedIds(); } bool BankBook::reorderSample(const std::string& id, const std::string& bankId, int targetSlot) { Bank* b = bank(bankId); if (b == nullptr) return false; if (b->index.query(id) == nullptr) return false; // bank does not hold the sample reconcileBankSlots(*b); // complete the target space first return b->slots.reorder(id, targetSlot); // gap-preserving; index untouched } bool BankBook::replaceSample(const std::string& newId, const std::string& oldId, const std::string& bankId) { if (newId == oldId) return false; Bank* b = bank(bankId); if (b == nullptr) return false; // Both the dragged sample and the occupant must live in this bank. if (b->index.query(newId) == nullptr) return false; if (b->index.query(oldId) == nullptr) return false; reconcileBankSlots(*b); // complete the map so oldId's slot is known // Capture the target slot BEFORE any mutation so the position survives the removal. const int targetSlot = b->slots.slotOf(oldId); if (targetSlot < 0) return false; // occupant not positioned (shouldn't happen post-reconcile) // POOL GUARD (settled): the occupant's index-removal must pass the SAME guard the // remove verb applies. Commit the removal FIRST so a rejection is a true no-op (no // slot mutation happened yet). removeSample(ThisBank) permits per-sample removal from // any bank incl. the pool (per-sample remove is not a pool privilege), so it succeeds // whenever the occupant exists — which we verified — but routing through it means a // future pool-floor guard added to remove governs replace identically, one rule. const RemoveResult r = removeSample(oldId, bankId, RemoveScope::ThisBank); if (r != RemoveResult::Removed) return false; // guard rejected -> nothing changed // Occupant gone from the index; now update the slot markers. Drop oldId's now-dangling // marker to free the target slot, then move newId onto it. reorder onto an EMPTY slot // places newId there exactly and empties newId's own (source) slot — the slot position // is preserved and only its occupant changed, exactly the replace contract. b->slots.remove(oldId); b->slots.reorder(newId, targetSlot); return true; } bool BankBook::hashReferencedElsewhere(const std::string& hash, const std::string& exceptBankId) const { if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash) for (const auto& b : banks_) { if (b.id == exceptBankId) continue; // the removed-from bank is excluded if (b.index.findByHash(hash) != nullptr) return true; } return false; } std::vector BankBook::referencedPaths() const { // Union across the whole book (pool first, then named banks in ordinal order — // banks_ is kept ordinal-sorted). De-duplicate by exact string so a file a copy // put in two banks appears once. Skip empty paths (they reference no file). std::vector paths; std::unordered_set seen; for (const auto& b : banks_) { for (const auto& s : b.index.all()) { if (s.relativePath.empty()) continue; if (seen.insert(s.relativePath).second) paths.push_back(s.relativePath); } } return paths; } // =========================================================================== // 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 intToStr(int v) { char buf[16]; std::snprintf(buf, sizeof(buf), "%d", v); return buf; } 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); } 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; }; } // namespace std::string SlotMap::serialize() const { // Array of {id, slot} objects in ascending slot order (entries_ is kept sorted). std::string out; out += '['; for (std::size_t i = 0; i < entries_.size(); ++i) { if (i) out += ','; ObjWriter e(out); e.keyStr("id", entries_[i].id); e.keyRaw("slot", intToStr(entries_[i].slot)); } out += ']'; return out; } 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 BankIndex::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 { class Parser { public: explicit Parser(const std::string& s) : s_(s) {} // Parses a book blob into a bank set + active id. On success fills the out-params // and returns true. Distinguishes 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 (ordinal normalize + active resolve). bool parseBook(std::vector& banks, std::string& activeBank); private: const std::string& s_; std::size_t pos_ = 0; bool eof() const { return pos_ >= s_.size(); } 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 parseInt(int& out); bool parseKey(std::string& key); bool skipValue(); // Captures the raw source text of one JSON value (object / array / string / // scalar) verbatim, so a nested BankIndex blob can be handed to its own parser. bool captureValue(std::string& raw); bool parseBank(Bank& out); // Parses the "slots" array ([{id, slot}, ...]) into (id, slot) pairs. An empty // array is valid (an empty bank). Malformed structure fails the whole parse; the // pair-level defensive repair (dupes/conflicts) lives in SlotMap::fromEntries. bool parseSlots(std::vector>& out); }; 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': { 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) { 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; codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); } else if (hi >= 0xDC00 && hi <= 0xDFFF) { return false; // unpaired low surrogate } 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 } bool Parser::parseInt(int& out) { skipWs(); std::size_t start = pos_; if (!eof() && (s_[pos_] == '-' || s_[pos_] == '+')) ++pos_; std::size_t digitsStart = pos_; while (!eof() && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_; if (pos_ == digitsStart) return false; // no digits long v = 0; try { v = std::stol(s_.substr(start, pos_ - start)); } catch (...) { return false; // out of long range → malformed } if (v < INT_MIN || v > INT_MAX) return false; out = static_cast(v); return true; } bool Parser::parseKey(std::string& key) { if (!parseString(key)) return false; if (!consume(':')) return false; return true; } bool Parser::skipValue() { std::string raw; return captureValue(raw); } // Records the raw source span of one JSON value starting at the current position // (after whitespace) so it can be re-parsed by a nested parser. Handles nested // objects/arrays with string-aware brace matching (braces inside strings ignored). bool Parser::captureValue(std::string& raw) { skipWs(); if (eof()) return false; std::size_t start = pos_; char c = s_[pos_]; if (c == '"') { std::string tmp; if (!parseString(tmp)) return false; raw.assign(s_, start, pos_ - start); return true; } 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; // advances past the string continue; } if (d == open) ++depth; else if (d == close) --depth; ++pos_; } if (depth != 0) return false; raw.assign(s_, start, pos_ - start); return true; } // bare scalar (number / true / false / null) while (!eof()) { char d = s_[pos_]; if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' || d == '\n' || d == '\r') break; ++pos_; } if (pos_ == start) return false; raw.assign(s_, start, pos_ - start); return true; } bool Parser::parseBank(Bank& b) { if (!consume('{')) return false; skipWs(); if (consume('}')) return false; // a bank object must at least carry an id bool haveId = false; bool haveIndex = false; do { std::string key; if (!parseKey(key)) return false; if (key == "id") { if (!parseString(b.id)) return false; haveId = true; } else if (key == "displayName") { if (!parseString(b.displayName)) return false; } else if (key == "ordinal") { if (!parseInt(b.ordinal)) return false; } else if (key == "index") { std::string raw; if (!captureValue(raw)) return false; auto idx = BankIndex::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(pairs)) return false; b.slots = SlotMap::fromEntries(pairs); } else { if (!skipValue()) return false; // forward-compat unknown keys } } while (consume(',')); if (!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 Parser::parseSlots(std::vector>& out) { out.clear(); if (!consume('[')) return false; skipWs(); if (consume(']')) return true; // empty slot array — a bank with no positions yet do { if (!consume('{')) return false; std::string id; int slot = 0; bool haveId = false, haveSlot = false; do { std::string k; if (!parseKey(k)) return false; if (k == "id") { if (!parseString(id)) return false; haveId = true; } else if (k == "slot") { if (!parseInt(slot)) return false; haveSlot = true; } else { if (!skipValue()) return false; } // forward-compat } while (consume(',')); if (!consume('}')) return false; if (!haveId || !haveSlot) return false; // a slot entry needs both out.emplace_back(std::move(id), slot); } while (consume(',')); return consume(']'); } bool Parser::parseBook(std::vector& banks, std::string& activeBank) { banks.clear(); activeBank.clear(); if (!consume('{')) return false; skipWs(); if (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 (!parseKey(key)) return false; if (key == "banks") { sawBanks = true; if (!consume('[')) return false; skipWs(); if (!consume(']')) { do { Bank b; if (!parseBank(b)) return false; parsedBanks.push_back(std::move(b)); } while (consume(',')); if (!consume(']')) return false; } } else if (key == "activeBank") { if (!parseString(activeBank)) return false; } else if (key == "samples") { // Legacy marker. The legacy index is re-parsed from the whole input below // (BankIndex::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 (!skipValue()) return false; } else { if (!skipValue()) return false; // version, or unknown } } while (consume(',')); if (!consume('}')) return false; skipWs(); if (!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 = BankIndex::deserialize(s_); 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; // --- 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. { std::vector seenKeys; seenKeys.reserve(parsedBanks.size()); for (auto& b : parsedBanks) { 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); } } 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& json) { std::vector banks; std::string activeBank; Parser p(json); if (!p.parseBook(banks, activeBank)) return std::nullopt; BankBook book; book.adoptBanks(std::move(banks), activeBank); return book; } // --------------------------------------------------------------------------- // Active-bank cycle ordering (pure, free function — mirror of nextModeId) // --------------------------------------------------------------------------- std::string nextBankId(const std::vector& orderedBankIds, const std::string& currentBankId) { if (orderedBankIds.empty()) return {}; // nothing to cycle to for (std::size_t i = 0; i < orderedBankIds.size(); ++i) { if (orderedBankIds[i] == currentBankId) return orderedBankIds[(i + 1) % orderedBankIds.size()]; // wrap past the last } // Active id not in the list (stale/unknown) — jump to the first id as a sane // home rather than returning "" (matches nextModeId's fallback). return orderedBankIds.front(); } BankBook BankBook::loadFromPersisted(const std::string& banksJson, const std::string& legacyJson) { // Precedence 1: the authoritative `banks` blob. A present-but-malformed blob is // an error, not an absence — degrade to an empty book rather than falling through // to a stale legacy key (which would resurrect superseded single-bank state). if (!banksJson.empty()) { auto book = deserialize(banksJson); return book ? std::move(*book) : BankBook{}; } // Precedence 2: no `banks` yet, but a legacy `bank_index` — one-way pool migration // (deserialize's parse-time legacy path promotes it into the pool). A malformed // legacy blob likewise degrades to empty. if (!legacyJson.empty()) { auto book = deserialize(legacyJson); return book ? std::move(*book) : BankBook{}; } // Precedence 3: a brand-new / never-captured project — a fresh empty book. return BankBook{}; } } // namespace reasampler