Merge Phase B completion: B5 sample-remove, B-cap owned-file manifest, R-B batched undo + reload hook

This commit is contained in:
2026-07-26 15:41:52 -04:00
13 changed files with 1305 additions and 59 deletions
+18 -1
View File
@@ -157,6 +157,18 @@ add_library(bank_book STATIC src/bank_book.cpp)
target_include_directories(bank_book PUBLIC src) target_include_directories(bank_book PUBLIC src)
target_link_libraries(bank_book PUBLIC bank_model) target_link_libraries(bank_book PUBLIC bank_model)
# ---------------------------------------------------------------------------
# 2g'') Pure owned_manifest library — NO REAPER, NO SWELL. The owned-file manifest
# seam (Phase B B-cap): the set of project-relative files the capture path
# itself created, so Phase R prune can tell the bank system's own orphans from
# hand-dropped files. Deliberately DECOUPLED from bank_book — it tracks files
# CREATED, not index membership (sample-remove is not manifest-remove). Small
# pure type + JSON round-trip; mirror of wav_trim / tab_strip. B-cap writes +
# persists it; Phase R (R1/R2) consumes it — no prune logic here.
# ---------------------------------------------------------------------------
add_library(owned_manifest STATIC src/owned_manifest.cpp)
target_include_directories(owned_manifest PUBLIC src)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record # 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record
# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values, # logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values,
@@ -251,6 +263,10 @@ add_executable(wav_trim_tests tests/test_wav_trim.cpp)
target_link_libraries(wav_trim_tests PRIVATE wav_trim) target_link_libraries(wav_trim_tests PRIVATE wav_trim)
add_test(NAME wav_trim_tests COMMAND wav_trim_tests) add_test(NAME wav_trim_tests COMMAND wav_trim_tests)
add_executable(owned_manifest_tests tests/test_owned_manifest.cpp)
target_link_libraries(owned_manifest_tests PRIVATE owned_manifest)
add_test(NAME owned_manifest_tests COMMAND owned_manifest_tests)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -286,8 +302,9 @@ add_library(reaper_reasampler MODULE
src/item_read.cpp src/item_read.cpp
src/actions.cpp src/actions.cpp
src/bank_book.cpp src/bank_book.cpp
src/owned_manifest.cpp
) )
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
+166 -10
View File
@@ -420,6 +420,7 @@ constexpr const char* kIdBankActivateNext = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE
constexpr const char* kIdBankActivatePool = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE_POOL"; constexpr const char* kIdBankActivatePool = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE_POOL";
constexpr const char* kIdBankMoveSel = "CEREBELLUM_REASAMPLER_BANK_MOVE_SELECTED"; constexpr const char* kIdBankMoveSel = "CEREBELLUM_REASAMPLER_BANK_MOVE_SELECTED";
constexpr const char* kIdBankCopySel = "CEREBELLUM_REASAMPLER_BANK_COPY_SELECTED"; constexpr const char* kIdBankCopySel = "CEREBELLUM_REASAMPLER_BANK_COPY_SELECTED";
constexpr const char* kIdBankRemoveSel = "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED";
constexpr const char* kIdBankPoolFull = "CEREBELLUM_REASAMPLER_BANK_POOL_FULLHEIGHT"; constexpr const char* kIdBankPoolFull = "CEREBELLUM_REASAMPLER_BANK_POOL_FULLHEIGHT";
constexpr const char* kIdBankBanksFull = "CEREBELLUM_REASAMPLER_BANK_BANKS_FULLHEIGHT"; constexpr const char* kIdBankBanksFull = "CEREBELLUM_REASAMPLER_BANK_BANKS_FULLHEIGHT";
@@ -431,6 +432,7 @@ int g_cmdBankActivateNext = 0;
int g_cmdBankActivatePool = 0; int g_cmdBankActivatePool = 0;
int g_cmdBankMoveSel = 0; int g_cmdBankMoveSel = 0;
int g_cmdBankCopySel = 0; int g_cmdBankCopySel = 0;
int g_cmdBankRemoveSel = 0;
int g_cmdBankPoolFull = 0; int g_cmdBankPoolFull = 0;
int g_cmdBankBanksFull = 0; int g_cmdBankBanksFull = 0;
@@ -442,6 +444,7 @@ gaccel_register_t g_accelBankActivateNext{};
gaccel_register_t g_accelBankActivatePool{}; gaccel_register_t g_accelBankActivatePool{};
gaccel_register_t g_accelBankMoveSel{}; gaccel_register_t g_accelBankMoveSel{};
gaccel_register_t g_accelBankCopySel{}; gaccel_register_t g_accelBankCopySel{};
gaccel_register_t g_accelBankRemoveSel{};
gaccel_register_t g_accelBankPoolFull{}; gaccel_register_t g_accelBankPoolFull{};
gaccel_register_t g_accelBankBanksFull{}; gaccel_register_t g_accelBankBanksFull{};
@@ -453,8 +456,26 @@ gaccel_register_t g_accelBankBanksFull{};
// persists. This is an intentional divergence from persistViewState (above), which // persists. This is an intentional divergence from persistViewState (above), which
// DOES prompt Save-As on an unsaved project; do not "align" the two — a bank mutation // DOES prompt Save-As on an unsaved project; do not "align" the two — a bank mutation
// follows capture's quiet-persist idiom, a Design-View mutation follows the prompt idiom. // follows capture's quiet-persist idiom, a Design-View mutation follows the prompt idiom.
void persistBook() { g_session->saveToActiveProject(); } // Returns whether a persist actually happened (false on an unsaved/no-active project),
// so persistBankOp can skip its undo block when nothing was written.
bool persistBook() { return g_session->saveToActiveProject(); }
// Persists a completed bank index verb (create/rename/reorder/delete/evacuate/
// move/copy) as a SINGLE batched REAPER undo point (R-B) — one bank op = one Ctrl-Z.
//
// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project
// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures
// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents
// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199).
// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family
// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them
// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs
// INSIDE the block so the post-mutation ext-state is the block's "after" image.
//
// NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a
// rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here,
// so no dangling/empty undo point is ever opened for a rejected op.
//
// Prompts the user for a single line of text via REAPER's stock input dialog. // Prompts the user for a single line of text via REAPER's stock input dialog.
// GetUserInputs(title, num_inputs=1, captions_csv, retvals_csv, sz) -> false on // GetUserInputs(title, num_inputs=1, captions_csv, retvals_csv, sz) -> false on
// cancel (SDK ~3808). `initial` pre-fills the field. Returns false (leaving `out` // cancel (SDK ~3808). `initial` pre-fills the field. Returns false (leaving `out`
@@ -523,7 +544,7 @@ void doBankCreate() {
.c_str()); .c_str());
return; return;
} }
persistBook(); persistBankOp("ReaSampler: create bank");
} }
// Rename a bank: prompt for which bank (by current display name) and the new name. // Rename a bank: prompt for which bank (by current display name) and the new name.
@@ -548,7 +569,7 @@ void doBankRename() {
"or another bank already uses that name).\n"); "or another bank already uses that name).\n");
return; return;
} }
persistBook(); persistBankOp("ReaSampler: rename bank");
} }
// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail: // Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail:
@@ -590,7 +611,7 @@ void doBankDelete() {
ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n");
return; return;
} }
persistBook(); persistBankOp("ReaSampler: delete bank");
} }
// Evacuate a named bank: move every member back to the pool (index-only, collapse by // Evacuate a named bank: move every member back to the pool (index-only, collapse by
@@ -611,7 +632,7 @@ void doBankEvacuate() {
"destination, not a source).\n"); "destination, not a source).\n");
return; return;
} }
persistBook(); persistBankOp("ReaSampler: evacuate bank");
} }
// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), // Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool),
@@ -625,14 +646,14 @@ void doBankActivateNext() {
const std::string target = nextBankId(ids, g_session->book().activeBankId()); const std::string target = nextBankId(ids, g_session->book().activeBankId());
if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded) if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded)
if (!g_session->book().setActiveBank(target)) return; if (!g_session->book().setActiveBank(target)) return;
persistBook(); persistBankOp("ReaSampler: activate bank");
} }
// Activate the pool directly (the common "back to the default target" jump). Bindable // Activate the pool directly (the common "back to the default target" jump). Bindable
// direct-by-id form; a general activate-bank-by-name/menu is a B4 affordance. // direct-by-id form; a general activate-bank-by-name/menu is a B4 affordance.
void doBankActivatePool() { void doBankActivatePool() {
if (!g_session->book().setActiveBank(kPoolBankId)) return; if (!g_session->book().setActiveBank(kPoolBankId)) return;
persistBook(); persistBankOp("ReaSampler: activate bank");
} }
// Move or copy the panel's selected samples into a named destination bank (prompted // Move or copy the panel's selected samples into a named destination bank (prompted
@@ -667,15 +688,145 @@ void doBankTransferSelected(bool copy) {
return; return;
} }
// Tally per-sample transfer outcomes so the no-op guardrail below can decide whether
// the index actually mutated (R-B). The console summary m11 stripped is gone; the
// counts remain because the verb-aware undo guardrail is driven by them.
int ok = 0, collapsed = 0;
for (const std::string& sampleId : selected) { for (const std::string& sampleId : selected) {
if (copy) g_session->book().copySample(sampleId, srcId, destId); const TransferResult r =
else g_session->book().moveSample(sampleId, srcId, destId); copy ? g_session->book().copySample(sampleId, srcId, destId)
: g_session->book().moveSample(sampleId, srcId, destId);
switch (r) {
case TransferResult::Moved:
case TransferResult::Copied: ++ok; break;
case TransferResult::Collapsed: ++collapsed; break;
// RejectedSampleAbsent and unknown-bank / same-bank (pre-checked above) are
// no-ops for the guardrail; nothing mutated for those ids.
case TransferResult::RejectedSampleAbsent:
case TransferResult::RejectedUnknownBank:
case TransferResult::RejectedSameBank: break;
} }
persistBook(); }
// No-op guardrail — VERB-AWARE (a collapse means different things per verb):
// * MOVE collapse: the source entry WAS removed (bank_book moveSample removes
// unconditionally before the dest add collapses on hash), so the index DID
// mutate — it counts toward opening an undo point.
// * COPY collapse: the source is left intact AND the dest already held the hash,
// so NOTHING changed — a true index no-op. It must NOT open an undo point.
// Hence: copy counts only real gains (ok); move counts gains OR collapses.
const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0);
if (mutated) {
const std::string label =
std::string("ReaSampler: ") + verb + " sample(s)";
persistBankOp(label.c_str());
}
}
// Remove the panel's selected samples from the SOURCE bank (the focused region's
// displayed bank — bankPanelSelectedSourceBankId, same source as move/copy). 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 — the manifest is untouched).
//
// SCOPE (fork R-A): this-bank only — the sole surfaced verb. The RemoveScope::AllBanks
// seam stays latent in the model; nothing here reaches for it.
//
// CONFIRM-ON-LAST-REFERENCE (guardrail): a remove that would orphan a file (no OTHER
// bank references its content hash after the remove) earns a confirm; a remove of a
// still-referenced sample does not. BATCH UX: for a multi-select we compute the
// last-reference set BEFORE mutating (removal changes the reference graph), then fire a
// SINGLE confirm summarizing the N that would orphan — not one dialog per sample. If
// none would orphan, no confirm fires at all (the confirm is earned by actual risk).
void doBankRemoveSelected() {
const std::vector<std::string> selected = bankPanelSelectedSampleIds();
if (selected.empty()) {
ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to remove.\n");
return;
}
const std::string srcId = bankPanelSelectedSourceBankId();
BankBook& book = g_session->book();
const Bank* src = book.bank(srcId);
if (src == nullptr) {
ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n");
return;
}
// Count the samples whose file this remove would orphan — computed on the CURRENT
// (pre-mutation) reference graph so a same-hash sibling in another bank counts as a
// surviving reference. Resolve by id against the live source index (ids, not cached
// refs); an id no longer present is skipped (it removes to a no-op below).
int orphanCount = 0;
for (const std::string& sampleId : selected) {
const Sample* s = src->index.query(sampleId);
if (s == nullptr) continue; // already gone; not a last-reference orphan
if (!book.hashReferencedElsewhere(s->contentHash, srcId)) ++orphanCount;
}
if (orphanCount > 0) {
const std::string msg =
std::to_string(orphanCount) +
(orphanCount == 1 ? " selected sample is" : " selected samples are") +
" in no other bank.\n\nRemoving " +
(orphanCount == 1 ? "it" : "them") +
" drops the index entry only — the file stays on disk until you prune "
"(it is never deleted by remove).\n\nRemove anyway?";
const int r = ShowMessageBox(msg.c_str(),
"ReaSampler: remove last-reference sample(s)", 4);
if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544)
}
// Perform the removes (this-bank scope). Pass ids by value — no BankIndex& is cached
// across the loop's mutations. Count real drops so the no-op guardrail can skip the
// undo point when nothing was removed (every id was already absent). The per-outcome
// console summary was dropped (m11 chatter policy); only the "did anything change?"
// signal the undo guardrail needs is retained.
int removed = 0;
for (const std::string& sampleId : selected) {
if (book.removeSample(sampleId, srcId, RemoveScope::ThisBank) ==
RemoveResult::Removed)
++removed;
// RejectedSampleAbsent / RejectedUnknownBank are no-ops for the guardrail.
// (Unknown bank cannot occur — srcId was resolved to a live bank above.)
}
// No-op guardrail (R-B): open an undo point only if the index actually mutated.
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
} }
} // namespace } // namespace
// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) —
// one bank op = one Ctrl-Z. Declared in actions.h so bank_panel.cpp can call it
// without duplicating the undo logic.
//
// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project
// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures
// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents
// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199).
// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family
// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them
// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs
// INSIDE the block so the post-mutation ext-state is the block's "after" image.
//
// NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a
// rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here,
// so no dangling/empty undo point is ever opened for a rejected op.
//
// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project persistBook() no-ops
// (nothing is written to ext state). We must still CLOSE the block we opened, but with
// an EMPTY label and a zero flag so REAPER DISCARDS the point instead of recording a
// no-effect undo entry — mirroring view.cpp's empty-plan close. The in-session model
// change stands and persists on the user's next save; it just earns no undo point until
// there is a project to persist into (undo of an unsaved bank op has nothing to roll
// back to anyway). The Begin/End must still be balanced, hence the close-either-way.
void persistBankOp(const char* label) {
Undo_BeginBlock2(nullptr);
const bool persisted = persistBook();
if (persisted)
Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG);
else
Undo_EndBlock2(nullptr, "", 0); // no ext-state write -> discard the empty point
}
void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
g_session = session; // shared with the Design View family; same live session g_session = session; // shared with the Design View family; same live session
@@ -695,6 +846,8 @@ void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session)
"ReaSampler: move selected samples to bank"); "ReaSampler: move selected samples to bank");
g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel, g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel,
"ReaSampler: copy selected samples to bank"); "ReaSampler: copy selected samples to bank");
g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel,
"ReaSampler: remove selected samples");
g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull, g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull,
"ReaSampler: toggle pool full-height"); "ReaSampler: toggle pool full-height");
g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull,
@@ -712,6 +865,7 @@ bool bankHandleCommand(int command) {
if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; } if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; }
if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; } if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; }
if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; } if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; }
if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; }
if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; } if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; }
if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; } if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; }
@@ -724,6 +878,8 @@ void bankUnregisterActions(reaper_plugin_info_t* rec) {
rec->Register("-command_id", (void*)kIdBankBanksFull); rec->Register("-command_id", (void*)kIdBankBanksFull);
rec->Register("-gaccel", (void*)&g_accelBankPoolFull); rec->Register("-gaccel", (void*)&g_accelBankPoolFull);
rec->Register("-command_id", (void*)kIdBankPoolFull); rec->Register("-command_id", (void*)kIdBankPoolFull);
rec->Register("-gaccel", (void*)&g_accelBankRemoveSel);
rec->Register("-command_id", (void*)kIdBankRemoveSel);
rec->Register("-gaccel", (void*)&g_accelBankCopySel); rec->Register("-gaccel", (void*)&g_accelBankCopySel);
rec->Register("-command_id", (void*)kIdBankCopySel); rec->Register("-command_id", (void*)kIdBankCopySel);
rec->Register("-gaccel", (void*)&g_accelBankMoveSel); rec->Register("-gaccel", (void*)&g_accelBankMoveSel);
+9
View File
@@ -66,4 +66,13 @@ bool bankHandleCommand(int command);
// rec==nullptr (before g_session is torn down). // rec==nullptr (before g_session is torn down).
void bankUnregisterActions(reaper_plugin_info_t* rec); void bankUnregisterActions(reaper_plugin_info_t* rec);
// Persists a completed bank-index verb as a single REAPER undo point (R-B).
// Wraps persistBook() (= SetProjExtState) in a Begin/End block with UNDO_STATE_MISCCFG
// so the bank op is one Ctrl-Z. On an unsaved / no-active project persistBook() no-ops
// and the block is closed with an empty label + zero flag (REAPER discards it). Callers
// must invoke this ONLY after a successful/effective mutation — rejected ops (duplicate
// name, un-deletable pool, etc.) must return before reaching here so no empty undo
// point is ever opened for a no-op. Defined in actions.cpp alongside persistBook().
void persistBankOp(const char* label);
} // namespace reasampler } // namespace reasampler
+33
View File
@@ -281,6 +281,39 @@ TransferResult BankBook::copySample(const std::string& sampleId,
return applyDestAdd(to->index, copy, TransferResult::Copied); return applyDestAdd(to->index, copy, TransferResult::Copied);
} }
// ---------------------------------------------------------------------------
// Sample removal (index-only) + the last-reference query
// ---------------------------------------------------------------------------
RemoveResult BankBook::removeSample(const std::string& sampleId,
const std::string& fromBankId,
RemoveScope scope) {
if (scope == RemoveScope::AllBanks) {
// Latent seam: purge the id from every bank that holds it. fromBankId is
// ignored (the id is dropped book-wide). Removed iff at least one drop landed.
bool any = false;
for (auto& b : banks_)
if (b.index.remove(sampleId)) any = true;
return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent;
}
// ThisBank (default, the only surfaced verb): drop from the one named source bank.
Bank* from = bank(fromBankId);
if (from == nullptr) return RemoveResult::RejectedUnknownBank;
return from->index.remove(sampleId) ? RemoveResult::Removed
: RemoveResult::RejectedSampleAbsent;
}
bool BankBook::hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const {
if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash)
for (const auto& b : banks_) {
if (b.id == exceptBankId) continue; // the removed-from bank is excluded
if (b.index.findByHash(hash) != nullptr) return true;
}
return false;
}
// =========================================================================== // ===========================================================================
// JSON — writer // JSON — writer
// =========================================================================== // ===========================================================================
+55
View File
@@ -85,6 +85,30 @@ enum class TransferResult {
RejectedSameBank, RejectedSameBank,
}; };
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam —
// live and tested at the model level, promotable later behind this parameter without
// a rewrite, but never wired to an affordance in B5.
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry
// in another bank survives (no cross-bank cascade — dedup is per-bank).
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
// ("purge from the library"). Latent; unsurfaced.
enum class RemoveScope {
ThisBank,
AllBanks,
};
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports
// what happened rather than silently mutating on a bad request.
// - Removed: at least one index entry was dropped.
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
enum class RemoveResult {
Removed,
RejectedUnknownBank,
RejectedSampleAbsent,
};
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample // 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 // indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
// multi-bank phase — mirror of bank_model / view_mode_model. // multi-bank phase — mirror of bank_model / view_mode_model.
@@ -155,6 +179,37 @@ public:
const std::string& fromBankId, const std::string& fromBankId,
const std::string& toBankId); const std::string& toBankId);
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --
// Drops a sample's index entry (the sample-level sibling of move/copy/evacuate).
// Index-only and non-destructive to the file: a last-reference remove leaves the
// file on disk, orphaned until Phase R prune — remove NEVER deletes bytes.
//
// Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from
// `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank
// that holds it. See RemoveResult for the outcome set.
// * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent
// if that bank does not hold the id; Removed on a drop.
// * AllBanks: `fromBankId` is ignored (the id is purged book-wide);
// RejectedSampleAbsent if NO bank held the id; Removed otherwise.
// No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer).
RemoveResult removeSample(const std::string& sampleId,
const std::string& fromBankId,
RemoveScope scope = RemoveScope::ThisBank);
// Reference-count query backing the confirm-on-last-reference guardrail: does any
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
//
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key
// the whole model already reasons in (findByHash / collapse-by-hash), and two
// entries that share content share one file — so "some other bank still references
// this hash" is exactly "removing here does not orphan the file." An EMPTY hash is
// never matched (it does not participate in dedup, mirroring findByHash), so an
// empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting
// direction (we cannot prove another bank shares an unhashed file).
bool hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const;
// -- Query --------------------------------------------------------------- // -- Query ---------------------------------------------------------------
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call. // The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
+103 -24
View File
@@ -46,6 +46,7 @@
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path)
#include "bank_book.h" #include "bank_book.h"
#include "bank_grid.h" #include "bank_grid.h"
#include "bank_model.h" #include "bank_model.h"
@@ -1179,14 +1180,11 @@ bool regionAt(int x, int y, Region& out) {
// --- Bank management ops (id-keyed; drive the B1 model + persist) -------------- // --- Bank management ops (id-keyed; drive the B1 model + persist) --------------
// //
// Each op mutates g_session.book() then persists via saveToActiveProject(). After a // Each op mutates g_session.book() then persists via persistBankOp(). After a
// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankIndex& is invalid — we // STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankIndex& is invalid — we
// resolve fresh, pass ids, and let the next refreshFingerprint repaint. persistBook // resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an
// no-ops on an unsaved project (matches the capture/B3 quiet-persist idiom). // unsaved project the empty-close discard in persistBankOp ensures no stale state
// survives (matches the capture/B3 quiet-persist idiom).
void persistBook() {
if (g_panel.session) g_panel.session->saveToActiveProject();
}
// REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3). // REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3).
bool promptText(const char* title, const char* caption, const std::string& initial, bool promptText(const char* title, const char* caption, const std::string& initial,
@@ -1224,7 +1222,7 @@ void doCreateBank() {
} }
g_panel.shownBankId = id; // show the freshly-created bank g_panel.shownBankId = id; // show the freshly-created bank
g_panel.focusedRegion = Region::Banks; g_panel.focusedRegion = Region::Banks;
persistBook(); persistBankOp("ReaSampler: create bank");
invalidatePanel(); invalidatePanel();
} }
@@ -1240,7 +1238,7 @@ void doRenameBank(const std::string& bankId) {
"ReaSampler: rename bank", 0); "ReaSampler: rename bank", 0);
return; return;
} }
persistBook(); persistBankOp("ReaSampler: rename bank");
invalidatePanel(); invalidatePanel();
} }
@@ -1273,7 +1271,7 @@ void doDeleteBank(const std::string& bankId) {
// r == 6 (Yes) falls through to a plain delete (drops members). // r == 6 (Yes) falls through to a plain delete (drops members).
} }
if (!book()->deleteBank(bankId)) return; if (!book()->deleteBank(bankId)) return;
persistBook(); persistBankOp("ReaSampler: delete bank");
// shownBankId is reconciled by the next fingerprint pass. If no named banks remain, // shownBankId is reconciled by the next fingerprint pass. If no named banks remain,
// nudge focus to the pool so the selection has a valid home. // nudge focus to the pool so the selection has a valid home.
if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool;
@@ -1285,36 +1283,106 @@ void doEvacuateBank(const std::string& bankId) {
const Bank* bk = book()->bank(bankId); const Bank* bk = book()->bank(bankId);
if (!bk || bk->isPool()) return; if (!bk || bk->isPool()) return;
if (!book()->evacuate(bankId)) return; if (!book()->evacuate(bankId)) return;
persistBook(); persistBankOp("ReaSampler: evacuate bank");
invalidatePanel(); invalidatePanel();
} }
void doActivateBank(const std::string& bankId) { void doActivateBank(const std::string& bankId) {
if (!book()) return; if (!book()) return;
if (!book()->setActiveBank(bankId)) return; // rejects an unknown id if (!book()->setActiveBank(bankId)) return; // rejects an unknown id
persistBook(); persistBankOp("ReaSampler: activate bank");
invalidatePanel(); invalidatePanel();
} }
// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass // Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass
// ids straight to the model op (no BankIndex& cached across the loop's mutations). // ids straight to the model op (no BankIndex& cached across the loop's mutations).
//
// NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected):
// * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally
// before the dest add collapses on hash), so the index DID mutate — counts.
// * COPY collapse: the source is left intact AND the dest already held the hash,
// so NOTHING changed — a true index no-op. Must NOT open an undo point.
// Hence: copy counts only real gains (Copied); move counts gains OR collapses.
void transferSamples(const std::vector<std::string>& sampleIds, void transferSamples(const std::vector<std::string>& sampleIds,
const std::string& srcBankId, const std::string& destBankId, const std::string& srcBankId, const std::string& destBankId,
bool copy) { bool copy) {
if (!book()) return; if (!book()) return;
if (sampleIds.empty() || srcBankId == destBankId) return; if (sampleIds.empty() || srcBankId == destBankId) return;
if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return; if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return;
int ok = 0, collapsed = 0;
for (const std::string& sid : sampleIds) { for (const std::string& sid : sampleIds) {
if (copy) book()->copySample(sid, srcBankId, destBankId); const TransferResult r =
else book()->moveSample(sid, srcBankId, destBankId); copy ? book()->copySample(sid, srcBankId, destBankId)
: book()->moveSample(sid, srcBankId, destBankId);
switch (r) {
case TransferResult::Moved:
case TransferResult::Copied: ++ok; break;
case TransferResult::Collapsed: ++collapsed; break;
case TransferResult::RejectedUnknownBank:
case TransferResult::RejectedSampleAbsent:
case TransferResult::RejectedSameBank: break;
} }
persistBook(); }
const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0);
if (!mutated) return; // nothing changed — no persist, no undo point
const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)";
persistBankOp(label);
// The selection indexed into the source; after a move those indices are stale, so // The selection indexed into the source; after a move those indices are stale, so
// clear it (the fingerprint pass will also clear, but do it now for immediacy). // clear it (the fingerprint pass will also clear, but do it now for immediacy).
g_panel.selection = Selection{}; g_panel.selection = Selection{};
invalidatePanel(); invalidatePanel();
} }
// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to
// the file: a last-reference remove leaves the file on disk, orphaned until Phase R
// prune — remove NEVER deletes bytes (the manifest is untouched). Confirm-on-last-
// reference guardrail: a single confirm summarizing the N whose files this would orphan
// (computed on the PRE-mutation reference graph), fired only when at least one would
// orphan. Ids passed by value — no BankIndex& cached across the loop's mutations.
void removeSamples(const std::vector<std::string>& sampleIds,
const std::string& srcBankId) {
if (!book() || sampleIds.empty()) return;
const Bank* src = book()->bank(srcBankId);
if (!src) return;
// Count files this remove would orphan — computed BEFORE mutating, so a same-hash
// sibling in another bank counts as a surviving reference.
int orphanCount = 0;
for (const std::string& sid : sampleIds) {
const Sample* s = src->index.query(sid);
if (!s) continue; // already gone; not a last-reference orphan
if (!book()->hashReferencedElsewhere(s->contentHash, srcBankId)) ++orphanCount;
}
if (orphanCount > 0) {
const std::string msg =
std::to_string(orphanCount) +
(orphanCount == 1 ? " selected sample is" : " selected samples are") +
" in no other bank.\n\nRemoving " +
(orphanCount == 1 ? "it" : "them") +
" drops the index entry only — the file stays on disk until you prune "
"(it is never deleted by remove).\n\nRemove anyway?";
// 4 == MB_YESNO. 6=Yes (SDK); anything else cancels.
const int r = ShowMessageBox(msg.c_str(),
"ReaSampler: remove last-reference sample(s)", 4);
if (r != 6) return;
}
int removed = 0;
for (const std::string& sid : sampleIds)
if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) ==
RemoveResult::Removed)
++removed;
if (removed == 0) return; // nothing changed — no persist, no undo point
persistBankOp("ReaSampler: remove sample(s)");
// The selection indexed into the source; after a remove those indices are stale, so
// clear it (the fingerprint pass will also clear, but do it now for immediacy).
g_panel.selection = Selection{};
invalidatePanel();
}
// The selection's sample ids resolved against the FOCUSED region's bank (source of a // The selection's sample ids resolved against the FOCUSED region's bank (source of a
// move/copy). Returns ids in bank order; empty when nothing selected. // move/copy). Returns ids in bank order; empty when nothing selected.
std::vector<std::string> focusedSelectionIds() { std::vector<std::string> focusedSelectionIds() {
@@ -1356,6 +1424,7 @@ enum : unsigned int {
kMenuDelete, kMenuDelete,
kMenuEvacuate, kMenuEvacuate,
kMenuCreate, kMenuCreate,
kMenuRemove, // remove selected sample(s) from the source bank (B5)
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
}; };
@@ -1411,16 +1480,13 @@ void showSelectionMenu(int screenX, int screenY) {
for (const Bank* bk : namedBanks()) for (const Bank* bk : namedBanks())
if (bk->id != srcId) dests.push_back({bk->id, bk->displayName}); if (bk->id != srcId) dests.push_back({bk->id, bk->displayName});
HMENU menu = CreatePopupMenu();
if (dests.empty()) {
menuAppend(menu, kMenuNone, "No other bank to move to", /*grayed=*/true);
TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr);
DestroyMenu(menu);
return;
}
const std::string label = std::to_string(sel.size()) + const std::string label = std::to_string(sel.size()) +
(sel.size() == 1 ? " sample" : " samples"); (sel.size() == 1 ? " sample" : " samples");
HMENU menu = CreatePopupMenu();
// Move/copy blocks appear only when there is another bank to transfer to; Remove is
// always offered (it needs no destination — it drops the entry from the source).
if (!dests.empty()) {
menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true);
for (std::size_t i = 0; i < dests.size(); ++i) for (std::size_t i = 0; i < dests.size(); ++i)
menuAppend(menu, kMenuMoveBase + static_cast<unsigned int>(i), menuAppend(menu, kMenuMoveBase + static_cast<unsigned int>(i),
@@ -1430,11 +1496,16 @@ void showSelectionMenu(int screenX, int screenY) {
for (std::size_t i = 0; i < dests.size(); ++i) for (std::size_t i = 0; i < dests.size(); ++i)
menuAppend(menu, kMenuCopyBase + static_cast<unsigned int>(i), menuAppend(menu, kMenuCopyBase + static_cast<unsigned int>(i),
(" " + dests[i].name).c_str()); (" " + dests[i].name).c_str());
menuSeparator(menu);
}
menuAppend(menu, kMenuRemove, ("Remove " + label + "\xE2\x80\xA6").c_str());
const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
g_panel.hwnd, nullptr); g_panel.hwnd, nullptr);
DestroyMenu(menu); DestroyMenu(menu);
if (cmd >= static_cast<int>(kMenuMoveBase) && if (cmd == static_cast<int>(kMenuRemove)) {
removeSamples(sel, srcId);
} else if (cmd >= static_cast<int>(kMenuMoveBase) &&
cmd < static_cast<int>(kMenuMoveBase + dests.size())) { cmd < static_cast<int>(kMenuMoveBase + dests.size())) {
transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false); transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false);
} else if (cmd >= static_cast<int>(kMenuCopyBase) && } else if (cmd >= static_cast<int>(kMenuCopyBase) &&
@@ -1670,6 +1741,14 @@ bool handleKey(int vk) {
case VK_ESCAPE: case VK_ESCAPE:
stopAudition(); stopAudition();
return true; return true;
case VK_DELETE: {
// Remove the focused-region selection (B5). Same confirm-on-last-reference
// path the context-menu "Remove" uses; a no-op when nothing is selected.
const std::vector<std::string> sel = focusedSelectionIds();
if (sel.empty()) return false; // nothing selected — let the key fall through
removeSamples(sel, bankIdForRegion(g_panel.focusedRegion));
return true;
}
default: default:
return false; return false;
} }
+72 -5
View File
@@ -145,7 +145,12 @@ static void CommitRealtimeResult(const reasampler::CaptureResult& res)
return; return;
} }
g_session.bank().add(res.sample); g_session.bank().add(res.sample);
g_session.saveToActiveProject(); // persist + MarkProjectDirty (travels with .rpp) // B-cap: record the file the capture created in the owned-file manifest, at the same
// point the Sample is added and before the same persist. Recorded regardless of the
// index AddResult — even a hash-collapse still WROTE a file the tool owns, and the
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
g_session.owned().add(res.sample.relativePath);
g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp)
} }
// Advance any in-flight realtime capture one tick. Cheap when none is running (a // Advance any in-flight realtime capture one tick. Cheap when none is running (a
@@ -234,6 +239,56 @@ static void OnTimer()
reasampler::bankPanelRefresh(); reasampler::bankPanelRefresh();
} }
// --- projectconfig hook: reload the session on undo/redo (R-B) ---------------
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view
// would stay stale until close+reopen. REAPER's projectconfig extension fires
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
// (isUndo == true for both). We hook it to drive a session reload.
//
// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling.
//
// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false);
// we ignore that here so a normal open flows solely through the timer's identity-transition
// Load path (no double load). Only undo/redo (isUndo=true) requests the reload.
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
{
if (isUndo)
g_session.requestReload();
}
// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"),
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own
// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo
// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER
// keeps dispatching (we claim none). SaveExtensionConfig writes nothing.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
bool /*isUndo*/, project_config_extension_t* /*reg*/)
{
return false; // we own no project lines — ext state carries our data
}
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
project_config_extension_t* /*reg*/)
{
// Nothing to write: our data rides in ext state, not project lines.
}
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
static project_config_extension_t g_projectConfig{
&OnProcessExtensionLine,
&OnSaveExtensionConfig,
&OnBeginLoadProjectState,
nullptr, // userData
};
// --- Scope-action source resolution ----------------------------------------- // --- Scope-action source resolution -----------------------------------------
// The three scope actions (item / track / master) each resolve to (1) an exact // The three scope actions (item / track / master) each resolve to (1) an exact
// render range in project seconds — razor-else-time, inferred here — and (2) the // render range in project seconds — razor-else-time, inferred here — and (2) the
@@ -558,10 +613,15 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
g_session.bank().add(res.sample); g_session.bank().add(res.sample);
// Persist the updated book into the active project's ext state (the `banks` key) // B-cap: record the created file in the owned-file manifest, at the same point the
// so the capture survives Save / close+reopen (M4) and travels with the .rpp. // Sample is added and before the same persist. Recorded regardless of the index
// saveToActiveProject also clears the retired legacy key and calls MarkProjectDirty. // AddResult — even a hash-collapse still WROTE a file the tool owns, and the manifest
// Non-destructive: writes only our own ext-state keys. // dedups a repeat path itself (Phase R prune reconciles manifest vs index later).
g_session.owned().add(res.sample.relativePath);
// Persist the updated book AND manifest into the active project's ext state (the
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
g_session.saveToActiveProject(); g_session.saveToActiveProject();
} }
@@ -758,6 +818,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
} }
g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
g_rec->Register("-toggleaction", (void*)&OnToggleAction); g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand); g_rec->Register("-hookcommand", (void*)&OnHookCommand);
// Tear down the Design View action family (D4) — mirror-unregisters each // Tear down the Design View action family (D4) — mirror-unregisters each
@@ -929,5 +990,11 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
// state, on a Save-As it relocates the bank folder under the new .rpp. // state, on a Save-As it relocates the bank folder under the new .rpp.
rec->Register("timer", (void*)&OnTimer); rec->Register("timer", (void*)&OnTimer);
// Register the projectconfig hook so an UNDO/REDO state restore reloads the
// session's book + view from the restored ext state (R-B). The timer's identity
// poll cannot see an undo (same project identity), so this hook owns undo/redo; it
// requests a deferred reload that the next timer tick drains (see the hook comment).
rec->Register("projectconfig", (void*)&g_projectConfig);
return 1; // success — REAPER keeps us loaded return 1; // success — REAPER keeps us loaded
} }
+315
View File
@@ -0,0 +1,315 @@
#include "owned_manifest.h"
#include <cctype>
#include <cstdio>
// owned_manifest implementation.
//
// JSON is hand-rolled and self-contained (project convention: the pure core is
// dependency-free — no third-party JSON lib, mirror of bank_model / bank_book /
// tail_control). The shape is a single object with one string array:
//
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
//
// so a compact writer + a focused string-array parser is all it needs — far smaller
// than bank_model's full recursive-descent parser, because there is exactly one key
// and one value kind.
namespace reasampler {
// ---------------------------------------------------------------------------
// path invariant (mirror of bank_model's isAbsolutePath)
// ---------------------------------------------------------------------------
namespace {
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to
// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant
// must match exactly (a path the index accepts must be recordable, and vice versa).
bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true;
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
return true;
return false;
}
} // namespace
// ---------------------------------------------------------------------------
// mutation / query
// ---------------------------------------------------------------------------
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath;
if (contains(relativePath)) return ManifestAddResult::AlreadyPresent;
paths_.push_back(relativePath);
return ManifestAddResult::Added;
}
bool OwnedFileManifest::contains(const std::string& relativePath) const {
for (const auto& p : paths_)
if (p == relativePath) return true;
return false;
}
// ---------------------------------------------------------------------------
// JSON writer
// ---------------------------------------------------------------------------
namespace {
void writeEscaped(std::string& out, const std::string& s) {
out += '"';
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x",
static_cast<unsigned char>(c));
out += buf;
} else {
out += c;
}
}
}
out += '"';
}
} // namespace
std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":[";
for (std::size_t i = 0; i < paths_.size(); ++i) {
if (i) out += ',';
writeEscaped(out, paths_[i]);
}
out += "]}";
return out;
}
// ---------------------------------------------------------------------------
// JSON parser (string-array only)
// ---------------------------------------------------------------------------
namespace {
class Parser {
public:
explicit Parser(const std::string& s) : s_(s) {}
// Parse the manifest object into `out`. Tolerates unknown keys (forward-compat)
// and requires the "owned" value to be an array of strings.
bool parseManifest(OwnedFileManifest& out);
private:
const std::string& s_;
std::size_t pos_ = 0;
bool eof() const { return pos_ >= s_.size(); }
void skipWs() {
while (!eof()) {
char c = s_[pos_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
else break;
}
}
bool consume(char c) {
skipWs();
if (eof() || s_[pos_] != c) return false;
++pos_;
return true;
}
bool parseString(std::string& out);
bool parseStringArray(std::vector<std::string>& out);
bool skipValue(); // for forward-compat unknown keys
};
// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX for
// control chars). Positioned before the opening quote (skips leading whitespace).
bool Parser::parseString(std::string& out) {
skipWs();
if (eof() || s_[pos_] != '"') return false;
++pos_;
out.clear();
while (!eof()) {
char c = s_[pos_++];
if (c == '"') return true;
if (c == '\\') {
if (eof()) return false;
char e = s_[pos_++];
switch (e) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
auto readHex4 = [&](unsigned int& cp) -> bool {
if (pos_ + 4 > s_.size()) return false;
cp = 0;
for (int i = 0; i < 4; ++i) {
char h = s_[pos_++];
cp <<= 4;
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
else return false;
}
return true;
};
unsigned int hi = 0;
if (!readHex4(hi)) return false;
unsigned int codePoint = hi;
if (hi >= 0xD800 && hi <= 0xDBFF) {
if (pos_ + 6 > s_.size()) return false;
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
pos_ += 2;
unsigned int lo = 0;
if (!readHex4(lo)) return false;
if (lo < 0xDC00 || lo > 0xDFFF) return false;
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
return false; // unpaired low surrogate
}
if (codePoint <= 0x7F) {
out += static_cast<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
}
break;
}
default: return false;
}
} else {
out += c;
}
}
return false; // unterminated string
}
bool Parser::parseStringArray(std::vector<std::string>& out) {
if (!consume('[')) return false;
skipWs();
if (consume(']')) return true; // empty array
for (;;) {
std::string s;
if (!parseString(s)) return false;
out.push_back(std::move(s));
skipWs();
if (consume(',')) continue;
if (consume(']')) return true;
return false; // neither separator nor terminator — malformed
}
}
// Skip a single JSON value (string / array / object / bare scalar) so an unknown key
// does not abort the parse. Minimal: enough for forward-compat siblings we don't know.
bool Parser::skipValue() {
skipWs();
if (eof()) return false;
char c = s_[pos_];
if (c == '"') {
std::string tmp;
return parseString(tmp);
}
if (c == '[' || c == '{') {
// Balance nested brackets of either kind, ignoring bracket chars inside
// strings. Enough to step over an unknown nested value; not a full validator.
int depth = 0;
bool inStr = false;
while (!eof()) {
char d = s_[pos_];
if (inStr) {
if (d == '\\') { pos_ += 2; continue; }
if (d == '"') inStr = false;
++pos_;
continue;
}
if (d == '"') { inStr = true; ++pos_; continue; }
if (d == '[' || d == '{') ++depth;
else if (d == ']' || d == '}') {
--depth;
if (depth == 0) { ++pos_; return true; }
}
++pos_;
}
return false;
}
// bare scalar (number / true / false / null) — read to the next structural char
while (!eof()) {
char d = s_[pos_];
if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' ||
d == '\n' || d == '\r')
break;
++pos_;
}
return true;
}
bool Parser::parseManifest(OwnedFileManifest& out) {
if (!consume('{')) return false;
skipWs();
if (consume('}')) return true; // empty object -> empty manifest
for (;;) {
std::string key;
if (!parseString(key)) return false;
if (!consume(':')) return false;
if (key == "owned") {
std::vector<std::string> paths;
if (!parseStringArray(paths)) return false;
for (auto& p : paths) {
// Feed through add() so the persisted invariants (dedup, reject
// empty/absolute) are re-asserted on load — a hand-edited or corrupt
// blob cannot smuggle an absolute or duplicate path into the manifest.
out.add(p);
}
} else {
if (!skipValue()) return false; // forward-compat: tolerate unknown keys
}
skipWs();
if (consume(',')) continue;
if (consume('}')) return true;
return false;
}
}
} // namespace
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& json) {
OwnedFileManifest m;
Parser p(json);
if (!p.parseManifest(m)) return std::nullopt;
return m;
}
} // namespace reasampler
+91
View File
@@ -0,0 +1,91 @@
#pragma once
// owned_manifest — the pure core of the owned-file manifest seam (Phase B, B-cap).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
// "small pure type + JSON round-trip" pattern as wav_trim / tab_strip.
//
// -- What it is --------------------------------------------------------------
//
// The set of files the bank system ITSELF created — every file the capture path
// writes gets recorded here. Phase R prune consumes it to tell the system's own
// orphans (owned ∩ present referenced) apart from hand-dropped files. B-cap only
// WRITES and PERSISTS the manifest; no prune logic lives here (fork R-D, settled
// 2026-07-24: "defer the feature, design the seam").
//
// -- What it is NOT ----------------------------------------------------------
//
// It is NOT a mirror of the bank index. Removing or moving an index entry does NOT
// remove the file's manifest record: the manifest tracks files *created*, and prune
// (Phase R) reconciles manifest-vs-index later. The ONLY thing that adds to it is
// the capture add-path. There is deliberately no remove verb here.
//
// -- The relative-paths-only invariant ---------------------------------------
//
// A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath
// and the persisted BankIndex). 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 BankIndex::add's rejection).
#include <optional>
#include <string>
#include <vector>
namespace reasampler {
// Outcome of an add(). Mirrors BankIndex::AddResult's honesty — the op reports what
// happened rather than silently mutating on a bad request.
// - Added: the path was new and recorded.
// - AlreadyPresent: the path was already in the manifest (dedup no-op).
// - RejectedEmptyPath: the path was empty.
// - RejectedAbsolutePath: the path was absolute (relative-paths-only invariant).
enum class ManifestAddResult {
Added,
AlreadyPresent,
RejectedEmptyPath,
RejectedAbsolutePath,
};
// The owned-file manifest: an insertion-ordered, deduplicated set of project-relative
// paths the capture path has created. Insertion order is preserved so serialize()
// round-trips byte-identically (deterministic ext-state, mirror of the index).
class OwnedFileManifest {
public:
OwnedFileManifest() = default;
// Record a project-relative path as owned. Rejects an empty or absolute path (no
// mutation). A path already present is a dedup no-op (AlreadyPresent), so a repeat
// capture of an identical request does not double-record.
ManifestAddResult add(const std::string& relativePath);
// True iff the exact path string is recorded. Phase R uses this to attribute a
// present file to the bank system. Exact string match — path normalization (if any)
// is the caller's concern, consistent across add and query.
bool contains(const std::string& relativePath) const;
// The owned paths in insertion order. Phase R unions this with the on-disk file
// set; here it is the round-trip + query surface.
const std::vector<std::string>& paths() const { return paths_; }
std::size_t size() const { return paths_.size(); }
bool empty() const { return paths_.empty(); }
bool operator==(const OwnedFileManifest& o) const { return paths_ == o.paths_; }
// -- Persistence ---------------------------------------------------------
// Serialize to a JSON string (lossless round-trip): deserialize(serialize(x)) == x.
// An empty manifest serializes to a well-formed empty shape (round-trips to empty).
std::string serialize() const;
// Parse a manifest JSON produced by serialize(). std::nullopt on malformed input
// (the persist shell warns + falls back to an empty manifest, mirroring the bank /
// view malformed handling). An empty/absent stored value is the caller's concern
// (an empty string is not valid JSON) — the shell maps absence to a fresh manifest.
static std::optional<OwnedFileManifest> deserialize(const std::string& json);
private:
std::vector<std::string> paths_; // insertion order; deduplicated
};
} // namespace reasampler
+85 -8
View File
@@ -51,11 +51,23 @@
// (after relocating, or on the forked-sibling Load branch) so identities diverge. // (after relocating, or on the forked-sibling Load branch) so identities diverge.
// //
// Rationale for the timer: the brief mandates ext-state storage (rules out the // Rationale for the timer: the brief mandates ext-state storage (rules out the
// projectconfig .rpp-line hook), and the timer composes cleanly with ext-state // projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with
// while covering both load and Save-As detection in one place. The // ext-state while covering identity-transition load + Save-As detection in one
// `projectconfig` BeginLoadProjectState hook is a deterministic alternative for // place.
// pure load detection but would still need the timer (or Main_SaveProject //
// post-hook) for Save-As path-change detection — surfaced in the handoff. // DIVISION OF LABOUR (R-B undo):
// * Identity-transition poll (this file, classifyProjectTransition) owns
// open / tab-switch / new / forked-sibling / Save-As-relocation — every case
// where the project OF RECORD changes.
// * The `projectconfig` hook (main.cpp registers project_config_extension_t;
// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project
// identity is unchanged but its ext state rolled back/forward on disk. The
// identity poll sees NoOp there and would never re-read ext state, so the hook
// requests a reload (requestReload) that poll() drains on the next tick, once
// REAPER has restored the <EXTSTATE> block. See requestReload / the poll drain.
// The hook fires on undo AND redo (isUndo true for both), and on normal open
// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open
// flows solely through the identity-transition Load path and never double-loads.
// //
// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY // NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY
// our own reasampler_bank/ folder. It never touches the user's media, items, or // our own reasampler_bank/ folder. It never touches the user's media, items, or
@@ -172,11 +184,11 @@ bool relocateBankFolder(const std::string& oldBankDir,
} // namespace } // namespace
void ReaSamplerSession::saveToActiveProject() { bool ReaSamplerSession::saveToActiveProject() {
std::string rppPath; std::string rppPath;
void* proj = readActiveProject(rppPath); void* proj = readActiveProject(rppPath);
if (!proj) return; // no active project — nothing to persist if (!proj) return false; // no active project — nothing to persist
if (rppPath.empty()) return; // unsaved project — no .rpp to store into if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
// Phase B: the whole book (pool as bank-zero + named banks) is authoritative and // Phase B: the whole book (pool as bank-zero + named banks) is authoritative and
// rides in the `banks` key. // rides in the `banks` key.
@@ -205,7 +217,17 @@ void ReaSamplerSession::saveToActiveProject() {
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace, SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
kProjExtTailKey, tailJson.c_str()); kProjExtTailKey, tailJson.c_str());
// Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own
// `owned_files` key. Independent write — does not disturb the blobs above. Written
// on EVERY save so a capture's manifest record survives Save / Save-As / reopen,
// and so the manifest and the bank stay in lockstep on disk (both persisted by the
// same saveToActiveProject the capture add-path calls).
const std::string ownedJson = owned_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
kProjExtOwnedKey, ownedJson.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj)); MarkProjectDirty(static_cast<ReaProject*>(proj));
return true;
} }
namespace { namespace {
@@ -246,6 +268,25 @@ TailSetting loadTailSetting(ReaProject* proj) {
return *loaded; return *loaded;
} }
// Load the owned-file manifest from a project's owned_files key, or return an empty
// manifest. An absent/empty key (older / never-captured project) yields an empty
// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to
// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then
// sees an empty ownership record and (safely) attributes nothing until the next capture
// rebuilds it — losing the record degrades safety, never correctness.
OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
if (!proj) return OwnedFileManifest{};
const std::string ownedJson =
getProjExtStateString(proj, kProjExtNamespace, kProjExtOwnedKey);
if (ownedJson.empty()) return OwnedFileManifest{}; // no stored manifest -> empty
std::optional<OwnedFileManifest> loaded = OwnedFileManifest::deserialize(ownedJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed — ignoring.\n");
return OwnedFileManifest{};
}
return std::move(*loaded);
}
} // namespace } // namespace
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
@@ -268,6 +309,12 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
// the previous project's choice (this REPLACES the old session-carry behavior). // the previous project's choice (this REPLACES the old session-carry behavior).
tail_ = loadTailSetting(static_cast<ReaProject*>(proj)); tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
// The owned-file manifest is restored on EVERY load path too (peer-symmetry with the
// bank/view/tail resets): switching to a project with no stored manifest must reset
// to empty, not inherit the previous project's ownership record; an undo/redo reload
// (R-B) must re-read the restored manifest so it matches the rolled-back bank state.
owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));
if (!proj) { if (!proj) {
book_ = BankBook{}; book_ = BankBook{};
return; return;
@@ -339,6 +386,13 @@ bool ReaSamplerSession::consumeLoadSignal() {
return pending; return pending;
} }
void ReaSamplerSession::requestReload() {
// Set-only; poll() drains it on the next tick (see the poll() drain block for why
// the read is deferred past the projectconfig callback). Cheap and idempotent —
// multiple undo/redo callbacks before the next tick collapse to one reload.
reloadRequested_ = true;
}
void ReaSamplerSession::poll() { void ReaSamplerSession::poll() {
std::string rppPath; std::string rppPath;
void* proj = readActiveProject(rppPath); void* proj = readActiveProject(rppPath);
@@ -355,6 +409,29 @@ void ReaSamplerSession::poll() {
lastProject_ = proj; lastProject_ = proj;
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath; lastRppPath_ = rppPath;
reloadRequested_ = false; // priming already loaded — a co-tick request is moot
return;
}
// Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier
// below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID,
// and .rpp path — so classifyProjectTransition would return NoOp and never re-read
// ext state, leaving book_/view_ stale after the on-disk ext state rolled back.
// The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_
// one or more ticks ago; by NOW REAPER has finished restoring the project's
// <EXTSTATE> block, so GetProjExtState returns the POST-undo value. Reload from the
// current active project and identity-adopt it (no relocation — the path is
// unchanged), then return. loadFromProject raises loadPending_, so the existing
// consumeLoadSignal() glue re-baselines the panel detector and reapplies the active
// mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is
// the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect
// an undo (Daniel's directive: the hook drives it, not a poll heuristic).
if (reloadRequested_) {
reloadRequested_ = false;
loadFromProject(proj, projectDirOf(rppPath));
lastProject_ = proj;
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath;
return; return;
} }
+54 -2
View File
@@ -21,6 +21,7 @@
#include "bank_book.h" #include "bank_book.h"
#include "bank_model.h" #include "bank_model.h"
#include "owned_manifest.h"
#include "tail_control.h" #include "tail_control.h"
#include "view_mode_model.h" #include "view_mode_model.h"
@@ -58,6 +59,17 @@ inline constexpr const char* kProjExtViewKey = "view_state";
// default — graceful, but the user's saved choice would be lost). // default — graceful, but the user's saved choice would be lost).
inline constexpr const char* kProjExtTailKey = "tail_setting"; inline constexpr const char* kProjExtTailKey = "tail_setting";
// The ext-state key holding the owned-file manifest JSON (the set of project-relative
// files the capture path itself created — Phase B B-cap seam, consumed by Phase R
// prune to distinguish the bank system's own orphans from hand-dropped files). A
// SIBLING key alongside banks/view_state/tail_setting — NOT folded into the `banks`
// blob, so it stays decoupled from bank membership (removing an index entry is not a
// manifest removal). One namespace, four content keys. FOREVER-STABLE: changing it
// strands every already-saved project's ownership record, so Phase R prune could no
// longer tell the tool's own files apart (it would fall back to an empty manifest —
// graceful, but the attribution safety net is lost until the next capture rebuilds it).
inline constexpr const char* kProjExtOwnedKey = "owned_files";
// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED // The ext-state key holding a GUID we mint per project to establish CONTENT-BASED
// project identity (REAPER exposes no stable per-project GUID). poll() uses it to // project identity (REAPER exposes no stable per-project GUID). poll() uses it to
// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch // tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch
@@ -122,17 +134,48 @@ public:
TailSetting& tail() { return tail_; } TailSetting& tail() { return tail_; }
const TailSetting& tail() const { return tail_; } const TailSetting& tail() const { return tail_; }
// The owned-file manifest (Phase B B-cap): the set of project-relative files the
// capture path itself created. The capture add-path records each created file here
// (main.cpp, alongside the bank add), exactly as it adds the Sample to the active
// bank; persist serializes it under the `owned_files` key on save and replaces it on
// project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it;
// B-cap only writes and persists it (no prune logic here).
OwnedFileManifest& owned() { return owned_; }
const OwnedFileManifest& owned() const { return owned_; }
// Serialize the current book (under the `banks` key), view model, and tail setting // Serialize the current book (under the `banks` key), view model, and tail setting
// to the active project's ext state (namespace "reasampler"), and clear the retired // to the active project's ext state (namespace "reasampler"), and clear the retired
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys. // legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
// Safe to call when there is no active/saved project (it no-ops). // Safe to call when there is no active/saved project (it no-ops).
void saveToActiveProject(); //
// Returns true iff a persist actually happened (an active, SAVED project existed);
// false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a
// caller wrapping this in an undo block skip the block when nothing was written, so
// no dangling no-effect undo entry is opened on an unsaved project.
bool saveToActiveProject();
// Poll the active project. Detects a project load (active project changed) // Poll the active project. Detects a project load (active project changed)
// and a Save-As (active project's .rpp path changed) and reacts accordingly. // and a Save-As (active project's .rpp path changed) and reacts accordingly.
// Intended to be driven by REAPER's "timer" register. Idempotent per tick. // Intended to be driven by REAPER's "timer" register. Idempotent per tick.
//
// Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z
// keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the
// identity classifier below reads it as NoOp and would never re-read ext state.
// The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state
// restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the
// (now-restored) ext state of the current project — before the identity check, so
// the undo is reflected in-session without any content polling.
void poll(); void poll();
// Request a reload of book_ + view_ + tail_ from the CURRENT active project's ext
// state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY
// on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read)
// because the projectconfig callback fires BEFORE REAPER has restored the project's
// <EXTSTATE> block — reading GetProjExtState synchronously there would return the
// PRE-undo value. Draining it on the next timer tick reads the restored value. This
// is REAPER-facing shell state; the request itself carries no REAPER types.
void requestReload();
// Load signal for the D4 reapply-on-open glue. poll() raises this whenever it // Load signal for the D4 reapply-on-open glue. poll() raises this whenever it
// (re)loads the view model from a project — prime, a project switch/open, or a // (re)loads the view model from a project — prime, a project switch/open, or a
// forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears // forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears
@@ -158,6 +201,13 @@ private:
// adjusted project), so an absent key is graceful. Peer to bank_/view_. // adjusted project), so an absent key is graceful. Peer to bank_/view_.
TailSetting tail_; TailSetting tail_;
// The owned-file manifest. Default empty; loadFromProject resets it to empty (or the
// stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to
// a project with no stored manifest must not inherit the previous project's ownership
// record, and an undo that rolled back a capture must re-read the restored manifest so
// the in-memory set matches disk. Absent key -> empty is graceful (older project).
OwnedFileManifest owned_;
// The project identity last observed by poll(), used to detect load/Save-As. // The project identity last observed by poll(), used to detect load/Save-As.
// The GUID is the PRIMARY signal (a different stored GUID = a different project // The GUID is the PRIMARY signal (a different stored GUID = a different project
// of record = Load, immune to pointer recycling). The pointer disambiguates the // of record = Load, immune to pointer recycling). The pointer disambiguates the
@@ -171,10 +221,12 @@ private:
std::string lastRppPath_; // .rpp path last seen for lastProject_ std::string lastRppPath_; // .rpp path last seen for lastProject_
bool primed_ = false; // false until the first poll() observes state bool primed_ = false; // false until the first poll() observes state
bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal
bool reloadRequested_ = false; // raised by requestReload (projectconfig undo/redo); drained by poll
// Load the book from the given project's ext state (the `banks` key, else the // Load the book from the given project's ext state (the `banks` key, else the
// legacy `bank_index` key migrated into the pool) and resolve bank paths against // legacy `bank_index` key migrated into the pool) and resolve bank paths against
// projectDir at read time. Replaces the in-memory book. projectDir empty -> the // projectDir at read time. Replaces the in-memory book. Also restores view_, tail_,
// and owned_ from their sibling keys on every load path. projectDir empty -> the
// book is reset to empty (unsaved project has no resolvable banks). // book is reset to empty (unsaved project has no resolvable banks).
void loadFromProject(void* proj, const std::string& projectDir); void loadFromProject(void* proj, const std::string& projectDir);
}; };
+124
View File
@@ -638,6 +638,124 @@ static void testDeserializeNamedBankCollidingWithPoolIsDisambiguated() {
if (back2) CHECK(back2->serialize() == back->serialize()); if (back2) CHECK(back2->serialize() == back->serialize());
} }
// --- B5: sample-remove (this-bank + latent all-banks) + last-reference query ------
//
// removeSample drops a Sample's index entry (index-only, non-destructive to the file).
// ThisBank (default, surfaced) drops from one named source bank; AllBanks (latent seam)
// purges the id book-wide. hashReferencedElsewhere backs the confirm-on-last-reference
// guardrail: does any OTHER bank still hold the content hash?
static void testRemoveDropsTargetEntry() {
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.bank("drums")->index.add(sampleWith("kick")) == AddResult::Added);
CHECK(book.bank("drums")->index.add(sampleWith("snare")) == AddResult::Added);
// Remove the kick from drums: dropped from the target bank, the sibling survives.
CHECK(book.removeSample("id-kick", "drums") == RemoveResult::Removed);
CHECK(book.bank("drums")->index.query("id-kick") == nullptr); // dropped
CHECK(book.bank("drums")->index.query("id-snare") != nullptr); // sibling kept
CHECK(book.bank("drums")->index.size() == 1);
}
static void testRemoveThisBankLeavesSameHashInAnotherBank() {
// Copy a sample into two banks (same hash in both), then remove from one under the
// default this-bank scope: the OTHER bank's entry survives — no cross-bank cascade.
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);
CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);
CHECK(book.removeSample("id-clap", kPoolBankId, RemoveScope::ThisBank) ==
RemoveResult::Removed);
CHECK(book.pool().index.query("id-clap") == nullptr); // removed from pool
CHECK(book.bank("drums")->index.query("id-clap") != nullptr); // drums copy survives
CHECK(book.bank("drums")->index.findByHash("clap-hash") != nullptr);
}
static void testRemoveFromPoolAllowedContainerPrivilegesHold() {
// Pool CONTENTS are removable (the pool must not be a roach-motel); the pool
// CONTAINER privileges (un-deletable / un-renamable / un-evacuable) are untouched.
BankBook book;
CHECK(book.pool().index.add(sampleWith("loop")) == AddResult::Added);
CHECK(book.removeSample("id-loop", kPoolBankId) == RemoveResult::Removed);
CHECK(book.pool().index.empty()); // content removed
// Container privileges still enforced.
CHECK(!book.deleteBank(kPoolBankId));
CHECK(!book.renameBank(kPoolBankId, "NotPool"));
CHECK(!book.evacuate(kPoolBankId));
CHECK(book.size() == 1);
CHECK(book.pool().displayName == std::string(kPoolBankName));
}
static void testRemoveRejectionsNoMutation() {
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.bank("drums")->index.add(sampleWith("kick")) == AddResult::Added);
// Unknown bank → honest rejection, no mutation.
CHECK(book.removeSample("id-kick", "ghost") == RemoveResult::RejectedUnknownBank);
CHECK(book.bank("drums")->index.query("id-kick") != nullptr); // untouched
// Absent sample (right bank, wrong id) → honest rejection, no mutation.
CHECK(book.removeSample("id-missing", "drums") == RemoveResult::RejectedSampleAbsent);
CHECK(book.bank("drums")->index.size() == 1);
}
static void testHashReferencedElsewhere() {
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.createBank("hits", "Hits"));
// Same-bank-only: the hash lives ONLY in drums → not referenced elsewhere (true
// last-reference; removing from drums would orphan the file).
CHECK(book.bank("drums")->index.add(sampleWith("solo", "solo-hash")) == AddResult::Added);
CHECK(!book.hashReferencedElsewhere("solo-hash", "drums"));
// Copied-to-two-banks: the hash lives in drums AND hits → referenced elsewhere from
// either vantage (removing from one leaves the other's reference intact).
CHECK(book.bank("drums")->index.add(sampleWith("dup-d", "dup-hash")) == AddResult::Added);
CHECK(book.bank("hits")->index.add(sampleWith("dup-h", "dup-hash")) == AddResult::Added);
CHECK(book.hashReferencedElsewhere("dup-hash", "drums")); // hits still holds it
CHECK(book.hashReferencedElsewhere("dup-hash", "hits")); // drums still holds it
// A hash present in NO bank is referenced nowhere.
CHECK(!book.hashReferencedElsewhere("absent-hash", "drums"));
// An empty hash never matches (mirrors findByHash) → reads as not-referenced-else,
// the safe confirm-eliciting direction for an unhashed sample.
CHECK(book.pool().index.add(sampleWith("nohash", "")) == AddResult::Added);
CHECK(!book.hashReferencedElsewhere("", "drums"));
}
static void testRemoveAllBanksLatentScope() {
// The latent all-banks seam (fork R-A): unsurfaced in the UI but live at the model
// level. Purges the id from EVERY bank that holds it in one act; fromBankId ignored.
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.createBank("hits", "Hits"));
CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);
CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);
CHECK(book.copySample("id-clap", kPoolBankId, "hits") == TransferResult::Copied);
// The id now lives in all three banks.
CHECK(book.pool().index.query("id-clap") != nullptr);
CHECK(book.bank("drums")->index.query("id-clap") != nullptr);
CHECK(book.bank("hits")->index.query("id-clap") != nullptr);
// AllBanks purge — fromBankId is ignored (pass a nonexistent bank to prove it).
CHECK(book.removeSample("id-clap", "ignored-bank", RemoveScope::AllBanks) ==
RemoveResult::Removed);
CHECK(book.pool().index.query("id-clap") == nullptr);
CHECK(book.bank("drums")->index.query("id-clap") == nullptr);
CHECK(book.bank("hits")->index.query("id-clap") == nullptr);
// A second all-banks purge of the now-absent id is an honest no-op rejection.
CHECK(book.removeSample("id-clap", "ignored-bank", RemoveScope::AllBanks) ==
RemoveResult::RejectedSampleAbsent);
}
int main() { int main() {
testPoolSeededAndDefaults(); testPoolSeededAndDefaults();
testPoolPrivileges(); testPoolPrivileges();
@@ -667,6 +785,12 @@ int main() {
testDeserializeCoalescesDuplicateFoldedNames(); testDeserializeCoalescesDuplicateFoldedNames();
testDeserializeCoalescesMultipleCollisions(); testDeserializeCoalescesMultipleCollisions();
testDeserializeNamedBankCollidingWithPoolIsDisambiguated(); testDeserializeNamedBankCollidingWithPoolIsDisambiguated();
testRemoveDropsTargetEntry();
testRemoveThisBankLeavesSameHashInAnotherBank();
testRemoveFromPoolAllowedContainerPrivilegesHold();
testRemoveRejectionsNoMutation();
testHashReferencedElsewhere();
testRemoveAllBanksLatentScope();
if (g_fail == 0) std::printf("All tests passed.\n"); if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
+171
View File
@@ -0,0 +1,171 @@
// Standalone tests for reasampler::OwnedFileManifest — no REAPER, no framework.
// The owned-file manifest seam (Phase B B-cap): a deduplicated, insertion-ordered
// set of project-relative files the capture path created, with JSON round-trip.
//
// Covers (brief-named): JSON round-trip, dedup of repeated adds, the empty manifest.
// Plus: the relative-paths-only invariant (reject empty / absolute), contains()
// semantics, insertion-order preservation, malformed-parse -> nullopt (the persist
// shell's warn+fallback hinges on it), and round-trip of paths with JSON metacharacters.
#include "../src/owned_manifest.h"
#include <cstdio>
#include <string>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- empty manifest ----------------------------------------------------------
static void testEmptyManifest() {
OwnedFileManifest m;
CHECK(m.empty());
CHECK(m.size() == 0);
CHECK(m.paths().empty());
CHECK(!m.contains("anything.wav"));
// An empty manifest serializes to a well-formed shape and round-trips to empty.
const std::string json = m.serialize();
auto back = OwnedFileManifest::deserialize(json);
CHECK(back.has_value());
CHECK(*back == m);
CHECK(back->empty());
}
// --- add / contains / order --------------------------------------------------
static void testAddAndContains() {
OwnedFileManifest m;
CHECK(m.add("reasampler_bank/a.wav") == ManifestAddResult::Added);
CHECK(m.add("reasampler_bank/b.wav") == ManifestAddResult::Added);
CHECK(m.size() == 2);
CHECK(m.contains("reasampler_bank/a.wav"));
CHECK(m.contains("reasampler_bank/b.wav"));
CHECK(!m.contains("reasampler_bank/c.wav"));
// Exact-string match — not a prefix / substring match.
CHECK(!m.contains("reasampler_bank/a"));
CHECK(!m.contains("a.wav"));
// Insertion order is preserved (deterministic ext-state).
CHECK(m.paths().size() == 2);
CHECK(m.paths()[0] == "reasampler_bank/a.wav");
CHECK(m.paths()[1] == "reasampler_bank/b.wav");
}
// --- dedup of repeated adds --------------------------------------------------
static void testDedupRepeatedAdds() {
OwnedFileManifest m;
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::Added);
// A repeat capture of an identical request must not double-record the file.
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
CHECK(m.size() == 1);
CHECK(m.paths().size() == 1);
}
// --- relative-paths-only invariant -------------------------------------------
static void testRejectsEmptyAndAbsolute() {
OwnedFileManifest m;
CHECK(m.add("") == ManifestAddResult::RejectedEmptyPath);
// Every absolute form bank_model rejects, the manifest rejects too.
CHECK(m.add("/abs/take.wav") == ManifestAddResult::RejectedAbsolutePath); // POSIX root
CHECK(m.add("\\\\host\\share\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* UNC */
CHECK(m.add("C:/bank/x.wav") == ManifestAddResult::RejectedAbsolutePath); // Win drive /
CHECK(m.add("C:\\bank\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* Win drive backslash */
CHECK(m.add("C:x.wav") == ManifestAddResult::RejectedAbsolutePath); // drive-relative
// A rejected add never mutates.
CHECK(m.empty());
CHECK(!m.contains("/abs/take.wav"));
}
// --- JSON round-trip ---------------------------------------------------------
static void testRoundTrip() {
OwnedFileManifest m;
m.add("reasampler_bank/one.wav");
m.add("reasampler_bank/two.wav");
m.add("reasampler_bank/three.wav");
const std::string json = m.serialize();
auto back = OwnedFileManifest::deserialize(json);
CHECK(back.has_value());
CHECK(*back == m);
// Order + membership survive.
CHECK(back->paths().size() == 3);
CHECK(back->paths()[0] == "reasampler_bank/one.wav");
CHECK(back->paths()[2] == "reasampler_bank/three.wav");
// serialize(deserialize(serialize(x))) is stable.
CHECK(back->serialize() == json);
}
// A path carrying JSON metacharacters must survive the escape/unescape round-trip.
static void testRoundTripEscaping() {
OwnedFileManifest m;
m.add("reasampler_bank/od\"d name.wav"); // embedded quote
m.add("reasampler_bank/back\\slash.wav"); // embedded backslash
m.add("reasampler_bank/tab\tafter.wav"); // control char
auto back = OwnedFileManifest::deserialize(m.serialize());
CHECK(back.has_value());
CHECK(*back == m);
CHECK(back->contains("reasampler_bank/od\"d name.wav"));
CHECK(back->contains("reasampler_bank/back\\slash.wav"));
CHECK(back->contains("reasampler_bank/tab\tafter.wav"));
}
// --- malformed / tolerant parse ----------------------------------------------
static void testMalformedParse() {
// The persist shell's warn+fallback hinges on nullopt for a corrupt blob.
CHECK(!OwnedFileManifest::deserialize("").has_value()); // empty string
CHECK(!OwnedFileManifest::deserialize("not json").has_value());
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[").has_value()); // unterminated array
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[1,2]}").has_value()); // non-string element
CHECK(!OwnedFileManifest::deserialize("{\"owned\":\"x\"}").has_value()); // wrong value type
// An explicit empty array parses to an empty manifest.
auto empty = OwnedFileManifest::deserialize("{\"owned\":[]}");
CHECK(empty.has_value());
CHECK(empty->empty());
// An unknown sibling key is tolerated (forward-compat) — the owned array still loads.
auto fwd = OwnedFileManifest::deserialize(
"{\"future\":{\"nested\":[1,2]},\"owned\":[\"reasampler_bank/x.wav\"]}");
CHECK(fwd.has_value());
CHECK(fwd->size() == 1);
CHECK(fwd->contains("reasampler_bank/x.wav"));
// A stored blob cannot smuggle a duplicate or absolute path past the load-time
// invariant re-assertion (deserialize routes each element through add()).
auto dupe = OwnedFileManifest::deserialize(
"{\"owned\":[\"reasampler_bank/x.wav\",\"reasampler_bank/x.wav\"]}");
CHECK(dupe.has_value());
CHECK(dupe->size() == 1);
auto absolute = OwnedFileManifest::deserialize(
"{\"owned\":[\"reasampler_bank/ok.wav\",\"/etc/evil.wav\"]}");
CHECK(absolute.has_value());
CHECK(absolute->size() == 1);
CHECK(absolute->contains("reasampler_bank/ok.wav"));
CHECK(!absolute->contains("/etc/evil.wav"));
}
int main() {
testEmptyManifest();
testAddAndContains();
testDedupRepeatedAdds();
testRejectsEmptyAndAbsolute();
testRoundTrip();
testRoundTripEscaping();
testMalformedParse();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}