Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green
This commit is contained in:
@@ -0,0 +1,737 @@
|
||||
#include "core/model/bank_book.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// bank_book implementation.
|
||||
//
|
||||
// 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.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05).
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
BankModel* BankBook::index(const std::string& id) {
|
||||
Bank* b = bank(id);
|
||||
return b ? &b->index : nullptr;
|
||||
}
|
||||
|
||||
const BankModel* 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<int>(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<char>(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<int>(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<Bank> 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<int>(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<Bank> 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<int>(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 — BankModel has no bulk move,
|
||||
// and adding into the pool must not alias the vector we are draining.
|
||||
BankModel& poolIndex = pool().index;
|
||||
const std::vector<Sample> 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 = BankModel{}; // 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;
|
||||
}
|
||||
|
||||
BankModel& BankBook::activeIndex() {
|
||||
// activeBankId_ always names a live bank; it falls back to the pool on delete.
|
||||
return bank(activeBankId_)->index;
|
||||
}
|
||||
|
||||
const BankModel& BankBook::activeIndex() const {
|
||||
return bank(activeBankId_)->index;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample movement (index-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Adds `s` to `dest` and maps the BankModel 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(BankModel& 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<std::string> indexIds(const BankModel& idx) {
|
||||
std::vector<std::string> 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<std::string> 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<std::string> 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<std::string> 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<std::string> paths;
|
||||
std::unordered_set<std::string> 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 {
|
||||
|
||||
// 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 (ordinal normalize +
|
||||
// active resolve).
|
||||
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;
|
||||
|
||||
// --- 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<std::string> 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<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;
|
||||
|
||||
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<std::string>& 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
|
||||
@@ -0,0 +1,374 @@
|
||||
#pragma once
|
||||
// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free
|
||||
// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the
|
||||
// third instance of the same "pure registry + JSON round-trip, unit-tested outside
|
||||
// the DAW" pattern as bank_model and view_mode_model.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only.
|
||||
//
|
||||
// -- What it is --------------------------------------------------------------
|
||||
//
|
||||
// An ordered registry of banks. Each bank = { stable id, display name, ordinal,
|
||||
// BankModel }. The book WRAPS N BankModel instances — bank_model / BankModel are
|
||||
// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is
|
||||
// index-only (remove from source's BankModel, add to destination's); files never
|
||||
// relocate — banks are logical groupings over one shared file pool.
|
||||
//
|
||||
// -- The pool (privileged, not special-cased) --------------------------------
|
||||
//
|
||||
// Structurally the pool is bank-zero — one Bank among many, seeded on construction
|
||||
// with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0.
|
||||
// Semantically it is privileged, and the privileges are enforced HERE in the pure
|
||||
// rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell):
|
||||
// * always exists (seeded on construction; the book never reaches zero banks)
|
||||
// * un-deletable (deleteBank rejects the pool)
|
||||
// * un-renamable (renameBank rejects the pool)
|
||||
// * un-evacuable (evacuate rejects the pool — the pool is evacuation's
|
||||
// destination, not a source)
|
||||
//
|
||||
// -- Id minting is the CALLER'S job (design decision) ------------------------
|
||||
//
|
||||
// createBank takes a caller-supplied stable id, mirroring bank_model's "id
|
||||
// assigned by the caller" and view_mode_model's mode ids. The pure core has no
|
||||
// REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source
|
||||
// would not be a real GUID anyway, and keeping ids caller-supplied lets the B2
|
||||
// shell mint a genuine REAPER GUID while the model stays pure and deterministically
|
||||
// testable. The model still enforces the invariants: non-empty, unique, not the
|
||||
// reserved pool id.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/model/slot_map.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Q-W1 interim: this god module re-namespaces in its own split wave; until then the
|
||||
// clean model types it wraps live in reasampler::model.
|
||||
using namespace model;
|
||||
|
||||
// The pool's fixed identity. The id is reserved: createBank rejects it, and the
|
||||
// pool is always bank-zero. The name is fixed: renameBank rejects the pool.
|
||||
inline constexpr const char* kPoolBankId = "pool";
|
||||
inline constexpr const char* kPoolBankName = "Pool";
|
||||
|
||||
// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h.
|
||||
// Included above because Bank carries one per bank.
|
||||
|
||||
// One bank: a stable id, a display name, an ordinal (tab/display order), and its
|
||||
// own BankModel. The pool is the bank whose id == kPoolBankId.
|
||||
struct Bank {
|
||||
std::string id; // stable, persisted; the pool's is kPoolBankId
|
||||
std::string displayName; // mutable for named banks; fixed "Pool" for the pool
|
||||
int ordinal = 0; // display order; pool is 0, named banks 1..N
|
||||
BankModel index; // this bank's samples
|
||||
SlotMap slots; // L7 display positions of this bank's samples (gap-preserving)
|
||||
|
||||
bool isPool() const { return id == kPoolBankId; }
|
||||
|
||||
bool operator==(const Bank& o) const {
|
||||
return id == o.id && displayName == o.displayName &&
|
||||
ordinal == o.ordinal && index == o.index && slots == o.slots;
|
||||
}
|
||||
};
|
||||
|
||||
// Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op
|
||||
// reports what happened rather than silently mutating on a bad request.
|
||||
// - Moved / Copied: the sample was transferred to the destination as a new entry.
|
||||
// - Collapsed: the destination already held the hash; it collapsed onto the
|
||||
// existing entry (a no-op add on the destination side). For a
|
||||
// MOVE the source entry is STILL removed; for a COPY the source
|
||||
// entry is (as always) retained.
|
||||
// - RejectedUnknownBank: a source or destination id named no bank.
|
||||
// - RejectedSampleAbsent: the sample id was not in the source bank.
|
||||
// - RejectedSameBank: source and destination were the same bank (no-op).
|
||||
enum class TransferResult {
|
||||
Moved,
|
||||
Copied,
|
||||
Collapsed,
|
||||
RejectedUnknownBank,
|
||||
RejectedSampleAbsent,
|
||||
RejectedSameBank,
|
||||
};
|
||||
|
||||
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default
|
||||
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam —
|
||||
// live and tested at the model level, promotable later behind this parameter without
|
||||
// a rewrite, but never wired to an affordance in B5.
|
||||
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry
|
||||
// in another bank survives (no cross-bank cascade — dedup is per-bank).
|
||||
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
|
||||
// ("purge from the library"). Latent; unsurfaced.
|
||||
enum class RemoveScope {
|
||||
ThisBank,
|
||||
AllBanks,
|
||||
};
|
||||
|
||||
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports
|
||||
// what happened rather than silently mutating on a bad request.
|
||||
// - Removed: at least one index entry was dropped.
|
||||
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
|
||||
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
|
||||
enum class RemoveResult {
|
||||
Removed,
|
||||
RejectedUnknownBank,
|
||||
RejectedSampleAbsent,
|
||||
};
|
||||
|
||||
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
|
||||
// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
|
||||
// multi-bank phase — mirror of bank_model / view_mode_model.
|
||||
class BankBook {
|
||||
public:
|
||||
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
|
||||
// active bank = pool; zero named banks.
|
||||
|
||||
// -- Bank lifecycle ------------------------------------------------------
|
||||
|
||||
// Creates a named bank with the caller-supplied stable id and display name,
|
||||
// assigning the next ordinal. Rejects (returns false, no mutation) an empty id,
|
||||
// a duplicate id, the reserved pool id, or a display name that duplicates an
|
||||
// existing bank's name (including the pool's "Pool"). Display-name uniqueness is
|
||||
// trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide.
|
||||
bool createBank(const std::string& id, const std::string& displayName);
|
||||
|
||||
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or a
|
||||
// target name already used by a DIFFERENT bank (trimmed + case-insensitive, as
|
||||
// createBank). Renaming a bank to its own current name is a no-op success.
|
||||
bool renameBank(const std::string& id, const std::string& displayName);
|
||||
|
||||
// Deletes a NAMED bank, removing it (and its member index entries) from the
|
||||
// registry. Files are a shell/prune concern and are NOT touched here. Rejects
|
||||
// (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are
|
||||
// compacted so the pool stays 0 and named banks stay contiguous 1..N. If the
|
||||
// deleted bank was active, the active bank falls back to the pool.
|
||||
bool deleteBank(const std::string& id);
|
||||
|
||||
// Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range),
|
||||
// shifting the others to keep ordinals contiguous. The pool is pinned at 0 and
|
||||
// cannot be reordered. Rejects (false, no mutation) an unknown id or the pool.
|
||||
bool reorderBank(const std::string& id, int newOrdinal);
|
||||
|
||||
// Moves EVERY member of a named bank into the pool (index-only, observing the
|
||||
// same destination-collapse-by-hash as a move), leaving the bank empty. Rejects
|
||||
// (false, no mutation) an unknown id or the pool (the pool is the destination,
|
||||
// never a source). Returns true on success even if the bank was already empty.
|
||||
bool evacuate(const std::string& id);
|
||||
|
||||
// -- Active bank ---------------------------------------------------------
|
||||
|
||||
// The active bank's id (the capture target). Defaults to the pool.
|
||||
const std::string& activeBankId() const { return activeBankId_; }
|
||||
|
||||
// Sets the active bank. Rejects (returns false, no change) an id that names no
|
||||
// bank — an invalid set never corrupts state.
|
||||
bool setActiveBank(const std::string& id);
|
||||
|
||||
// The active bank's BankModel — the index the capture layer adds to. Always
|
||||
// valid (the active id always names a live bank; it falls back to the pool).
|
||||
BankModel& activeIndex();
|
||||
const BankModel& activeIndex() const;
|
||||
|
||||
// -- Sample movement (index-only; files never relocate) ------------------
|
||||
|
||||
// Moves a sample by id from `fromBankId` to `toBankId`: removes it from the
|
||||
// source index and adds it to the destination (observing destination
|
||||
// collapse-by-hash). See TransferResult for the full outcome set.
|
||||
TransferResult moveSample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
const std::string& toBankId);
|
||||
|
||||
// Copies a sample by id from `fromBankId` to `toBankId`: the source entry is
|
||||
// retained, the destination gains it (observing destination collapse-by-hash).
|
||||
// Same hash may then live in both banks — cross-bank dedup is NOT enforced.
|
||||
TransferResult copySample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
const std::string& toBankId);
|
||||
|
||||
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --
|
||||
|
||||
// Drops a sample's index entry (the sample-level sibling of move/copy/evacuate).
|
||||
// Index-only and non-destructive to the file: a last-reference remove leaves the
|
||||
// file on disk, orphaned until Phase R prune — remove NEVER deletes bytes.
|
||||
//
|
||||
// Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from
|
||||
// `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank
|
||||
// that holds it. See RemoveResult for the outcome set.
|
||||
// * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent
|
||||
// if that bank does not hold the id; Removed on a drop.
|
||||
// * AllBanks: `fromBankId` is ignored (the id is purged book-wide);
|
||||
// RejectedSampleAbsent if NO bank held the id; Removed otherwise.
|
||||
// No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer).
|
||||
RemoveResult removeSample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
RemoveScope scope = RemoveScope::ThisBank);
|
||||
|
||||
// -- Sample display order (L7; index membership untouched) ---------------
|
||||
|
||||
// The bank's sample ids in DISPLAY (slot) order — the deterministic order the grid
|
||||
// iterates, sourced from the bank's SlotMap. Reconciles the map against live index
|
||||
// membership first (drops stale markers, appends unmapped samples densely), so a
|
||||
// freshly-migrated or out-of-band-mutated bank always yields a complete order. An
|
||||
// unknown bank id yields an empty vector. Const-logical but reconciles lazily, so
|
||||
// it is a non-const member.
|
||||
std::vector<std::string> orderedSampleIds(const std::string& bankId);
|
||||
|
||||
// Ensures every bank's SlotMap is consistent with its index membership: seeds a
|
||||
// map that has NO overlap with its index from insertion order (the pre-L7 migration
|
||||
// default — dense, no gaps), and reconciles a partially-populated map (drop stale,
|
||||
// append unmapped). Idempotent. Called after deserialize and after any capture/
|
||||
// transfer that added samples out-of-band of the L7 reorder path.
|
||||
void reconcileSlots();
|
||||
|
||||
// Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see
|
||||
// SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and
|
||||
// metadata are untouched (capture != placement holds). Reconciles the bank's slots
|
||||
// first so the target space is complete. Returns false (no mutation) on an unknown
|
||||
// bank or an id the bank does not hold.
|
||||
bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot);
|
||||
|
||||
// Alt-replace (L7 F3): the dragged sample `newId` (already a member of `bankId`)
|
||||
// takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s
|
||||
// index (index-only, same semantics as removeSample ThisBank — the file stays on
|
||||
// disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the
|
||||
// last-reference case). Position of the slot is preserved; only its occupant changes.
|
||||
//
|
||||
// POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the
|
||||
// remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed.
|
||||
// For the pool this is permitted whenever the occupant exists (per-sample removal
|
||||
// is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate,
|
||||
// never per-sample remove). If the removal would be rejected (occupant absent), the
|
||||
// whole replace is rejected: false, NO mutation (neither the index nor the slots
|
||||
// change), so the shell can fall back to the default insert-shift or a no-op.
|
||||
// Rejects (false, no mutation) an unknown bank, a `newId`/`oldId` the bank does not
|
||||
// hold, or `newId == oldId`. NEVER touches disk; introduces no new deletion authority.
|
||||
bool replaceSample(const std::string& newId, const std::string& oldId,
|
||||
const std::string& bankId);
|
||||
|
||||
// Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture):
|
||||
// finds the bank holding `sampleId` and replaces its entry with `updated`
|
||||
// (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in
|
||||
// ordinal order and updates the FIRST holder (a sample id is unique within a
|
||||
// bank; the same id living in two banks via copy would update the earliest, which
|
||||
// is acceptable — re-capture operates on the panel's focused single selection).
|
||||
// Returns false (no mutation) if no bank holds the id or the replacement's path
|
||||
// is absolute. Index-only and non-destructive to the timeline.
|
||||
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
|
||||
|
||||
// Reference-count query backing the confirm-on-last-reference guardrail: does any
|
||||
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
|
||||
//
|
||||
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key
|
||||
// the whole model already reasons in (findByHash / collapse-by-hash), and two
|
||||
// entries that share content share one file — so "some other bank still references
|
||||
// this hash" is exactly "removing here does not orphan the file." An EMPTY hash is
|
||||
// never matched (it does not participate in dedup, mirroring findByHash), so an
|
||||
// empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting
|
||||
// direction (we cannot prove another bank shares an unhashed file).
|
||||
bool hashReferencedElsewhere(const std::string& hash,
|
||||
const std::string& exceptBankId) const;
|
||||
|
||||
// Every project-relative file path referenced by ANY bank in the book, pool
|
||||
// included — the union across the whole book (Phase R, prune). This is the
|
||||
// safety-critical referenced-set the prune core subtracts: a file referenced by
|
||||
// any bank (INCLUDING via a copy into a second bank) appears here, so prune never
|
||||
// reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings —
|
||||
// no normalization), first-seen order across banks in ordinal order then sample
|
||||
// insertion order, and DE-DUPLICATED (one file referenced by N banks appears
|
||||
// once). An empty relativePath is skipped (it references no file). Additive
|
||||
// read-only query; adds no mutation and no coupling to Phase R.
|
||||
std::vector<std::string> referencedPaths() const;
|
||||
|
||||
// -- Query ---------------------------------------------------------------
|
||||
|
||||
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
|
||||
Bank* bank(const std::string& id);
|
||||
const Bank* bank(const std::string& id) const;
|
||||
|
||||
// The bank's BankModel by id, or nullptr. Convenience over bank()->index.
|
||||
BankModel* index(const std::string& id);
|
||||
const BankModel* index(const std::string& id) const;
|
||||
|
||||
// The pool (always present). Never null.
|
||||
Bank& pool();
|
||||
const Bank& pool() const;
|
||||
|
||||
// All banks in ordinal order (pool first). The pool is always banks()[0].
|
||||
const std::vector<Bank>& banks() const { return banks_; }
|
||||
|
||||
std::size_t size() const { return banks_.size(); } // >= 1 (the pool)
|
||||
|
||||
bool operator==(const BankBook& o) const {
|
||||
return banks_ == o.banks_ && activeBankId_ == o.activeBankId_;
|
||||
}
|
||||
|
||||
// -- Persistence ---------------------------------------------------------
|
||||
|
||||
// Serializes the whole book to a JSON string (lossless round-trip): the pool
|
||||
// folded in as bank-zero + named banks + per-bank indices + ordinals + active
|
||||
// id. deserialize(serialize(x)) == x.
|
||||
std::string serialize() const;
|
||||
|
||||
// Parses a book JSON produced by serialize(). std::nullopt on malformed input.
|
||||
//
|
||||
// LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an
|
||||
// object with a "samples" array and no "banks" key) is promoted into the pool's
|
||||
// index, yielding a book of { pool } with zero named banks — one-way, lossless.
|
||||
// After migration the book blob is authoritative (the caller persists the book
|
||||
// shape going forward; the legacy key is retired by the B2 shell).
|
||||
static std::optional<BankBook> deserialize(const std::string& json);
|
||||
|
||||
// Resolve a BankBook from the two persisted ext-state values a project may carry:
|
||||
// the authoritative `banks` blob and the retired-but-possibly-present legacy
|
||||
// `bank_index` blob. The persist shell (B2) hands both raw strings straight here so
|
||||
// the load-source decision stays REAPER-free and unit-tested. Precedence:
|
||||
// 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is
|
||||
// MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks`
|
||||
// blob is an error, not an absence; return an empty book so a stale legacy key
|
||||
// can never resurrect a superseded single-bank state over a broken book.
|
||||
// 2. else non-empty `legacyJson` -> deserialize it (one-way pool migration).
|
||||
// 3. else (both absent/empty) -> a fresh empty book (pool only).
|
||||
// Never returns nullopt: an unloadable input degrades to the empty book (matching
|
||||
// the shell's existing "malformed -> ignore, start empty" behaviour), so the caller
|
||||
// has one branchless install path.
|
||||
static BankBook loadFromPersisted(const std::string& banksJson,
|
||||
const std::string& legacyJson);
|
||||
|
||||
private:
|
||||
std::vector<Bank> banks_; // ordinal order; banks_[0] is always the pool
|
||||
std::string activeBankId_; // always names a live bank; defaults to pool
|
||||
|
||||
// True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key
|
||||
// (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check;
|
||||
// pass exceptId=id to let a bank keep (or re-case/-space) its own name.
|
||||
bool displayNameTaken(const std::string& name, const std::string& exceptId) const;
|
||||
|
||||
// Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a
|
||||
// contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any
|
||||
// structural change (create / delete / reorder).
|
||||
void normalizeOrdinals();
|
||||
|
||||
// Replaces the book's banks with a parsed set, normalizes ordinals, and resolves
|
||||
// the active bank (falling back to the pool if the id names no bank). Used only
|
||||
// by deserialize; kept private so the public surface stays create/rename/etc.
|
||||
void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
|
||||
};
|
||||
|
||||
// The next bank id to activate when cycling the active bank forward, in ordinal
|
||||
// order (the ids arrive pool-first, named 1..N, matching banks()). Wraps: the id
|
||||
// after the last returns the first (pool → named → … → pool). This is the pure
|
||||
// decision behind the "cycle active bank" action — the shell reads the book's
|
||||
// ordered bank ids + current active id, asks for the next, and activates it.
|
||||
// * empty list -> "" (nothing to cycle to)
|
||||
// * single id (pool-only) -> that id (a one-bank book stays put)
|
||||
// * currentBankId not present -> the first id (a sane home to jump to)
|
||||
// Exposed as a free function (not a BankBook member) so it is unit-testable against
|
||||
// a bare id vector without a full book. Mirror of view_mode_model's nextModeId.
|
||||
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
|
||||
const std::string& currentBankId);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,461 @@
|
||||
#include "core/model/bank_model.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// bank_model implementation.
|
||||
//
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer,
|
||||
// no per-module Parser copy). 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 DOMAIN parser over json::Reader 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::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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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 <alpha>: 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<unsigned char>(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<Sample> BankModel::byTier(Tier tier) const {
|
||||
std::vector<Sample> 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<int>(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";
|
||||
|
||||
// Phase S seam fields (D-B). Emitted as null when absent (same shape as `key`
|
||||
// and `provenance`) so pre-Phase-S JSON — which 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<int>(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. The lexical layer (strings, numbers, skip) lives in
|
||||
// core/json; only the Sample/index DOMAIN grammar lives here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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<int>(SourceMode::MasterMix) ||
|
||||
v > static_cast<int>(SourceMode::Realtime))
|
||||
return false;
|
||||
s.sourceMode = static_cast<SourceMode>(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<int>(Tier::Scratch) ||
|
||||
v > static_cast<int>(Tier::Archive))
|
||||
return false;
|
||||
s.tier = static_cast<Tier>(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<Sample> 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> BankModel::deserialize(const std::string& blob) {
|
||||
BankModel idx;
|
||||
json::Reader r(blob);
|
||||
if (!parseIndex(r, idx)) return std::nullopt;
|
||||
return idx;
|
||||
}
|
||||
|
||||
} // namespace reasampler::model
|
||||
@@ -0,0 +1,217 @@
|
||||
#pragma once
|
||||
// bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so
|
||||
// it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample
|
||||
// bank: the `Sample` metadata struct and the `BankModel` (add / remove / query /
|
||||
// tier moves / dedup-by-hash + JSON round-trip to/from std::string).
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
// How the source audio was obtained. Kept in the pure core (no REAPER coupling);
|
||||
// the capture backends (M3/M8) map their own notion onto these.
|
||||
enum class SourceMode {
|
||||
MasterMix, // offline render of the master output
|
||||
SelectedTracks, // offline render of selected tracks
|
||||
SelectedItems, // offline render of selected media items
|
||||
TimeSelection, // offline render bounded by the time selection
|
||||
RazorArea, // offline render of a razor edit area
|
||||
Realtime, // realtime record of wet output
|
||||
};
|
||||
|
||||
// Retention tier. `Scratch` is auto-prunable working material; `Archive` is kept.
|
||||
enum class Tier {
|
||||
Scratch,
|
||||
Archive,
|
||||
};
|
||||
|
||||
// Sample-accurate source bounds, in both project seconds and PPQ (ticks). Both
|
||||
// are stored because capture needs seconds and musical placement needs PPQ; we
|
||||
// refuse to re-derive one from the other and risk rounding (precision invariant).
|
||||
struct SourceRange {
|
||||
double startSeconds = 0.0;
|
||||
double endSeconds = 0.0;
|
||||
double startPpq = 0.0;
|
||||
double endPpq = 0.0;
|
||||
|
||||
bool operator==(const SourceRange& o) const;
|
||||
};
|
||||
|
||||
// Present only when a sample was resampled FROM another sample. Carries the
|
||||
// parent's id and the FX-chain snapshot string (a thin drift fingerprint, NOT a
|
||||
// restorable chunk) captured at resample time; the re-capture-from-source action
|
||||
// (M10) uses it to detect chain drift and replay the original capture request.
|
||||
struct Provenance {
|
||||
std::string parentSampleId;
|
||||
std::string fxChainSnapshot;
|
||||
|
||||
bool operator==(const Provenance& o) const;
|
||||
};
|
||||
|
||||
// Loudness / level metrics measured from the captured file.
|
||||
struct Levels {
|
||||
double peakDb = 0.0;
|
||||
double rmsDb = 0.0;
|
||||
double lufs = 0.0;
|
||||
|
||||
bool operator==(const Levels& o) const;
|
||||
};
|
||||
|
||||
// Sample-accurate sustain-loop bounds, as frame indices into the captured file
|
||||
// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like
|
||||
// sampleRate or length — consumed by the future MIDI-playback instrument to hold
|
||||
// notes past the recorded length. Modeled as one optional struct (not two loose
|
||||
// optionals) so "both points or neither" is a structural invariant, not a rule to
|
||||
// re-check at every boundary. Frame indices, not seconds, because the loop is a
|
||||
// per-sample-frame contract; the instrument reads the file's sample rate to relate
|
||||
// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end.
|
||||
// start == end is a valid zero-length loop marker.
|
||||
struct LoopPoints {
|
||||
std::int64_t start = 0;
|
||||
std::int64_t end = 0;
|
||||
|
||||
bool operator==(const LoopPoints& o) const;
|
||||
};
|
||||
|
||||
// The metadata record for one captured sample. The audio itself lives in a
|
||||
// project-relative file; `relativePath` is ALWAYS relative (enforced at the
|
||||
// BankModel::add boundary — see AddResult).
|
||||
struct Sample {
|
||||
std::string id; // stable unique id (assigned by the caller)
|
||||
std::string displayName;
|
||||
std::string relativePath; // project-relative; never absolute (invariant)
|
||||
|
||||
SourceMode sourceMode = SourceMode::MasterMix;
|
||||
SourceRange sourceRange;
|
||||
|
||||
// Track GUID(s) the capture came from, when applicable (empty otherwise).
|
||||
std::vector<std::string> trackGuids;
|
||||
|
||||
double wetDry = 1.0; // 1.0 = fully wet, 0.0 = fully dry
|
||||
|
||||
int channelCount = 0;
|
||||
int sampleRate = 0;
|
||||
|
||||
double lengthSeconds = 0.0;
|
||||
double lengthBeats = 0.0;
|
||||
double captureTempo = 0.0; // project tempo (BPM) at capture time
|
||||
|
||||
// Time signature at capture time (L7 F1 — stamped alongside captureTempo so the
|
||||
// bars.beats.subdivisions read-out is stable under later project meter changes).
|
||||
// 0/0 means UNSTAMPED (pre-L7 sample, or a capture that could not read the meter);
|
||||
// the metadata formatter renders a blank musical read-out for 0/0 and keeps s.ms.
|
||||
int captureTimeSigNum = 0; // meter numerator (e.g. 4 in 4/4); 0 = unstamped
|
||||
int captureTimeSigDenom = 0; // meter denominator (e.g. 4 in 4/4); 0 = unstamped
|
||||
|
||||
std::optional<std::string> key; // musical key, when known
|
||||
|
||||
// Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument,
|
||||
// additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S
|
||||
// samples deserialize without them and re-serialize without inventing values.
|
||||
// - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument
|
||||
// can repitch it across the keyboard. DISTINCT from the musical `key` above:
|
||||
// `key` is a human label ("F#m"); `rootNote` is the exact pitch for repitch.
|
||||
// Populated at/after capture only where derivable — left empty (never guessed)
|
||||
// when the source is not a single played note.
|
||||
// - loop: sustain-loop bounds, populated only where explicitly set.
|
||||
std::optional<int> rootNote;
|
||||
std::optional<LoopPoints> loop;
|
||||
|
||||
Levels levels;
|
||||
bool clipped = false;
|
||||
|
||||
Tier tier = Tier::Scratch;
|
||||
|
||||
std::string contentHash; // dedup key (see BankModel)
|
||||
|
||||
std::optional<Provenance> provenance; // set only when resampled
|
||||
|
||||
std::int64_t createdTimestamp = 0; // unix epoch seconds
|
||||
|
||||
bool operator==(const Sample& o) const;
|
||||
bool operator!=(const Sample& o) const { return !(*this == o); }
|
||||
|
||||
// A scratch-tier sample is auto-prunable; archive is kept.
|
||||
bool isAutoPrunable() const { return tier == Tier::Scratch; }
|
||||
};
|
||||
|
||||
// Outcome of BankModel::add. `add` rejects rather than silently mutating:
|
||||
// - RejectedAbsolutePath: relativePath was absolute (precision invariant).
|
||||
// - RejectedEmptyId: id was empty (the collection is keyed by id).
|
||||
// - Collapsed: content hash matched an existing entry; the existing
|
||||
// entry is kept and the add is a no-op (dedup).
|
||||
// - Added: inserted as a new entry.
|
||||
enum class AddResult {
|
||||
Added,
|
||||
Collapsed,
|
||||
RejectedAbsolutePath,
|
||||
RejectedEmptyId,
|
||||
};
|
||||
|
||||
// An ordered, id-keyed collection of Samples with content-hash dedup, tier
|
||||
// moves/filtering, and lossless JSON round-trip. Insertion order is preserved
|
||||
// so a future panel (M5) can iterate in stable order.
|
||||
class BankModel {
|
||||
public:
|
||||
// Adds a sample. Enforces the relative-paths-only invariant and dedups by
|
||||
// content hash (an equal-hash add collapses onto the existing entry rather
|
||||
// than duplicating). See AddResult for the full outcome set.
|
||||
AddResult add(const Sample& sample);
|
||||
|
||||
// Removes the sample with `id`. Returns true if one was removed.
|
||||
bool remove(const std::string& id);
|
||||
|
||||
// Replaces the sample carrying `id` IN PLACE (preserving its position in
|
||||
// insertion order), with `updated`. Used by M10 re-capture-from-source: a
|
||||
// provenanced sample's file is regenerated and its metadata (relativePath,
|
||||
// contentHash, levels, timestamp, ...) refreshed while its identity (id) and
|
||||
// slot are kept, so the bank panel shows the same tile updated rather than a
|
||||
// reordered new entry. `updated.id` should equal `id` (the caller keeps the id
|
||||
// stable); a differing id is written through as given (the caller's contract).
|
||||
// Does NOT dedup — an in-place refresh of one entry is not a new insert, so the
|
||||
// collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false
|
||||
// (no mutation) if `id` is absent or `updated.relativePath` is absolute
|
||||
// (the relative-paths-only invariant still holds for the replacement).
|
||||
bool updateInPlace(const std::string& id, const Sample& updated);
|
||||
|
||||
// Returns the sample with `id`, or nullptr if absent. The pointer is
|
||||
// invalidated by any mutating call.
|
||||
const Sample* query(const std::string& id) const;
|
||||
|
||||
// Returns the sample whose contentHash matches, or nullptr. Empty hashes are
|
||||
// never matched (they do not participate in dedup).
|
||||
const Sample* findByHash(const std::string& contentHash) const;
|
||||
|
||||
// Moves the sample with `id` to `tier`. Returns true if the sample existed.
|
||||
bool moveTier(const std::string& id, Tier tier);
|
||||
|
||||
// Returns copies of all samples in the given tier, in insertion order.
|
||||
std::vector<Sample> byTier(Tier tier) const;
|
||||
|
||||
// All samples in insertion order.
|
||||
const std::vector<Sample>& all() const { return samples_; }
|
||||
|
||||
std::size_t size() const { return samples_.size(); }
|
||||
bool empty() const { return samples_.empty(); }
|
||||
|
||||
bool operator==(const BankModel& o) const { return samples_ == o.samples_; }
|
||||
|
||||
// Serializes the whole index to a JSON string (lossless round-trip).
|
||||
std::string serialize() const;
|
||||
|
||||
// Parses a JSON string produced by serialize(). Returns std::nullopt on
|
||||
// malformed / truncated input (error signaled, never UB). On success the
|
||||
// returned index satisfies deserialize(serialize(x)) == x.
|
||||
static std::optional<BankModel> deserialize(const std::string& json);
|
||||
|
||||
private:
|
||||
std::vector<Sample> samples_; // insertion order preserved
|
||||
};
|
||||
|
||||
} // namespace reasampler::model
|
||||
@@ -0,0 +1,113 @@
|
||||
#include "core/model/owned_manifest.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// owned_manifest implementation.
|
||||
//
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model /
|
||||
// bank_book / tail_control). The shape is a single object with one string array:
|
||||
//
|
||||
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
|
||||
//
|
||||
// so a compact writer + a focused string-array domain parse is all it needs.
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// path invariant (mirror of bank_model's isAbsolutePath)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
|
||||
// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to
|
||||
// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant
|
||||
// must match exactly (a path the index accepts must be recordable, and vice versa).
|
||||
bool isAbsolutePath(const std::string& p) {
|
||||
if (p.empty()) return false;
|
||||
if (p[0] == '/' || p[0] == '\\') return true;
|
||||
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mutation / query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
|
||||
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
|
||||
if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath;
|
||||
if (contains(relativePath)) return ManifestAddResult::AlreadyPresent;
|
||||
paths_.push_back(relativePath);
|
||||
return ManifestAddResult::Added;
|
||||
}
|
||||
|
||||
bool OwnedFileManifest::contains(const std::string& relativePath) const {
|
||||
for (const auto& p : paths_)
|
||||
if (p == relativePath) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON writer (shared core/json escape — byte-identical to the prior local one)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::string OwnedFileManifest::serialize() const {
|
||||
std::string out = "{\"owned\":[";
|
||||
for (std::size_t i = 0; i < paths_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
json::writeEscaped(out, paths_[i]);
|
||||
}
|
||||
out += "]}";
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON parser (string-array-only DOMAIN grammar over the shared core/json
|
||||
// lexical layer). Tolerates unknown keys (forward-compat) and requires the
|
||||
// "owned" value to be an array of strings.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
bool parseManifest(json::Reader& r, OwnedFileManifest& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true; // empty object -> empty manifest
|
||||
for (;;) {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
if (key == "owned") {
|
||||
std::vector<std::string> paths;
|
||||
if (!r.parseStringArray(paths)) return false;
|
||||
for (auto& p : paths) {
|
||||
// Feed through add() so the persisted invariants (dedup, reject
|
||||
// empty/absolute) are re-asserted on load — a hand-edited or corrupt
|
||||
// blob cannot smuggle an absolute or duplicate path into the manifest.
|
||||
out.add(p);
|
||||
}
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys
|
||||
}
|
||||
r.skipWs();
|
||||
if (r.consume(',')) continue;
|
||||
if (r.consume('}')) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& blob) {
|
||||
OwnedFileManifest m;
|
||||
json::Reader r(blob);
|
||||
if (!parseManifest(r, m)) return std::nullopt;
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace reasampler::model
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
// owned_manifest — the pure core of the owned-file manifest seam (Phase B, B-cap).
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
|
||||
// "small pure type + JSON round-trip" pattern as wav_trim / tab_strip.
|
||||
//
|
||||
// -- What it is --------------------------------------------------------------
|
||||
//
|
||||
// The set of files the bank system ITSELF created — every file the capture path
|
||||
// writes gets recorded here. Phase R prune consumes it to tell the system's own
|
||||
// orphans (owned ∩ present − referenced) apart from hand-dropped files. B-cap only
|
||||
// WRITES and PERSISTS the manifest; no prune logic lives here (fork R-D, settled
|
||||
// 2026-07-24: "defer the feature, design the seam").
|
||||
//
|
||||
// -- What it is NOT ----------------------------------------------------------
|
||||
//
|
||||
// It is NOT a mirror of the bank index. Removing or moving an index entry does NOT
|
||||
// remove the file's manifest record: the manifest tracks files *created*, and prune
|
||||
// (Phase R) reconciles manifest-vs-index later. The ONLY thing that adds to it is
|
||||
// the capture add-path. There is deliberately no remove verb here.
|
||||
//
|
||||
// -- The relative-paths-only invariant ---------------------------------------
|
||||
//
|
||||
// A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath
|
||||
// and the persisted BankModel). add() rejects an absolute path rather than guess a
|
||||
// relativization — the pure model has no project root, so a "normalization" would be
|
||||
// a guess that could point at the wrong file (mirror of BankModel::add's rejection).
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
// Outcome of an add(). Mirrors BankModel::AddResult's honesty — the op reports what
|
||||
// happened rather than silently mutating on a bad request.
|
||||
// - Added: the path was new and recorded.
|
||||
// - AlreadyPresent: the path was already in the manifest (dedup no-op).
|
||||
// - RejectedEmptyPath: the path was empty.
|
||||
// - RejectedAbsolutePath: the path was absolute (relative-paths-only invariant).
|
||||
enum class ManifestAddResult {
|
||||
Added,
|
||||
AlreadyPresent,
|
||||
RejectedEmptyPath,
|
||||
RejectedAbsolutePath,
|
||||
};
|
||||
|
||||
// The owned-file manifest: an insertion-ordered, deduplicated set of project-relative
|
||||
// paths the capture path has created. Insertion order is preserved so serialize()
|
||||
// round-trips byte-identically (deterministic ext-state, mirror of the index).
|
||||
class OwnedFileManifest {
|
||||
public:
|
||||
OwnedFileManifest() = default;
|
||||
|
||||
// Record a project-relative path as owned. Rejects an empty or absolute path (no
|
||||
// mutation). A path already present is a dedup no-op (AlreadyPresent), so a repeat
|
||||
// capture of an identical request does not double-record.
|
||||
ManifestAddResult add(const std::string& relativePath);
|
||||
|
||||
// True iff the exact path string is recorded. Phase R uses this to attribute a
|
||||
// present file to the bank system. Exact string match — path normalization (if any)
|
||||
// is the caller's concern, consistent across add and query.
|
||||
bool contains(const std::string& relativePath) const;
|
||||
|
||||
// The owned paths in insertion order. Phase R unions this with the on-disk file
|
||||
// set; here it is the round-trip + query surface.
|
||||
const std::vector<std::string>& paths() const { return paths_; }
|
||||
|
||||
std::size_t size() const { return paths_.size(); }
|
||||
bool empty() const { return paths_.empty(); }
|
||||
|
||||
bool operator==(const OwnedFileManifest& o) const { return paths_ == o.paths_; }
|
||||
|
||||
// -- Persistence ---------------------------------------------------------
|
||||
|
||||
// Serialize to a JSON string (lossless round-trip): deserialize(serialize(x)) == x.
|
||||
// An empty manifest serializes to a well-formed empty shape (round-trips to empty).
|
||||
std::string serialize() const;
|
||||
|
||||
// Parse a manifest JSON produced by serialize(). std::nullopt on malformed input
|
||||
// (the persist shell warns + falls back to an empty manifest, mirroring the bank /
|
||||
// view malformed handling). An empty/absent stored value is the caller's concern
|
||||
// (an empty string is not valid JSON) — the shell maps absence to a fresh manifest.
|
||||
static std::optional<OwnedFileManifest> deserialize(const std::string& json);
|
||||
|
||||
private:
|
||||
std::vector<std::string> paths_; // insertion order; deduplicated
|
||||
};
|
||||
|
||||
} // namespace reasampler::model
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "core/model/provenance.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "core/wire/wire.h"
|
||||
|
||||
// provenance implementation — pure, self-contained (no third-party lib, mirror of
|
||||
// bank_model's hand-rolled encoding discipline).
|
||||
//
|
||||
// ENCODING (the fingerprint string): a length-prefixed, field-ordered format so it
|
||||
// is unambiguous and forge-proof (a value containing the separator cannot shift
|
||||
// the parse). Grammar:
|
||||
//
|
||||
// "rsprov1" -- magic + version tag
|
||||
// then, in fixed order, each field as <len>':'<bytes>
|
||||
//
|
||||
// Every field — including numbers — is emitted as its decimal / %.17g text then
|
||||
// length-prefixed, so the parser never has to guess a field boundary. A trailing
|
||||
// field is the track-GUID count followed by that many length-prefixed GUIDs, then
|
||||
// the folded fxChainIdentity. Numbers use the SAME %.17g the bank model uses so a
|
||||
// double round-trips bit-for-bit. Any deviation (wrong magic, short read, bad
|
||||
// number) -> parseFingerprint returns nullopt.
|
||||
//
|
||||
// The fxChainIdentity fold is itself length-prefixed per entry field, so it is
|
||||
// injection-proof on its own and can be embedded whole as one more length-prefixed
|
||||
// field of the fingerprint.
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
bool CaptureRecipe::operator==(const CaptureRecipe& o) const {
|
||||
return scope == o.scope && sourceMode == o.sourceMode &&
|
||||
startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
|
||||
tailMode == o.tailMode && tailMs == o.tailMs &&
|
||||
sampleRate == o.sampleRate && channelCount == o.channelCount &&
|
||||
trackGuids == o.trackGuids && fxChainIdentity == o.fxChainIdentity;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kMagic = "rsprov1";
|
||||
|
||||
// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full
|
||||
// hardening (incl. the fixed fieldInt range check that closes the old strtol
|
||||
// silent-narrowing TODO). Only the %.17g double rendering stays local — it is
|
||||
// this writer's convention, shared with the bank model's JSON doubles.
|
||||
using wire::putField;
|
||||
using Cursor = wire::Cursor;
|
||||
|
||||
std::string dblToStr(double v) {
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", v);
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries) {
|
||||
std::string out;
|
||||
// Count first, then each entry's three fields length-prefixed. Order is part of
|
||||
// identity (chain order matters), so we emit in the given vector order.
|
||||
putField(out, std::to_string(entries.size()));
|
||||
for (const FxIdentityEntry& e : entries) {
|
||||
putField(out, e.name);
|
||||
putField(out, e.guid);
|
||||
putField(out, e.enabled ? "1" : "0");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string combineChainIdentities(const std::vector<std::string>& perTrack) {
|
||||
std::string out;
|
||||
putField(out, std::to_string(perTrack.size()));
|
||||
for (const std::string& id : perTrack) putField(out, id);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string buildFingerprint(const CaptureRecipe& r) {
|
||||
std::string out(kMagic);
|
||||
putField(out, std::to_string(static_cast<int>(r.scope)));
|
||||
putField(out, std::to_string(r.sourceMode));
|
||||
putField(out, dblToStr(r.startSeconds));
|
||||
putField(out, dblToStr(r.endSeconds));
|
||||
putField(out, std::to_string(r.tailMode));
|
||||
putField(out, dblToStr(r.tailMs));
|
||||
putField(out, std::to_string(r.sampleRate));
|
||||
putField(out, std::to_string(r.channelCount));
|
||||
putField(out, std::to_string(r.trackGuids.size()));
|
||||
for (const std::string& g : r.trackGuids) putField(out, g);
|
||||
putField(out, r.fxChainIdentity);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
|
||||
Cursor c(fingerprint);
|
||||
if (!c.literal(kMagic)) return std::nullopt;
|
||||
|
||||
CaptureRecipe r;
|
||||
int scopeInt = 0;
|
||||
if (!c.fieldInt(scopeInt)) return std::nullopt;
|
||||
if (scopeInt != static_cast<int>(ProvenanceScope::Item) &&
|
||||
scopeInt != static_cast<int>(ProvenanceScope::Track))
|
||||
return std::nullopt;
|
||||
r.scope = static_cast<ProvenanceScope>(scopeInt);
|
||||
|
||||
if (!c.fieldInt(r.sourceMode)) return std::nullopt;
|
||||
if (!c.fieldDouble(r.startSeconds)) return std::nullopt;
|
||||
if (!c.fieldDouble(r.endSeconds)) return std::nullopt;
|
||||
if (!c.fieldInt(r.tailMode)) return std::nullopt;
|
||||
if (!c.fieldDouble(r.tailMs)) return std::nullopt;
|
||||
if (!c.fieldInt(r.sampleRate)) return std::nullopt;
|
||||
if (!c.fieldInt(r.channelCount)) return std::nullopt;
|
||||
|
||||
std::size_t guidCount = 0;
|
||||
if (!c.fieldSizeT(guidCount)) return std::nullopt;
|
||||
// Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least
|
||||
// 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the
|
||||
// reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge)
|
||||
// into std::length_error / bad_alloc through the shell.
|
||||
if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt;
|
||||
r.trackGuids.reserve(guidCount);
|
||||
for (std::size_t i = 0; i < guidCount; ++i) {
|
||||
std::string g;
|
||||
if (!c.field(g)) return std::nullopt;
|
||||
r.trackGuids.push_back(std::move(g));
|
||||
}
|
||||
|
||||
if (!c.field(r.fxChainIdentity)) return std::nullopt;
|
||||
|
||||
// Trailing garbage means the string was not produced by our writer -> reject,
|
||||
// so a corrupt/extended blob never silently drives a partial re-capture.
|
||||
if (!c.ok() || !c.atEnd()) return std::nullopt;
|
||||
return r;
|
||||
}
|
||||
|
||||
std::optional<std::string> detectParent(
|
||||
const std::vector<std::string>& sourceItemFiles,
|
||||
const std::vector<BankFileRef>& bankFiles) {
|
||||
if (sourceItemFiles.empty()) return std::nullopt;
|
||||
|
||||
std::optional<std::string> parent; // the single bank sample all sources point at
|
||||
for (const std::string& src : sourceItemFiles) {
|
||||
// Resolve this source file against the bank by exact normalized path.
|
||||
const std::string* matchedId = nullptr;
|
||||
for (const BankFileRef& ref : bankFiles) {
|
||||
if (!ref.absolutePath.empty() && ref.absolutePath == src) {
|
||||
matchedId = &ref.sampleId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchedId == nullptr)
|
||||
return std::nullopt; // a source item is NOT a bank file -> not a resample
|
||||
|
||||
if (!parent) {
|
||||
parent = *matchedId;
|
||||
} else if (*parent != *matchedId) {
|
||||
return std::nullopt; // sources span >1 bank sample -> ambiguous, no parent
|
||||
}
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
} // namespace reasampler::model
|
||||
@@ -0,0 +1,149 @@
|
||||
#pragma once
|
||||
// provenance — the REAPER-free core behind Milestone 10 (re-capture from source).
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The shell (main.cpp / actions.cpp)
|
||||
// gathers the raw inputs from REAPER — the source item media-file names, the
|
||||
// source track FX-chain identity (names / GUIDs / enabled flags), the exact
|
||||
// capture range, scope, tail — and hands plain strings/values here. This module
|
||||
// owns:
|
||||
//
|
||||
// * CaptureRecipe — the recorded capture request PLUS the source FX-chain
|
||||
// identity at capture time. Everything "re-capture from
|
||||
// source" needs to re-run the SAME request against the
|
||||
// source's CURRENT state, and to tell whether the source
|
||||
// drifted since capture.
|
||||
// * the ENCODING of a recipe into the single `Provenance.fxChainSnapshot`
|
||||
// string (M1's field already JSON-round-trips one string,
|
||||
// so the whole thin fingerprint rides in it — no schema
|
||||
// change to Sample).
|
||||
// * fxChainIdentity — folds the shell-gathered FX-chain rows into one identity
|
||||
// string (the drift-detection component of the fingerprint).
|
||||
// * detectParent — the pure parent-detection decision: given the resolved
|
||||
// absolute media-file path(s) of the capture's source item(s)
|
||||
// and the bank's path->sampleId map, decide whether this
|
||||
// capture genuinely derives from a bank sample (P1: identity
|
||||
// by resolved file path only — no fuzzy match, no false
|
||||
// parentage).
|
||||
//
|
||||
// Fork picks (docs/product/provenance.md, settled 2026-07-23): P1 = a THIN
|
||||
// reproducibility fingerprint (drift-detect + re-run the same request), NOT a
|
||||
// serialized FX chunk to restore. P2 = bank-only re-capture. So nothing here
|
||||
// stores a restorable chain, and nothing here reaches into view_mode_model.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
// Capture scope, mirrored from render_settings' CaptureScope but kept independent
|
||||
// here so the pure provenance module does not pull the whole render_settings graph
|
||||
// in. The shell maps its CaptureScope onto this two-value enum. Item = item/take FX
|
||||
// only; Track = item FX + the track's own FX (CLAUDE.md §Capture FX scope).
|
||||
enum class ProvenanceScope {
|
||||
Item,
|
||||
Track,
|
||||
};
|
||||
|
||||
// One FX-chain entry as the shell reads it from REAPER. For Track scope the shell
|
||||
// uses TrackFX_GetFXName/GetFXGUID/GetEnabled; for Item scope it uses the TakeFX_*
|
||||
// equivalents over the active take's FX chain. Plain data — the shell fills it, the
|
||||
// pure fold turns the vector into the identity string.
|
||||
struct FxIdentityEntry {
|
||||
std::string name; // TrackFX_GetFXName / TakeFX_GetFXName
|
||||
std::string guid; // TrackFX_GetFXGUID / TakeFX_GetFXGUID -> guidToString
|
||||
bool enabled; // TrackFX_GetEnabled / TakeFX_GetEnabled
|
||||
};
|
||||
|
||||
// The recorded capture recipe + source FX-chain identity — the thin fingerprint.
|
||||
// Re-capture replays the request fields verbatim against the source's CURRENT
|
||||
// state; fxChainIdentity is compared post-hoc to report drift. Ordinary equality
|
||||
// (via ==) is a full recipe match; fxChainIdentity difference alone is "the source
|
||||
// drifted but the recipe is the same" (the re-run still succeeds, the user is told).
|
||||
struct CaptureRecipe {
|
||||
ProvenanceScope scope = ProvenanceScope::Track;
|
||||
int sourceMode = 0; // reasampler::SourceMode as int (bank_model)
|
||||
|
||||
double startSeconds = 0.0; // exact bounds — no rounding (invariant)
|
||||
double endSeconds = 0.0;
|
||||
|
||||
int tailMode = 0; // reasampler::TailMode as int (render_settings)
|
||||
double tailMs = 0.0;
|
||||
|
||||
int sampleRate = 0; // 0 = follow project rate
|
||||
int channelCount = 2;
|
||||
|
||||
// Canonical GUID strings of the source track(s) the capture came from
|
||||
// (guidString form). Re-capture resolves these back to live tracks.
|
||||
std::vector<std::string> trackGuids;
|
||||
|
||||
// The source FX-chain identity at capture time — the drift component. A folded
|
||||
// string (fxChainIdentity) of the in-scope FX rows. Not a restorable chunk.
|
||||
std::string fxChainIdentity;
|
||||
|
||||
bool operator==(const CaptureRecipe& o) const;
|
||||
bool operator!=(const CaptureRecipe& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
// Folds the shell-gathered FX rows into ONE identity string. Order-sensitive
|
||||
// (chain order is part of identity), delimited so a name containing the delimiter
|
||||
// cannot forge a different chain (the fields are length-prefixed). Empty vector ->
|
||||
// empty string (a no-FX source has an empty, stable identity). Pure + deterministic.
|
||||
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries);
|
||||
|
||||
// Combines several per-track FX-chain identity strings (one per source track, in
|
||||
// track order) into ONE identity, length-prefixing each so two different per-track
|
||||
// partitions can never collide by concatenation (e.g. {"X",""} != {"","X"}). Used
|
||||
// for a multi-track Track-scope capture. A single-track capture combines to a
|
||||
// stable, unambiguous wrapping of its one identity. Pure + deterministic.
|
||||
std::string combineChainIdentities(const std::vector<std::string>& perTrack);
|
||||
|
||||
// Encodes a CaptureRecipe into the single string stored in
|
||||
// Provenance.fxChainSnapshot. Self-describing, versioned, and escape-safe so it
|
||||
// round-trips losslessly through the Sample JSON (which treats the whole thing as
|
||||
// one opaque string value). buildFingerprint(x) then parseFingerprint(...) == x.
|
||||
std::string buildFingerprint(const CaptureRecipe& recipe);
|
||||
|
||||
// Parses a fingerprint produced by buildFingerprint. Returns nullopt on any
|
||||
// malformed / unrecognized-version input (never throws, never UB) so a legacy or
|
||||
// corrupt provenance string degrades to "no recipe" gracefully rather than
|
||||
// mis-driving a re-capture.
|
||||
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint);
|
||||
|
||||
// --- Parent detection (P1: identity by resolved file path) -------------------
|
||||
|
||||
// One bank sample as the detector sees it: its stable id and the ABSOLUTE,
|
||||
// normalized path its file resolves to (the shell resolves relativePath against
|
||||
// the current project dir via resolveBankFile + normalizeSlashes before handing
|
||||
// it here). Plain data so the decision is pure and testable.
|
||||
struct BankFileRef {
|
||||
std::string sampleId;
|
||||
std::string absolutePath; // normalized (forward-slash, no trailing slash)
|
||||
};
|
||||
|
||||
// Decides whether a capture derives from a bank sample.
|
||||
//
|
||||
// RULE (stated for the handoff, honest — no false parentage): a capture derives
|
||||
// from a bank sample iff EVERY source item whose media file could be resolved
|
||||
// points at the SAME bank sample's file (by exact normalized absolute path). If
|
||||
// the source items resolve to files not in the bank, or to MORE THAN ONE distinct
|
||||
// bank sample (ambiguous parentage), no parent is recorded. An empty source-file
|
||||
// set (nothing resolvable) yields no parent.
|
||||
//
|
||||
// sourceItemFiles : normalized absolute paths of the capture's source items'
|
||||
// take media files (the shell gathers + normalizes them). A
|
||||
// file that could not be resolved is simply omitted by the
|
||||
// shell — it never becomes an empty string here.
|
||||
// bankFiles : the active book's samples as BankFileRefs (path -> id).
|
||||
//
|
||||
// Returns the parent sample id, or nullopt when the capture is not a genuine
|
||||
// resample-from-sample. Comparison is exact path identity; the caller normalizes
|
||||
// both sides identically via normalizeSlashes (which lowercases on Windows) so a
|
||||
// slash/case difference never spuriously matches or misses. On Windows both sides
|
||||
// are lowercased before they reach here; on macOS/Linux they are case-exact.
|
||||
std::optional<std::string> detectParent(
|
||||
const std::vector<std::string>& sourceItemFiles,
|
||||
const std::vector<BankFileRef>& bankFiles);
|
||||
|
||||
} // namespace reasampler::model
|
||||
@@ -0,0 +1,145 @@
|
||||
#include "core/model/slot_map.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// slot_map implementation (extracted from bank_book, Q-W1 T4-05).
|
||||
//
|
||||
// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one
|
||||
// slot per id. Every mutator restores it; queries assume it. serialize rides the
|
||||
// shared core/json emit helpers — the emitted fragment is byte-identical to the
|
||||
// pre-extraction bank_book writer.
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
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<std::string> SlotMap::orderedIds() const {
|
||||
// entries_ is sorted ascending by slot, so a straight walk is display order.
|
||||
std::vector<std::string> 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<std::string>& 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<std::string>& 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<std::pair<std::string, int>>& 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;
|
||||
}
|
||||
|
||||
std::string SlotMap::serialize() const {
|
||||
// Array of {id, slot} objects in ascending slot order (entries_ is kept sorted).
|
||||
// json::Writer + numToStr are the same emit path the pre-extraction writer used,
|
||||
// so the fragment is byte-identical.
|
||||
std::string out;
|
||||
out += '[';
|
||||
for (std::size_t i = 0; i < entries_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
json::Writer e(out);
|
||||
e.keyStr("id", entries_[i].id);
|
||||
e.keyRaw("slot", json::numToStr(entries_[i].slot));
|
||||
}
|
||||
out += ']';
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::model
|
||||
@@ -0,0 +1,106 @@
|
||||
#pragma once
|
||||
// slot_map — the L7 gap-preserving display-position carrier for ONE bank (F2 settled:
|
||||
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a
|
||||
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps
|
||||
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty
|
||||
// first row above an occupied second row). At most one id per slot (a slot is never
|
||||
// double-occupied) and at most one slot per id (an id sits in exactly one place).
|
||||
//
|
||||
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one
|
||||
// sample into two banks may sit at different slots, so position is a per-bank display
|
||||
// concern owned by the bank's membership. bank_model / Sample stay untouched.
|
||||
//
|
||||
// Extracted from bank_book (Q-W1, T4-05): a self-contained ordered-slot container
|
||||
// with its own serialize, distinct from the multi-bank registry that carries it.
|
||||
// Behavior covered by bank_book_tests (the round-trip + reorder/reconcile suites);
|
||||
// a dedicated slot_map_tests target is a welcome follow-up, not a Q-W1 requirement.
|
||||
//
|
||||
// PURE: standard library + core/json (serialize) only.
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
class SlotMap {
|
||||
public:
|
||||
// The slot an id occupies, or -1 if the id is not mapped. O(N).
|
||||
int slotOf(const std::string& id) const;
|
||||
|
||||
// The id occupying `slot`, or "" if the slot is empty. O(N).
|
||||
std::string idAt(int slot) const;
|
||||
|
||||
// The highest occupied slot, or -1 when the map is empty. Defines the append
|
||||
// frontier and (with trailing-empty trim) the content extent.
|
||||
int maxSlot() const;
|
||||
|
||||
// Ids in ASCENDING slot order (the deterministic display order). Empty slots
|
||||
// produce no entry — the caller iterates occupants; sparse layout is a draw
|
||||
// concern that reads slotOf/idAt, not this list.
|
||||
std::vector<std::string> orderedIds() const;
|
||||
|
||||
// Places `id` at the next free slot after the last occupied one (append). If the
|
||||
// id is already mapped it is first removed (leaving its old slot empty), then
|
||||
// appended — an append never fills an earlier gap. No-op guard: empty id ignored.
|
||||
void append(const std::string& id);
|
||||
|
||||
// Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id
|
||||
// keeps its position. Returns true if the id was mapped.
|
||||
bool remove(const std::string& id);
|
||||
|
||||
// Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics):
|
||||
// * target slot EMPTY -> `id` moves there; its old slot is left empty.
|
||||
// * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
|
||||
// every occupant at slot >= targetSlot (except `id` itself) shifts up by one,
|
||||
// preserving their relative order and never colliding. Matches file-manager
|
||||
// reorder. Interior gaps between shifted occupants are preserved as-is
|
||||
// (shift is +1 on each occupant, so the gap structure above the target is kept).
|
||||
// * negative targetSlot is clamped to 0.
|
||||
// Returns false (no mutation) if `id` is not mapped. Deterministic.
|
||||
bool reorder(const std::string& id, int targetSlot);
|
||||
|
||||
// Rebuilds the map densely from `ids` in the given order (slot i = ids[i]),
|
||||
// dropping any prior state. The migration path: a pre-L7 bank with no persisted
|
||||
// slot data is seeded from its BankModel insertion order, densely packed (no gaps),
|
||||
// so it is visually identical on first post-L7 load. Empty/duplicate ids skipped.
|
||||
void resetDense(const std::vector<std::string>& ids);
|
||||
|
||||
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left
|
||||
// the index) and appends any live id that has NO mapping yet (a sample the index
|
||||
// gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps
|
||||
// the map consistent with the bank's membership without a re-pack. Deterministic:
|
||||
// orphan appends follow `liveIds` order.
|
||||
void reconcile(const std::vector<std::string>& liveIds);
|
||||
|
||||
bool empty() const { return entries_.empty(); }
|
||||
std::size_t size() const { return entries_.size(); }
|
||||
|
||||
bool operator==(const SlotMap& o) const;
|
||||
|
||||
// JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the
|
||||
// bank envelope's "slots" member by BankBook::serialize; parsed back by its parser.
|
||||
// Round-trips losslessly with the rest of the bank.
|
||||
std::string serialize() const;
|
||||
|
||||
// Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces
|
||||
// the map invariants defensively against a hand-edited blob: a duplicate id keeps
|
||||
// its FIRST occurrence; a slot already taken by a kept id drops the later pair
|
||||
// (never double-occupies); an empty id or negative slot is dropped. The result is
|
||||
// sorted ascending by slot. reconcile() against live membership runs afterward, so
|
||||
// a lossy repair here degrades gracefully rather than corrupting lookup.
|
||||
static SlotMap fromEntries(const std::vector<std::pair<std::string, int>>& pairs);
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
std::string id;
|
||||
int slot = 0;
|
||||
bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; }
|
||||
};
|
||||
std::vector<Entry> entries_; // kept sorted ascending by slot (invariant)
|
||||
|
||||
void sortBySlot();
|
||||
};
|
||||
|
||||
} // namespace reasampler::model
|
||||
Reference in New Issue
Block a user