Cut core/model, reclaim, json, util comment bloat ~26% (comments only, zero code change)

This commit is contained in:
2026-07-29 20:49:06 -04:00
parent 1f24c4b095
commit 65ca1e1f9d
16 changed files with 400 additions and 681 deletions
+4 -11
View File
@@ -1,6 +1,5 @@
// core/json implementation — see json.h. The bodies are the (previously
// quintuplicated) bank_model / view_mode_model lexical layer, verbatim; any
// behavioral change here changes five persisted-blob parsers at once.
// core/json implementation — see json.h. Any behavioral change here changes
// every persisted-blob parser that shares this lexical layer at once.
#include "core/json/json.h"
@@ -11,9 +10,7 @@
namespace reasampler::json {
// ---------------------------------------------------------------------------
// emit helpers
// ---------------------------------------------------------------------------
// -- emit helpers -------------------------------------------------------
void writeEscaped(std::string& out, const std::string& s) {
out += '"';
@@ -75,9 +72,7 @@ void writeIntArray(std::string& out, const std::vector<int>& v) {
out += ']';
}
// ---------------------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------
// -- Reader ---------------------------------------------------------------
void Reader::skipWs() {
while (!eof()) {
@@ -117,7 +112,6 @@ bool Reader::parseString(std::string& out) {
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
// Decode a \uXXXX escape to its code point.
auto readHex4 = [&](unsigned int& cp) -> bool {
if (pos_ + 4 > s_.size()) return false;
cp = 0;
@@ -149,7 +143,6 @@ bool Reader::parseString(std::string& out) {
return false; // unpaired low surrogate — malformed
}
// Encode codePoint as UTF-8.
if (codePoint <= 0x7F) {
out += static_cast<char>(codePoint);
} else if (codePoint <= 0x7FF) {
+16 -23
View File
@@ -1,22 +1,19 @@
// core/json — the ONE hand-rolled JSON lexical layer (Q-W1; audit T2-02 / §2
// "Parser ×4"). Pure: standard library only — NO REAPER, NO SWELL, NO VST3.
// core/json — the ONE hand-rolled JSON lexical layer. Pure: standard library only
// — NO REAPER, NO SWELL, NO VST3.
//
// This module owns the lexical half of the house JSON dialect: the escape-aware
// string literal (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), the bare
// scalar tokens, the number parses (strtod/strtoll with full-token + ERANGE
// rejection), key+':' consumption, unknown-value skipping, and the emit side
// (escaping, %.17g / %d / %lld number rendering, the scoped object writer).
// The DOMAIN grammars — which keys exist, what shape each value takes, what is
// rejected at the model boundary — stay in the consumers (bank_model, bank_book,
// view_mode_model, owned_manifest, tail_control). One lexical definition means
// the five decoders can no longer drift on tolerance or escaping.
// Owns the lexical half of the house JSON dialect: escape-aware string literals
// (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), bare scalar tokens, number
// parsing (strtod/strtoll, full-token + ERANGE rejection), key+':' consumption,
// unknown-value skipping, and the emit side (escaping, %.17g/%d/%lld rendering,
// the scoped object writer). Domain grammars — which keys exist, what shape each
// value takes — stay in the consumers (bank_model, bank_book, view_mode_model,
// owned_manifest, tail_control).
//
// Byte-compatibility contract (load-bearing): the emit helpers reproduce the
// prior per-module writers EXACTLY — writeEscaped's escape set, %.17g for
// doubles (shortest form that round-trips every IEEE-754 double bit-for-bit),
// plain decimal for ints — so a re-serialized blob is byte-identical to what
// the pre-extraction writers produced. This was a structural dedupe, not a
// format change; persisted .rpp ext-state must not shift by a byte.
// Byte-compatibility contract (load-bearing): the emit helpers reproduce the prior
// per-module writers EXACTLY — writeEscaped's escape set, %.17g for doubles
// (shortest form that round-trips every IEEE-754 double bit-for-bit), plain
// decimal for ints — so a re-serialized blob is byte-identical to what the
// pre-extraction writers produced. Persisted .rpp ext-state must not shift by a byte.
#pragma once
@@ -26,9 +23,7 @@
namespace reasampler::json {
// ---------------------------------------------------------------------------
// emit helpers (writer side)
// ---------------------------------------------------------------------------
// -- emit helpers (writer side) ----------------------------------------------
// Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX
// for remaining control chars, everything else verbatim (UTF-8 passes through).
@@ -88,9 +83,7 @@ private:
bool first_ = true;
};
// ---------------------------------------------------------------------------
// Reader — the lexical cursor (parser side)
// ---------------------------------------------------------------------------
// -- Reader — the lexical cursor (parser side) -------------------------------
//
// Every method returns false on malformed input and never reads out of bounds.
// Only the subset the house writers emit is supported. The reader borrows the
+18 -39
View File
@@ -5,19 +5,13 @@
// bank_book implementation — the registry RULES half: construction, pool
// privileges, bank lifecycle, active bank, sample movement/removal, slot order,
// and the reference queries. The JSON round-trip half (serialize / deserialize —
// Q-W1's golden-literal-pinned byte format) lives in bank_book_json.cpp, compiled
// into the same bank_book target (the slot_map extraction shape: same header, a
// second TU). The one symbol both halves share is the private static
// BankBook::nameKey display-name folding rule (declared in bank_book.h).
// and the reference queries. The JSON round-trip half lives in bank_book_json.cpp,
// compiled into the same target. The one symbol both halves share is the private
// static BankBook::nameKey display-name folding rule (declared in bank_book.h).
namespace reasampler {
// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05).
// ---------------------------------------------------------------------------
// BankBook — construction + bank lookup
// ---------------------------------------------------------------------------
// -- construction + bank lookup ----------------------------------------------
BankBook::BankBook() {
Bank pool;
@@ -59,9 +53,7 @@ const Bank& BankBook::pool() const {
return *bank(kPoolBankId);
}
// ---------------------------------------------------------------------------
// Ordinal normalization
// ---------------------------------------------------------------------------
// -- Ordinal normalization ----------------------------------------------------
void BankBook::normalizeOrdinals() {
// Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a
@@ -75,16 +67,13 @@ void BankBook::normalizeOrdinals() {
banks_[i].ordinal = static_cast<int>(i);
}
// ---------------------------------------------------------------------------
// Display-name uniqueness (trimmed + case-insensitive, ASCII)
// ---------------------------------------------------------------------------
// -- Display-name uniqueness (trimmed + case-insensitive, ASCII) --------------
// 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. Private static member (Q-W5): the one folding rule shared
// with bank_book_json.cpp's parse-time duplicate-display-name coalesce.
// whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
// key. ASCII-only by design — the pure core carries no locale facility; bank names
// are short user labels, not Unicode case-folding candidates. Private static: the
// one folding rule shared with bank_book_json.cpp's parse-time coalesce.
std::string BankBook::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'; };
@@ -109,9 +98,7 @@ bool BankBook::displayNameTaken(const std::string& name, const std::string& exce
return false;
}
// ---------------------------------------------------------------------------
// Bank lifecycle
// ---------------------------------------------------------------------------
// -- Bank lifecycle ------------------------------------------------------------
bool BankBook::createBank(const std::string& id, const std::string& displayName) {
if (id.empty()) return false; // ids key the registry
@@ -208,9 +195,7 @@ bool BankBook::evacuate(const std::string& id) {
return true;
}
// ---------------------------------------------------------------------------
// Active bank
// ---------------------------------------------------------------------------
// -- Active bank ----------------------------------------------------------------
bool BankBook::setActiveBank(const std::string& id) {
if (bank(id) == nullptr) return false; // unknown id never corrupts state
@@ -227,9 +212,7 @@ const BankModel& BankBook::activeIndex() const {
return bank(activeBankId_)->index;
}
// ---------------------------------------------------------------------------
// Sample movement (index-only)
// ---------------------------------------------------------------------------
// -- Sample movement (index-only) -----------------------------------------------
namespace {
@@ -276,9 +259,7 @@ TransferResult BankBook::copySample(const std::string& sampleId,
return applyDestAdd(to->index, copy, TransferResult::Copied);
}
// ---------------------------------------------------------------------------
// Sample removal (index-only) + the last-reference query
// ---------------------------------------------------------------------------
// -- Sample removal (index-only) + the last-reference query ---------------------
RemoveResult BankBook::removeSample(const std::string& sampleId,
const std::string& fromBankId,
@@ -310,9 +291,7 @@ bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& up
return false; // no bank holds the id
}
// ---------------------------------------------------------------------------
// Sample display order (L7) — SlotMap driven, index membership untouched
// ---------------------------------------------------------------------------
// -- Sample display order — SlotMap driven, index membership untouched -----------
namespace {
@@ -323,9 +302,9 @@ std::vector<std::string> indexIds(const BankModel& idx) {
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).
// Squares one bank's SlotMap with its index membership. A map with NO overlap with
// the index (a bank with no persisted slot data, or freshly constructed) is seeded
// dense from insertion order; an existing map is reconciled (drop stale, append unmapped).
void reconcileBankSlots(Bank& b) {
const std::vector<std::string> live = indexIds(b.index);
if (b.slots.empty()) {
+108 -208
View File
@@ -1,41 +1,13 @@
#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.
// bank_book — pure multi-bank registry: wraps N BankModel instances (bank_model
// itself is untouched — additive, no bankId on Sample). Movement between banks is
// index-only; files never relocate, banks are logical groupings over one shared pool.
//
// 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.
// The pool is bank-zero (fixed id/name, ordinal 0), privileged and enforced HERE:
// always exists, un-deletable, un-renamable, un-evacuable (evacuate's destination
// only). createBank takes a caller-supplied id — REAPER GUID minting stays in the
// shell so this model stays pure and deterministic; the model still enforces
// non-empty/unique/not-reserved.
#include <optional>
#include <string>
@@ -47,26 +19,22 @@
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.
// Interim: this module re-namespaces later; the 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.
// The pool's fixed identity: createBank rejects this id; renameBank rejects this name.
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.
// One bank: id/display/ordinal/BankModel/SlotMap. 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)
SlotMap slots; // display positions of this bank's samples (gap-preserving)
bool isPool() const { return id == kPoolBankId; }
@@ -76,16 +44,12 @@ struct Bank {
}
};
// 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).
// Outcome of a cross-bank move/copy — reports what happened rather than mutating
// silently on a bad request.
// - Moved / Copied: transferred to the destination as a new entry.
// - Collapsed: destination already held the hash, collapsed onto it (move
// still removes the source; copy keeps it, as always).
// - RejectedUnknownBank / RejectedSampleAbsent / RejectedSameBank: no-op guards.
enum class TransferResult {
Moved,
Copied,
@@ -95,21 +59,18 @@ enum class TransferResult {
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.
// Scope of a sample-remove. ThisBank is the only behavior surfaced in the UI;
// AllBanks is a tested latent seam, not wired to any affordance.
// - ThisBank: drop the entry from the one named source bank only (no cross-bank
// cascade — dedup is per-bank).
// - AllBanks: drop the sample's entry from every bank holding it ("purge from
// the library").
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.
// Outcome of BankBook::removeSample — same honesty as TransferResult.
// - 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).
@@ -120,8 +81,7 @@ enum class RemoveResult {
};
// 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.
// indices, an active-bank pointer, and lossless JSON round-trip.
class BankBook {
public:
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
@@ -129,34 +89,29 @@ public:
// -- 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.
// Creates a named bank with a caller-supplied id/display name (next ordinal
// assigned automatically). Rejects (false, no mutation) an empty/duplicate id,
// the reserved pool id, or a duplicate display name (trimmed + case-insensitive,
// ASCII — "Drums"/"drums"/" Drums " collide, including against the pool's "Pool").
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.
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or
// a name already used by another bank. Renaming 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.
// Deletes a named bank and its member entries (files untouched — a shell/prune
// concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
// ordinals compact after; if the deleted bank was active, 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.
// Reorders a named bank to newOrdinal (clamped into range, others shift to stay
// contiguous). The pool is pinned at 0. Rejects 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.
// Moves every member of a named bank into the pool (index-only, same
// destination-collapse-by-hash as a move). Rejects an unknown id or the pool
// (the pool is only ever a destination). Returns true even if already empty.
bool evacuate(const std::string& id);
// -- Active bank ---------------------------------------------------------
@@ -164,123 +119,83 @@ public:
// 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.
// Sets the active bank. Rejects (false, no change) an id that names no bank.
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).
// The active bank's BankModel — always valid (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.
// Moves a sample by id between banks (destination collapse-by-hash observed).
// 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.
// Copies a sample by id between banks, source retained (destination
// collapse-by-hash observed). Cross-bank dedup is NOT enforced — the same hash
// may then live in both banks.
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).
// Drops a sample's index entry — non-destructive to the file (a last-reference
// remove leaves it on disk, orphaned until prune reclaims it). See RemoveScope/
// RemoveResult for scope and outcome. No mutation on any Rejected outcome.
RemoveResult removeSample(const std::string& sampleId,
const std::string& fromBankId,
RemoveScope scope = RemoveScope::ThisBank);
// -- Sample display order (L7; index membership untouched) ---------------
// -- Sample display order (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.
// The bank's sample ids in display (slot) order, reconciled against live index
// membership first (drops stale markers, appends unmapped samples densely). An
// unknown bank id yields an empty vector.
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.
// Ensures every bank's SlotMap is consistent with its index membership (seeds a
// dense order, or reconciles a partial map). Idempotent — call after deserialize
// or any out-of-band membership change.
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.
// Reorders sample `id` within `bankId` to targetSlot (gap-preserving; index/file/
// metadata untouched). Returns false (no mutation) on an unknown bank or id.
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.
// Alt-replace: the dragged `newId` (already a member of bankId) takes the slot of
// `oldId`, and `oldId` is removed from the index (same semantics as removeSample
// ThisBank). Position is preserved; only the 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.
// Applies the same pool guard as removeSample — per-sample removal from the pool
// is allowed (the pool's guards are un-delete/rename/evacuate, never per-sample
// remove). Rejects (false, no mutation of either index or slots) an unknown bank,
// a newId/oldId the bank doesn't hold, or newId == oldId.
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.
// Refreshes a sample in place wherever it lives (re-capture): finds the bank
// holding sampleId and replaces its entry with `updated` (order-preserving, no
// dedup). Updates the FIRST holder in ordinal order if the id lives in multiple
// banks via copy. Returns false (no mutation) if no bank holds the id or the
// replacement's path is absolute.
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).
// Does any bank other than exceptBankId still hold an entry whose contentHash
// == hash? Backs the confirm-on-last-reference guardrail: two entries sharing a
// hash share one file, so this answers "would removing here orphan the file."
// An empty hash never matches (mirrors findByHash) — reads as
// referenced-nowhere-else, the safe confirm-eliciting default.
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.
// Every project-relative path referenced by any bank (pool included) — the union
// prune subtracts against. Paths are verbatim (no normalization), first-seen
// order across banks in ordinal then insertion order, de-duplicated. An empty
// relativePath is skipped.
std::vector<std::string> referencedPaths() const;
// -- Query ---------------------------------------------------------------
@@ -308,33 +223,25 @@ public:
// -- 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.
// Serializes the whole book to JSON (lossless): pool 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).
// A bare legacy bank_index JSON (pre-multi-bank shape: a "samples" array, no
// "banks" key) is promoted into the pool's index — one-way, lossless — yielding
// a book of { pool } with zero named banks.
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.
// Resolves a BankBook from the two persisted ext-state values a project may
// carry: the authoritative `banksJson` and the retired legacy `bank_index` blob.
// 1. non-empty banksJson -> deserialize it. If malformed, do NOT fall back to
// legacy — a corrupt banks blob is an error, not an absence; returns an
// empty book so a stale legacy key can never resurrect superseded state.
// 2. else non-empty legacyJson -> deserialize it (pool migration).
// 3. else -> a fresh empty book (pool only).
// Never returns nullopt — an unloadable input degrades to the empty book.
static BankBook loadFromPersisted(const std::string& banksJson,
const std::string& legacyJson);
@@ -343,41 +250,34 @@ private:
std::string activeBankId_; // always names a live bank; defaults to pool
// 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. A private STATIC member (Q-W5, settled)
// because BOTH halves of the split implementation need the ONE folding rule: the
// rules TU (bank_book.cpp, displayNameTaken) and the JSON TU (bank_book_json.cpp,
// deserialize's duplicate-display-name coalesce) — a drifted second copy would let
// a parsed book violate the create/rename uniqueness invariant.
// whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
// key. ASCII-only by design — the pure core carries no locale facility. Private
// static because both halves of the split implementation (rules + JSON) need the
// one folding rule; a drifted second copy would let a parsed book violate the
// create/rename uniqueness invariant.
static std::string nameKey(const std::string& s);
// 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.
// True if a bank other than exceptId already carries name's uniqueness key.
// 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).
// contiguous 0..N-1. 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.
// by deserialize.
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.
// order (pool -> named -> ... -> pool, wraps). Free function (not a member) so it's
// unit-testable against a bare id vector without a full book.
// * 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);
+27 -43
View File
@@ -6,33 +6,25 @@
#include "core/json/json.h"
// bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header,
// compiled into the same bank_book target; the slot_map second-TU shape). The
// registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private
// static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time
// duplicate-display-name coalesce below must fold names EXACTLY as the create/rename
// uniqueness check does, or a parsed book could violate the in-model invariant.
// bank_book JSON round-trip — a sibling TU to bank_book.cpp, sharing its header
// and target. The registry RULES half stays in bank_book.cpp; the one shared
// symbol is the private static BankBook::nameKey folding rule — the parse-time
// duplicate-display-name coalesce below must fold names EXACTLY as create/rename
// uniqueness does, or a parsed book could violate the in-model invariant.
//
// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model
// and view_mode_model. The book blob nests one bank object per bank, each carrying that
// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize),
// so per-bank sample serialization stays owned by bank_model and is not duplicated
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
// raw "index" member whose value is the BankModel blob verbatim; the parser splits
// the book envelope, then hands each nested index blob straight to
// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped.
// BYTE-IDENTICAL to the pre-extraction writer — the Q-W1 golden-literal test pins it.
// The book blob nests one bank object per bank, each carrying that bank's
// BankModel serialized by bank_model's OWN writer, 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.
namespace reasampler {
// ===========================================================================
// JSON — writer
// ===========================================================================
// -- 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;
@@ -58,8 +50,8 @@ std::string BankBook::serialize() const {
// 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.
// Display positions (gap-preserving). Absent on a pre-existing blob;
// the parser defaults such a bank's slots from insertion order on load.
b.keyRaw("slots", banks_[i].slots.serialize());
}
out += ']';
@@ -67,13 +59,10 @@ std::string BankBook::serialize() const {
return out;
}
// ===========================================================================
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
// ===========================================================================
// -- JSON — parser (recursive descent; std::nullopt on 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
@@ -112,9 +101,9 @@ bool parseBank(json::Reader& r, Bank& b) {
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.
// Display positions. Absent on a pre-existing blob; 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);
@@ -186,9 +175,7 @@ bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks
} 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.
// Legacy marker; the legacy index is re-parsed from the whole input below.
sawSamples = true;
if (!r.skipValue()) return false;
} else {
@@ -253,23 +240,20 @@ std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
json::Reader r(blob);
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
// --- Coalesce duplicate folded display names. --------------------------
// 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"
// DISAMBIGUATE the later duplicate: 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 names seen so far — the first bank to carry a key
// keeps its name verbatim. No bank or sample is lost, ids are untouched, and the
// pool's reserved "Pool" key is seeded first so a named bank folding to "pool"
// is disambiguated away from it, never the reverse.
//
// Hosted HERE (a static member, Q-W5) rather than in the free parseBook because
// it folds through the PRIVATE BankBook::nameKey the same rule the
// create/rename uniqueness check applies. Runs after parseBook on BOTH shapes;
// the legacy path yields { pool } alone, where the scan is a trivial no-op.
// Hosted here (not in the free parseBook) because it folds through the PRIVATE
// BankBook::nameKey the create/rename uniqueness check also uses.
{
std::vector<std::string> seenKeys;
seenKeys.reserve(banks.size());
+21 -40
View File
@@ -4,21 +4,14 @@
#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.
// bank_model implementation. JSON rides on the shared core/json lexical layer;
// only the Sample/index DOMAIN grammar lives here. Doubles are emitted with 17
// significant digits (%.17g), the shortest form that round-trips every
// IEEE-754 double exactly, so deserialize(serialize(x)) == x holds bit-for-bit.
namespace reasampler::model {
// ---------------------------------------------------------------------------
// equality
// ---------------------------------------------------------------------------
// -- equality -----------------------------------------------------------
bool SourceRange::operator==(const SourceRange& o) const {
return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
@@ -51,20 +44,15 @@ bool Sample::operator==(const Sample& o) const {
provenance == o.provenance && createdTimestamp == o.createdTimestamp;
}
// ---------------------------------------------------------------------------
// path invariant
// ---------------------------------------------------------------------------
// -- 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.
// Rejects absolute paths rather than normalizing them: the pure model has no
// knowledge of the project root, so any "normalization" would be a guess that
// could point at the wrong file. 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 what follows — drive-relative paths
// ("C:foo.wav") resolve against the drive's current directory, not the project
// root, so they violate relative-paths-only 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
@@ -73,9 +61,7 @@ static bool isAbsolutePath(const std::string& p) {
return false;
}
// ---------------------------------------------------------------------------
// BankModel
// ---------------------------------------------------------------------------
// -- BankModel ------------------------------------------------------------
AddResult BankModel::add(const Sample& sample) {
if (sample.id.empty()) return AddResult::RejectedEmptyId;
@@ -139,9 +125,7 @@ std::vector<Sample> BankModel::byTier(Tier tier) const {
return out;
}
// ---------------------------------------------------------------------------
// JSON writer
// ---------------------------------------------------------------------------
// -- JSON writer ------------------------------------------------------------
namespace {
@@ -182,9 +166,9 @@ void writeSample(std::string& out, const Sample& s) {
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.
// Emitted as null when absent (same shape as `key`/`provenance`) so JSON that
// 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";
@@ -240,12 +224,9 @@ std::string BankModel::serialize() const {
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.
// ---------------------------------------------------------------------------
// -- 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.
namespace {
+35 -44
View File
@@ -1,11 +1,7 @@
#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.
// bank_model — the HEART of ReaSampler: the per-project sample bank. `Sample`
// metadata struct + `BankModel` (add/remove/query/tier moves/dedup-by-hash + JSON
// round-trip to/from std::string).
#include <cstdint>
#include <optional>
@@ -14,8 +10,8 @@
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.
// How the source audio was obtained; the capture backends map their own notion
// onto these.
enum class SourceMode {
MasterMix, // offline render of the master output
SelectedTracks, // offline render of selected tracks
@@ -31,9 +27,8 @@ enum class Tier {
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).
// Sample-accurate source bounds, in both project seconds and PPQ (ticks) — both
// stored so capture doesn't re-derive one from the other and risk rounding.
struct SourceRange {
double startSeconds = 0.0;
double endSeconds = 0.0;
@@ -44,9 +39,9 @@ struct SourceRange {
};
// 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.
// parent's id and an FX-chain snapshot (a thin drift fingerprint, NOT a restorable
// chunk) — re-capture-from-source uses it to detect chain drift and replay the
// original capture request.
struct Provenance {
std::string parentSampleId;
std::string fxChainSnapshot;
@@ -63,15 +58,13 @@ struct Levels {
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.
// Sample-accurate sustain-loop bounds, as frame indices into the captured file — a
// bank intrinsic (like sampleRate or length) the MIDI-playback instrument uses to
// hold notes past the recorded length. One optional struct (not two loose
// optionals) so "both points or neither" is structural, not a rule to re-check at
// every boundary. Frame indices, not seconds — the instrument relates them to time
// via the file's sample rate. Invariant (enforced at deserialize): 0 <= start <=
// end; start == end is a valid zero-length loop marker.
struct LoopPoints {
std::int64_t start = 0;
std::int64_t end = 0;
@@ -102,23 +95,23 @@ struct Sample {
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.
// Time signature at capture time, stamped alongside captureTempo so the
// bars.beats.subdivisions read-out is stable under later project meter changes.
// 0/0 means UNSTAMPED (pre-existing sample, or a capture that could not read the
// meter); the metadata formatter renders a blank musical read-out then, keeping 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.
// Bank intrinsics for the MIDI-playback instrument, additive like `provenance`.
// Both default cleanly empty: pre-existing 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.
// Populated only where derivable — never guessed when the source isn't a
// single played note.
// - loop: sustain-loop bounds, populated only where explicitly set.
std::optional<int> rootNote;
std::optional<LoopPoints> loop;
@@ -156,7 +149,7 @@ enum class AddResult {
// 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.
// so a panel can iterate in stable order.
class BankModel {
public:
// Adds a sample. Enforces the relative-paths-only invariant and dedups by
@@ -168,16 +161,14 @@ public:
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).
// insertion order) with `updated`. Used by re-capture-from-source: a
// provenanced sample's file is regenerated and its metadata refreshed while
// its identity (id) and slot are kept, so the panel shows the same tile
// updated rather than a reordered new entry. `updated.id` should equal `id`;
// a differing id is written through as given. Does NOT dedup — an in-place
// refresh is not a new insert, so collapse-by-hash (which guards inserts)
// does not apply. Returns false (no mutation) if `id` is absent or
// `updated.relativePath` is absolute (relative-paths-only still holds here).
bool updateInPlace(const std::string& id, const Sample& updated);
// Returns the sample with `id`, or nullptr if absent. The pointer is
+10 -23
View File
@@ -4,27 +4,21 @@
#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_manifest implementation. JSON 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)
// ---------------------------------------------------------------------------
// -- 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).
// 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.
bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true;
@@ -35,9 +29,7 @@ bool isAbsolutePath(const std::string& p) {
} // namespace
// ---------------------------------------------------------------------------
// mutation / query
// ---------------------------------------------------------------------------
// -- mutation / query -------------------------------------------------------
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
@@ -53,9 +45,7 @@ bool OwnedFileManifest::contains(const std::string& relativePath) const {
return false;
}
// ---------------------------------------------------------------------------
// JSON writer (shared core/json escape — byte-identical to the prior local one)
// ---------------------------------------------------------------------------
// -- JSON writer --------------------------------------------------------
std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":[";
@@ -67,11 +57,8 @@ std::string OwnedFileManifest::serialize() const {
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.
// ---------------------------------------------------------------------------
// JSON parser: string-array-only grammar. Tolerates unknown keys and requires
// the "owned" value to be an array of strings.
namespace {
+14 -29
View File
@@ -1,31 +1,16 @@
#pragma once
// owned_manifest — the pure core of the owned-file manifest seam (Phase B, B-cap).
// owned_manifest — the set of files the bank system ITSELF created; every file the
// capture path writes gets recorded here so prune can tell the system's own orphans
// (owned ∩ present referenced) apart from hand-dropped files. Writes and persists
// the manifest only — no prune logic lives here.
//
// 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_codec / tab_strip.
// NOT a mirror of the bank index: removing/moving an index entry does NOT remove
// the file's manifest record (the manifest tracks files *created*; prune reconciles
// manifest-vs-index later). Only the capture add-path adds to it — no remove verb.
//
// -- 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).
// Paths are ALWAYS project-relative (same invariant as Sample.relativePath). add()
// rejects an absolute path rather than guess a relativization — the pure model has
// no project root, so "normalizing" could point at the wrong file.
#include <optional>
#include <string>
@@ -58,12 +43,12 @@ public:
// 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.
// True iff the exact path string is recorded. Prune 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
// The owned paths in insertion order. Prune 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_; }
+15 -28
View File
@@ -4,26 +4,16 @@
#include "core/wire/wire.h"
// provenance implementation — pure, self-contained (no third-party lib, mirror of
// bank_model's hand-rolled encoding discipline).
// provenance implementation — pure, self-contained encoding.
//
// 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.
// Fingerprint grammar: magic "rsprov1" + fixed-order length-prefixed fields
// (<len>':'<bytes>), so a value containing the separator can never shift the
// parse. Numbers render as decimal/%.17g text before prefixing (same %.17g the
// bank model uses, so doubles round-trip bit-for-bit). Trailing fields: the
// track-GUID count + that many GUIDs, then the folded fxChainIdentity — itself
// length-prefixed per entry field, so it nests safely as one more field. Any
// deviation (bad magic, short read, bad number) -> parseFingerprint returns
// nullopt.
namespace reasampler::model {
@@ -39,10 +29,9 @@ 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.
// The shared core/wire codec carries the field grammar + range-checked fieldInt.
// Only the %.17g double rendering stays local — this writer's convention, shared
// with the bank model's JSON doubles.
using wire::putField;
using Cursor = wire::Cursor;
@@ -112,10 +101,9 @@ std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
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.
// 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.
if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt;
r.trackGuids.reserve(guidCount);
for (std::size_t i = 0; i < guidCount; ++i) {
@@ -139,7 +127,6 @@ std::optional<std::string> detectParent(
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) {
+23 -47
View File
@@ -1,35 +1,17 @@
#pragma once
// provenance — the REAPER-free core behind Milestone 10 (re-capture from source).
// provenance — the REAPER-free core behind re-capture-from-source. The shell
// gathers raw inputs from REAPER (source media-file names, FX-chain identity,
// capture range/scope/tail) and hands plain strings/values here. Owns:
// * CaptureRecipe — the recorded request + source FX-chain identity, so
// re-capture can re-run the same request and detect drift.
// * fingerprint codec — encodes/decodes a recipe into the single
// Provenance.fxChainSnapshot string (no schema change).
// * fxChainIdentity — folds FX-chain rows into one drift-detection string.
// * detectParent — pure parent-detection: resolved file path only, no
// fuzzy match, no false parentage.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The shell (main.cpp / the action families)
// 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.
// A THIN reproducibility fingerprint (drift-detect + re-run) — NOT a serialized FX
// chunk to restore; nothing here stores a restorable chain.
#include <optional>
#include <string>
@@ -111,7 +93,7 @@ std::string buildFingerprint(const CaptureRecipe& recipe);
// mis-driving a re-capture.
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint);
// --- Parent detection (P1: identity by resolved file path) -------------------
// -- Parent detection: identity by resolved file path only --------------------
// 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
@@ -122,26 +104,20 @@ struct BankFileRef {
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.
// Decides whether a capture derives from a bank sample. 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 (exact normalized absolute
// path). Files not in the bank, or matching MORE THAN ONE distinct bank sample
// (ambiguous), yield no parent. An empty source-file set 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.
// take media files, gathered by the shell. A file that could
// not be resolved is simply omitted — never an empty string.
// 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.
// Returns the parent sample id, or nullopt otherwise. Both sides are normalized
// identically via normalizeSlashes (lowercased on Windows) so a slash/case
// difference never spuriously matches or misses.
std::optional<std::string> detectParent(
const std::vector<std::string>& sourceItemFiles,
const std::vector<BankFileRef>& bankFiles);
+2 -6
View File
@@ -4,12 +4,10 @@
#include "core/json/json.h"
// slot_map implementation (extracted from bank_book, Q-W1 T4-05).
// slot_map implementation.
//
// 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.
// slot per id. Every mutator restores it; queries assume it.
namespace reasampler::model {
@@ -128,8 +126,6 @@ SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pai
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) {
+12 -20
View File
@@ -1,21 +1,13 @@
#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).
// slot_map — the gap-preserving display-position carrier for ONE bank: plain
// interchangeable slots, not fixed/addressable ones. A slot is a display position a
// sample id occupies; the map is sample id -> slot (>= 0). Gaps are first-class (a
// bank may have slot 1 occupied with slot 0 empty). At most one id per slot, at
// most one slot per id.
//
// 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.
// Position lives HERE, not on Sample: 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.
#include <cstddef>
#include <string>
@@ -50,7 +42,7 @@ public:
// 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):
// Moves `id` to `targetSlot`, gap-preserving:
// * 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,
@@ -62,9 +54,9 @@ public:
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.
// dropping any prior state. The migration path: a bank with no persisted slot
// data is seeded from its BankModel insertion order, densely packed (no gaps),
// so it is visually identical on first 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
+88 -109
View File
@@ -1,45 +1,32 @@
#pragma once
// prune_reconcile — the pure core of Phase R (Reclaim), Wave 1. The safety-critical
// "which files are orphans" decision, computed with NO filesystem I/O and NO REAPER
// types. The mirror of view_mode_model's reconcile(liveGuids), one level DOWN: it
// reconciles FILES ON DISK against REFERENCED FILES (the union across every bank),
// where reconcile reconciled membership entries against live tracks.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes, NO filesystem calls. Standard library only. Unit-tested outside
// the DAW — this is the safety-critical part (it decides which bytes get deleted in
// R2/R3), so it is hard-tested here before any I/O exists.
//
// -- The one computation ------------------------------------------------------
// prune_reconcile — the safety-critical "which files are orphans" decision, computed
// with NO filesystem I/O and NO REAPER types. Unit-tested outside the DAW before any
// I/O exists — this decides which bytes get deleted.
//
// The one computation:
// orphans = (owned ∩ present) referenced
//
// * present — files enumerated in the resolved current bank folder (R2 shell).
// * present — files enumerated in the resolved current bank folder (shell).
// * referenced — every project-relative path referenced by ANY bank in the book,
// pool included (union across the whole book — see BankBook::
// referencedPaths). A file referenced by any bank — including via a
// COPY into a second bank — is NEVER an orphan (the prune null test).
// * owned — the owned-file manifest: the files the bank system itself created
// (OwnedFileManifest). A present-but-unowned (hand-dropped) file is
// NEVER reclaimed — prune reclaims only the system's own leavings.
// * owned — the owned-file manifest: files the bank system itself created. A
// present-but-unowned (hand-dropped) file is NEVER reclaimed.
//
// The three settled guardrails fall straight out of the set algebra:
// The three guardrails fall straight out of the set algebra:
// * ∩ present — never proposes deleting a file that is not on disk (an owned-
// but-absent manifest entry yields no orphan, no error).
// * ∩ owned — never a hand-dropped file (ownership attribution, fork R-D).
// * ∩ owned — never a hand-dropped file (ownership attribution).
// * referenced — never a file any bank references (union safety, prune null test).
//
// -- Path representation: EXACT-STRING match (safety-critical) -----------------
//
// Every path in the model is a project-relative string compared VERBATIM: Sample.
// relativePath, OwnedFileManifest::contains (p == relativePath), and BankModel all
// use raw std::string equality — no separator normalization, no case-folding, no
// trailing-slash trimming. This core MATCHES that convention exactly: it compares
// the raw strings the shell supplies. Feeding a consistent spelling across the three
// inputs is the R2 shell's contract (it enumerates the folder, unions the book, and
// reads the manifest against the SAME resolved current folder). Diverging from exact
// match here (e.g. case-insensitive compare) would be the unsafe direction — it could
// let one spelling of a referenced file be treated as an orphan under another.
// Path representation: EXACT-STRING match everywhere — Sample.relativePath,
// OwnedFileManifest::contains, BankModel all use raw std::string equality: no
// separator normalization, no case-folding, no trailing-slash trimming. Feeding a
// consistent spelling across the three inputs is the shell's contract (it enumerates
// the folder, unions the book, and reads the manifest against the SAME resolved
// current folder). Diverging from exact match here (e.g. case-insensitive compare)
// would be the unsafe direction — it could let one spelling of a referenced file be
// treated as an orphan under another.
#include <cstdint>
#include <string>
@@ -48,34 +35,28 @@
namespace reasampler::reclaim {
// The dry-run prune result (Phase R, Wave 2 — report only, no deletion). The thin
// prune shell (persist) fills this from pruneOrphans() + a per-file size stat and hands
// it to the report surface; R3 will act on the SAME set behind the confirm guardrail.
// REAPER-free / filesystem-free by design (the shell does the I/O; this is just the
// tallied outcome), so the count/size aggregation is unit-testable outside the DAW.
// The dry-run prune result — report only, no deletion. The shell fills this from
// pruneOrphans() + a per-file size stat; a confirmed delete later acts on the SAME
// set behind the confirm guardrail. Filesystem-free by design (the shell does the
// I/O; this is the tallied outcome), so the aggregation is unit-testable.
//
// * count — number of orphan files (== orphans.size(); the AUTHORITATIVE tally,
// exact even when `orphans` below is a truncated display list).
// * totalBytes — sum of the on-disk sizes of the orphan files, in bytes (reclaimable
// space). A file the stat could not size contributes 0 (never negative).
// * orphans — the orphan file list as project-relative index-spelled paths, in
// folder-enumeration order (deterministic). MAY be truncated for a large
// set (the shell's display cap); `count` stays exact regardless, and
// `truncated` says whether the list was clipped.
// * truncated — true iff `orphans` holds fewer than `count` entries (a large set was
// clipped for display); false when the list is complete.
// * count — number of orphan files (authoritative tally, exact even when
// `orphans` below is a truncated display list).
// * totalBytes — sum of the on-disk sizes of the orphan files, in bytes. A file
// the stat could not size contributes 0 (never negative).
// * orphans — the orphan file list, project-relative, in folder-enumeration
// order (deterministic). MAY be truncated for a large set (the
// shell's display cap); `count` stays exact regardless.
// * truncated — true iff `orphans` holds fewer than `count` entries.
// * abortedUnreadableUsage — true iff the scan found a present-but-unreadable
// rsusage_* instance-usage record (pS-usage fail-safe): the orphan
// computation was NOT performed (count 0, empty list) and the prune
// must HALT — deleting with degraded protection is the data-loss
// direction. Set by the session's scan shell, never by
// buildPruneReport (which stays a pure tally).
// * offendingUsageKeys — the exact "rsusage_<guid>" ext-state key names that
// triggered the abort (non-empty iff abortedUnreadableUsage). Named
// so the action can print them for operator recovery: a corrupt/
// oversized key whose owning instance no longer exists is never
// automatically rewritten, so the abort would be permanent without
// a way to clear it. The operator can clear each key via ReaScript:
// rsusage_* instance-usage record: the orphan computation was NOT
// performed (count 0, empty list) and the prune must HALT —
// deleting with degraded protection is the data-loss direction.
// Set by the scan shell, never by buildPruneReport (which stays a
// pure tally).
// * offendingUsageKeys — the exact "rsusage_<guid>" key names that triggered the
// abort (non-empty iff abortedUnreadableUsage), so the operator
// can clear each key via ReaScript:
// reaper.SetProjExtState(0, "reasampler", "<key>", "")
struct PruneReport {
std::size_t count = 0;
@@ -86,20 +67,18 @@ struct PruneReport {
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
};
// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills
// this as it deletes the confirmed orphan set, reporting what it ACTUALLY reclaimed (not
// what it intended to) so a locked/vanished file shows up as a skip, never a false claim.
// REAPER-free / filesystem-free by design (the shell does the deletion; this is the
// tallied outcome), so the count/byte aggregation is unit-testable outside the DAW.
// The outcome of an actual prune DELETION. The shell fills this as it deletes the
// confirmed orphan set, reporting what it ACTUALLY reclaimed (not what it intended)
// so a locked/vanished file shows up as a skip, never a false claim. Filesystem-free
// by design, so the count/byte aggregation is unit-testable.
//
// * reclaimedCount — number of files actually removed from disk BY THIS CALL (trash or
// unlink). Already-absent files are NOT counted here.
// * reclaimedBytes — sum of the on-disk sizes of the files actually removed, in bytes.
// * skippedCount — files that could not be or were not reclaimed: stale entries that
// dropped out of the fresh-orphan intersection, files that vanished
// between the plan and the delete call (already absent), and real
// delete failures (locked, conversion error). Never an error/crash.
// * usedTrash — true iff the deletions were routed to the OS trash/recycle bin
// * reclaimedCount — files actually removed from disk BY THIS CALL (trash or
// unlink). Already-absent files are NOT counted.
// * reclaimedBytes — sum of the on-disk sizes of the files actually removed.
// * skippedCount — files not reclaimed: stale entries dropped from the
// fresh-orphan intersection, files that vanished between plan
// and delete, and real delete failures. Never an error/crash.
// * usedTrash — true iff deletions were routed to the OS trash/recycle bin
// (recoverable); false iff the platform fell back to hard unlink.
struct PruneDeletionResult {
std::size_t reclaimedCount = 0;
@@ -110,11 +89,11 @@ struct PruneDeletionResult {
// Computes the prune orphan set: (owned ∩ present) referenced.
//
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the ORDER
// they appear in `present` (deterministic output — mirror of the insertion-order
// determinism the index / manifest keep; the R2 dry-run reports a stable file list).
// Duplicate spellings within `present` are de-duplicated in the result (a folder
// enumeration yields distinct names, but the core does not rely on that).
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the order
// they appear in `present` (deterministic — mirrors the insertion-order determinism
// the index/manifest keep). Duplicate spellings within `present` are de-duplicated
// in the result (a folder enumeration yields distinct names, but the core does not
// rely on that).
//
// Pure: no I/O, no REAPER, no hidden state. All three inputs are project-relative
// path strings, compared by exact std::string equality (see header note).
@@ -122,56 +101,56 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& owned);
// Union two referenced-path sets into one (pS-usage): the bank's own referencedPaths()
// PLUS the paths held by live ReaSampler 9000 instances (sample_usage::usageHeldPaths).
// Unions two referenced-path sets into one: the bank's own referencedPaths() PLUS
// the paths held by live ReaSampler 9000 instances (sample_usage::usageHeldPaths).
// Order-preserving (`primary` first, then the `extra` paths not already present),
// exact-string de-dup — the same comparison convention as everything above, so feeding
// the result to pruneOrphans keeps the ` referenced` guardrail byte-exact. A path held
// ONLY by an instance (e.g. its bank entry was deleted while the instance kept its v10
// ref) is protected exactly like a bank-referenced one.
// exact-string de-dup — the same comparison convention as everything above, so
// feeding the result to pruneOrphans keeps the ` referenced` guardrail byte-exact.
// A path held ONLY by an instance (its bank entry was deleted while the instance
// kept its ref) is protected exactly like a bank-referenced one.
//
// Pure: no I/O, no REAPER. Kept here (not in the shells) so the "instance usage makes a
// file un-prunable" property is provable at the prune layer itself.
// Pure: no I/O, no REAPER. Kept here (not in the shells) so "instance usage makes a
// file un-prunable" is provable at the prune layer itself.
std::vector<std::string> mergeReferenced(const std::vector<std::string>& primary,
const std::vector<std::string>& extra);
// Tallies a dry-run PruneReport from a computed orphan set and a per-path size lookup.
// PURE (no I/O): the shell does the folder stat and passes the sizes in `sizeByPath`;
// this owns the count / byte-sum / display-truncation decision so it is unit-testable.
// Tallies a dry-run PruneReport from a computed orphan set and a per-path size
// lookup. Pure (no I/O): the shell does the folder stat and passes sizes in
// `sizeByPath`; this owns the count/byte-sum/display-truncation decision.
//
// * count == orphans.size() (the authoritative tally, exact regardless of the cap).
// * totalBytes == the sum of sizeByPath[o] over EVERY orphan o (not just the displayed
// ones); a path missing from sizeByPath contributes 0 (an orphan whose
// size could not be stat'd — never negative, never dropped from the sum).
// * count == orphans.size() (authoritative tally, exact regardless of the cap).
// * totalBytes == sum of sizeByPath[o] over EVERY orphan o (not just displayed); a
// path missing from sizeByPath contributes 0 (never negative).
// * orphans == the first `displayCap` orphans in input order (the deterministic
// folder-enumeration order pruneOrphans preserved); the whole set when
// count <= displayCap. displayCap == 0 means "no display cap" (whole set).
// * truncated == count > orphans.size() (a large set was clipped for display).
// order pruneOrphans preserved); the whole set when count <=
// displayCap. displayCap == 0 means "no display cap".
// * truncated == count > orphans.size().
//
// Kept separate from pruneOrphans so the safety-critical set algebra stays a pure function
// of three sets, while the presentation tally (which the R2 dry-run and R3 confirm both
// need) is its own small, testable step.
// Kept separate from pruneOrphans so the safety-critical set algebra stays a pure
// function of three sets, while the presentation tally is its own testable step.
PruneReport buildPruneReport(const std::vector<std::string>& orphans,
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
std::size_t displayCap);
// Computes the confirm-time delete plan: the intersection of the set the user was SHOWN
// and confirmed (`confirmed`) with a FRESH pure-core orphan output (`freshOrphans`) taken
// at delete time. Returns exactly `confirmed ∩ freshOrphans`, in the order of `confirmed`
// (deterministic — the same order the confirm listed).
// Computes the confirm-time delete plan: the intersection of the set the user was
// SHOWN and confirmed (`confirmed`) with a FRESH pure-core orphan output
// (`freshOrphans`) taken at delete time. Returns exactly `confirmed ∩ freshOrphans`,
// in the order of `confirmed` (deterministic — the same order the confirm listed).
//
// This is the R3 staleness guard, and it protects in BOTH directions so that "what was
// This is the staleness guard, and it protects in BOTH directions so that "what was
// shown is what is deleted" holds no matter what changed between confirm and delete:
// * A confirmed path that is NO LONGER a fresh orphan — a file that vanished (gone from
// `present`), or that some bank now references (gone from ` referenced`), or whose
// ownership changed — is DROPPED (a skip, never an error, never a wrongful delete of a
// now-referenced file). Because `freshOrphans` is itself a pure-core output, the plan
// can never contain a referenced or hand-dropped file: the guard survives recompute.
// * A path that became an orphan AFTER the confirm (in `freshOrphans` but not `confirmed`)
// is NOT deleted — it was never shown, so it is never swept without its own confirm.
// * A confirmed path that is NO LONGER a fresh orphan — vanished (gone from
// `present`), now referenced (gone from ` referenced`), or ownership changed —
// is DROPPED (a skip, never a wrongful delete of a now-referenced file). Because
// `freshOrphans` is itself a pure-core output, the plan can never contain a
// referenced or hand-dropped file: the guard survives recompute.
// * A path that became an orphan AFTER the confirm (in `freshOrphans` but not
// `confirmed`) is NOT deleted — it was never shown, so it's never swept without
// its own confirm.
//
// PURE: no I/O, no REAPER. Duplicate spellings in `confirmed` are de-duplicated in the
// result (mirrors pruneOrphans; a confirmed set from a real scan holds distinct names).
// Pure: no I/O, no REAPER. Duplicate spellings in `confirmed` are de-duplicated in
// the result (mirrors pruneOrphans; a confirmed set from a real scan holds distinct
// names).
std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed,
const std::vector<std::string>& freshOrphans);
+4 -7
View File
@@ -1,11 +1,8 @@
#pragma once
// clamp01 — the ONE unit-interval clamp (Q-W1, T4-24). Replaces the per-module
// static copies (master_gain / param_slider / envelope_overlay / reasampler_editor).
// Deliberately the ternary form: comparisons with NaN are false, so a NaN input
// passes through unchanged rather than silently collapsing to a bound — the
// behavior of the majority of the retired copies.
//
// PURE: standard library only (not even that).
// clamp01 — the ONE unit-interval clamp, replacing several per-module static
// copies. Deliberately the ternary form: comparisons with NaN are false, so a NaN
// input passes through unchanged rather than silently collapsing to a bound — the
// behavior most of the retired copies already had.
namespace reasampler::util {
+3 -4
View File
@@ -1,7 +1,6 @@
// core/util/file_bytes — the ONE whole-file byte loader (Q-W1; audit T2-03).
// Pure standard library — NO REAPER, NO SWELL, NO VST3 — but it does blocking
// file I/O: NEVER call it on the audio thread (off-thread only, the same rule
// every prior hand-rolled copy carried). Linked by both artifacts.
// core/util/file_bytes — the ONE whole-file byte loader. Pure standard library —
// NO REAPER, NO SWELL, NO VST3 — but it does blocking file I/O: NEVER call it on
// the audio thread. Linked by both artifacts.
#pragma once