#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, // BankIndex }. The book WRAPS N BankIndex instances — bank_model / BankIndex are // UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is // index-only (remove from source's BankIndex, 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 #include #include #include "bank_model.h" namespace reasampler { // 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"; // One bank: a stable id, a display name, an ordinal (tab/display order), and its // own BankIndex. 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 BankIndex index; // this bank's samples 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; } }; // 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, }; // 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 BankIndex — the index the capture layer adds to. Always // valid (the active id always names a live bank; it falls back to the pool). BankIndex& activeIndex(); const BankIndex& 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); // -- 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 BankIndex by id, or nullptr. Convenience over bank()->index. BankIndex* index(const std::string& id); const BankIndex* 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& 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 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 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&& 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& orderedBankIds, const std::string& currentBankId); } // namespace reasampler