Merge dev into phase-g: Phase Ε/Ρ and the 1.5.0 bump meet Phase Gamma's instrument work; 120/120 green
The per-directory CLAUDE.md count is re-derived at twenty-seven rather than carried from either side. The "Decouple the instrument reload from VST3 activation" TODO entry does not survive: Γ-W3-T1 landed it, and COMPLETED.md carries the discharge.
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
|
||||
The bindable action families routed through REAPER's `command_id`/`gaccel`/
|
||||
`hookcommand` contract (Design View toggle actions, bank actions, the prune
|
||||
action, and the shared registration plumbing/table), plus the three drag-out
|
||||
action, the bank-package import action, and the shared registration
|
||||
plumbing/table), plus the three drag-out
|
||||
outcome shells (OS hand-off, instrument drop, arrange drop), plus the
|
||||
extension-side ingest-through-the-bank shell. This is
|
||||
where user-facing REAPER actions and OS-level drag/drop live; the underlying
|
||||
@@ -16,8 +17,10 @@ is owned by other directories and only skinned here.
|
||||
- **Ingest is an extension act; the instrument is a read-only bank consumer.** Any
|
||||
instrument code path that captures, imports, inserts a timeline item, or writes
|
||||
back into the bank is a bug — the instrument reads and plays only.
|
||||
- **`arrange_drop_win` is the only timeline-placing shell in this directory**, and
|
||||
it places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s
|
||||
- **`arrange_drop_win` is the only timeline-placing shell IN THIS DIRECTORY** — the
|
||||
claim scopes here, not to the system: `shell/capture` holds two more
|
||||
(`RunInsertSelected` and `render_in_place`, the third verb). `arrange_drop_win`
|
||||
places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s
|
||||
capture/placement separation forbids a CAPTURE placing an item; a deliberate drop
|
||||
is placement on demand. No other module here may grow an `InsertMedia` call.
|
||||
- **Ingest NEVER inserts a timeline item.** Arrange capture→bank→assign reuses the
|
||||
@@ -37,9 +40,11 @@ is owned by other directories and only skinned here.
|
||||
## Modules
|
||||
|
||||
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
|
||||
- `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body.
|
||||
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
|
||||
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
|
||||
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
|
||||
- `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Builds and shows every message the import produces, but the ledger-refusal body itself is `core/package::ledgerRefusalMessage` — a pure fold this TU only supplies the channel-correct namespace to — so the wording is assertable without a DAW. `doImportBankPackage`/`doImportBankPackageFile` return the minted bank id on a landed import (empty otherwise) so a caller can focus it; the verb itself is promptless.
|
||||
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// package_export_action.cpp — see package_export_action.h for the contract this TU
|
||||
// preserves. main.cpp owns the API pointers; this TU gets them extern.
|
||||
|
||||
#include "shell/actions/package_export_action.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // projectDirOfRpp, sanitizeStem
|
||||
#include "shell/package/export_bank.h"
|
||||
#include "shell/package/package_pickers.h"
|
||||
#include "shell/persist/session.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_ShowMessageBox
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kUnsavedProjectMsg =
|
||||
"ReaSampler export: save the project first -- an unsaved project has no bank folder "
|
||||
"to read from.\n";
|
||||
|
||||
std::string currentProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return capture::projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
// 6 == YES; anything else cancels (SDK ~6544).
|
||||
bool confirmed(const std::string& msg, const char* title) {
|
||||
return ShowMessageBox(msg.c_str(), title, 4) == 6;
|
||||
}
|
||||
|
||||
std::string entryLine(const package::ExcludedEntry& e) {
|
||||
const char* why = e.reason == package::ExclusionReason::FileMissing ? "missing"
|
||||
: e.reason == package::ExclusionReason::FileUnreadable ? "unreadable"
|
||||
: "unusable index record";
|
||||
return " " + (e.displayName.empty() ? e.sampleId : e.displayName) + " [" + why +
|
||||
"] " + e.relativePath + "\n";
|
||||
}
|
||||
|
||||
// `maxLines` == 0 lists everything (the console record); a positive cap keeps a
|
||||
// confirm dialog readable on a bank with hundreds of absent files, prune's own
|
||||
// truncate-the-confirm-not-the-report discipline.
|
||||
std::string excludedManifest(const std::vector<package::ExcludedEntry>& excluded,
|
||||
std::size_t maxLines) {
|
||||
std::string msg;
|
||||
std::size_t shown = 0;
|
||||
for (const package::ExcludedEntry& e : excluded) {
|
||||
if (maxLines != 0 && shown == maxLines) {
|
||||
msg += " ... (" + std::to_string(excluded.size() - shown) +
|
||||
" more, listed in the console)\n";
|
||||
break;
|
||||
}
|
||||
msg += entryLine(e);
|
||||
++shown;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
void reportOutcome(const ExportOutcome& out, const std::string& destPath) {
|
||||
switch (out.status) {
|
||||
case ExportStatus::Written:
|
||||
ShowConsoleMsg(("ReaSampler export: wrote " + std::to_string(out.entriesWritten) +
|
||||
" entry/entries (" + std::to_string(out.bytesWritten) +
|
||||
" bytes) to " + destPath + "\n")
|
||||
.c_str());
|
||||
return;
|
||||
case ExportStatus::SourceReadFailed:
|
||||
ShowConsoleMsg(("ReaSampler export: ABORTED -- \"" + out.offendingName +
|
||||
"\" could not be read. Nothing was written.\n")
|
||||
.c_str());
|
||||
return;
|
||||
case ExportStatus::SourceChanged:
|
||||
ShowConsoleMsg(("ReaSampler export: ABORTED -- \"" + out.offendingName +
|
||||
"\" changed on disk while the package was being written. "
|
||||
"Nothing was written; run the export again.\n")
|
||||
.c_str());
|
||||
return;
|
||||
case ExportStatus::EncodeFailed:
|
||||
ShowConsoleMsg("ReaSampler export: ABORTED -- this bank could not be encoded "
|
||||
"as a package. Nothing was written.\n");
|
||||
return;
|
||||
// Both refusals are re-derived from a FRESH plan, so reaching them after the
|
||||
// survey means the bank changed under the export, not that the user declined.
|
||||
case ExportStatus::RefusedIncomplete:
|
||||
case ExportStatus::RefusedUnrepresentable:
|
||||
ShowConsoleMsg("ReaSampler export: ABORTED -- the bank changed between the "
|
||||
"report and the write. Nothing was written; run the export "
|
||||
"again.\n");
|
||||
return;
|
||||
case ExportStatus::RefusedDestinationExists:
|
||||
ShowConsoleMsg("ReaSampler export: cancelled -- nothing was written.\n");
|
||||
return;
|
||||
case ExportStatus::NoSuchBank:
|
||||
ShowConsoleMsg("ReaSampler export: that bank no longer exists.\n");
|
||||
return;
|
||||
case ExportStatus::NoProjectDir:
|
||||
ShowConsoleMsg(kUnsavedProjectMsg);
|
||||
return;
|
||||
case ExportStatus::WriteFailed:
|
||||
ShowConsoleMsg(("ReaSampler export: FAILED writing " + destPath +
|
||||
". No package was left behind; any file already at that path is "
|
||||
"untouched.\n")
|
||||
.c_str());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void doBankPackageExport(const ReaSamplerSession& session, const std::string& bankId) {
|
||||
const std::string projectDir = currentProjectDir();
|
||||
if (projectDir.empty()) {
|
||||
ShowConsoleMsg(kUnsavedProjectMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Report before acting, and before the picker opens: a refusal the user cannot
|
||||
// act on should not cost them a trip through a save dialog first.
|
||||
const ExportSurvey survey = surveyBankExport(session, projectDir, bankId);
|
||||
if (!survey.bankFound) {
|
||||
ShowConsoleMsg("ReaSampler export: no such bank.\n");
|
||||
return;
|
||||
}
|
||||
const std::string bankName = survey.plan.manifest.bankDisplayName;
|
||||
|
||||
bool allowIncomplete = false;
|
||||
if (survey.plan.verdict == package::ExportVerdict::Refused) {
|
||||
ShowConsoleMsg(("ReaSampler export: ABORTED -- \"" + bankName +
|
||||
"\" holds index record(s) a package cannot carry. Nothing was "
|
||||
"written.\n" +
|
||||
excludedManifest(survey.plan.excluded, 0))
|
||||
.c_str());
|
||||
return;
|
||||
}
|
||||
if (survey.plan.verdict == package::ExportVerdict::Incomplete) {
|
||||
const std::string headline =
|
||||
"ReaSampler export: \"" + bankName + "\" has " +
|
||||
std::to_string(survey.plan.excluded.size()) +
|
||||
" entry/entries whose file is missing or unreadable:\n";
|
||||
ShowConsoleMsg((headline + excludedManifest(survey.plan.excluded, 0)).c_str());
|
||||
if (!confirmed(headline + excludedManifest(survey.plan.excluded, 10) +
|
||||
"\nExport the " +
|
||||
std::to_string(survey.plan.manifest.entries.size()) +
|
||||
" present entry/entries anyway?",
|
||||
"ReaSampler: incomplete bank")) {
|
||||
ShowConsoleMsg("ReaSampler export: cancelled -- nothing was written.\n");
|
||||
return;
|
||||
}
|
||||
allowIncomplete = true;
|
||||
}
|
||||
|
||||
// The bank's own name, not the project's: the artifact is a bank, and a user
|
||||
// exporting three banks from one project needs three distinguishable files.
|
||||
const std::string suggested =
|
||||
projectDir + "/" + capture::sanitizeStem(bankName) + ".rsbank";
|
||||
std::string dest;
|
||||
bool appended = false;
|
||||
if (!pickPackageSavePath(suggested, dest, &appended)) return; // user cancelled the picker
|
||||
|
||||
ExportRequest req;
|
||||
req.projectDir = projectDir;
|
||||
req.bankId = bankId;
|
||||
req.destAbsPath = dest;
|
||||
req.exportTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
req.allowIncomplete = allowIncomplete;
|
||||
// The dialog's own overwrite confirm covered exactly this path when the picker
|
||||
// did not need to append `.rsbank` to reach it — asking again would be a second
|
||||
// prompt for the same consent. An appended path is one the dialog never saw, so
|
||||
// that case still falls through to exportBank's own refusal and the confirm below.
|
||||
req.allowOverwrite = !appended;
|
||||
|
||||
ExportOutcome out = exportBank(session, req);
|
||||
if (out.status == ExportStatus::RefusedDestinationExists) {
|
||||
if (!confirmed("A file already exists at:\n\n " + dest +
|
||||
"\n\nReplace it with this bank package?",
|
||||
"ReaSampler: replace package")) {
|
||||
ShowConsoleMsg("ReaSampler export: cancelled -- nothing was written.\n");
|
||||
return;
|
||||
}
|
||||
req.allowOverwrite = true;
|
||||
out = exportBank(session, req);
|
||||
}
|
||||
reportOutcome(out, dest);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
// package_export_action — the "export bank as package" action body: survey and
|
||||
// report first, confirm what is absent, pick a destination, write. Every prompt in
|
||||
// the flow lives here; shell/package/export_bank stays promptless. Registration and
|
||||
// dispatch for its FOREVER-STABLE id ride main.cpp's action table.
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// Exports one bank (the pool included — it is structurally a bank) to a .rsbank the
|
||||
// user picks. Read-only against the project: the session is const, so no ext-state
|
||||
// write, generation bump or undo point is reachable from here.
|
||||
void doBankPackageExport(const ReaSamplerSession& session, const std::string& bankId);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,188 @@
|
||||
// package_import_action.cpp — see package_import_action.h for the contract.
|
||||
// main.cpp owns the API pointers; this TU gets them extern.
|
||||
|
||||
#include "shell/actions/package_import_action.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "core/package/import_plan.h"
|
||||
#include "core/package/package_format.h"
|
||||
#include "core/version/app_version.h"
|
||||
#include "shell/package/import_bank.h"
|
||||
#include "shell/package/package_pickers.h"
|
||||
#include "shell/persist/session.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_ShowMessageBox
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kTitle = "ReaSampler: import bank package";
|
||||
|
||||
std::string quoted(const std::string& s) { return "\"" + s + "\""; }
|
||||
|
||||
// The message body itself is core/package::ledgerRefusalMessage — a pure
|
||||
// (LedgerRefusal, namespace) -> string fold, testable without a DAW. This TU only
|
||||
// supplies the channel-correct namespace and the console call.
|
||||
void reportLedgerRefusal(package::LedgerRefusal refusal) {
|
||||
ShowConsoleMsg(
|
||||
package::ledgerRefusalMessage(refusal, version::extStateNamespace()).c_str());
|
||||
}
|
||||
|
||||
// The refusal a user can act on names all three: what the package needs, what this
|
||||
// build reads, and which build wrote it. Any two of them leave them stuck.
|
||||
void reportTooNew(const ImportBankResult& r) {
|
||||
const bool knownWriter = !r.header.writerVersion.empty();
|
||||
const std::string writer =
|
||||
knownWriter ? "ReaSampler " + r.header.writerVersion : std::string("an unidentified build");
|
||||
std::string msg =
|
||||
"Cannot import this bank package.\n"
|
||||
"It was written by " + writer + " and needs package format " +
|
||||
std::to_string(r.header.minReaderVersion) + " or newer.\n"
|
||||
"This build (" + version::appVersion() + ") reads package format " +
|
||||
std::to_string(package::kPackageFormatVersion) + ".\n"
|
||||
"Nothing was imported. ";
|
||||
// "Install <writer> or newer" reads fine when writer is a real semver; it does not
|
||||
// when writer is the "unidentified build" filler, so that case gets its own sentence.
|
||||
msg += knownWriter ? "Install " + writer + " or newer and try again."
|
||||
: "Install a newer version of ReaSampler and try again.";
|
||||
ShowMessageBox(msg.c_str(), kTitle, 0);
|
||||
}
|
||||
|
||||
void reportSuccess(const ImportBankResult& r) {
|
||||
std::string detail = "ReaSampler import: imported " + std::to_string(r.landedCount) +
|
||||
" sample(s) into a new bank: " + quoted(r.bankDisplayName);
|
||||
if (r.bankNameAdjusted)
|
||||
detail += " (a bank named " + quoted(r.seedBankName) +
|
||||
" already exists in this project)";
|
||||
detail += ".\n";
|
||||
// Two distinct triggers (core/package::ImportPlan), reported as two counts rather
|
||||
// than folded into one ambiguous "already taken, or not spelled right" line.
|
||||
if (r.collisionRenameCount > 0) {
|
||||
detail += " " + std::to_string(r.collisionRenameCount) +
|
||||
" file(s) landed under a freshly minted name (the package's own name "
|
||||
"was already taken in the bank folder). An existing bank file is "
|
||||
"never overwritten.\n";
|
||||
}
|
||||
if (r.sanitizeRenameCount > 0) {
|
||||
detail += " " + std::to_string(r.sanitizeRenameCount) +
|
||||
" file(s) landed under a freshly minted name (not spelled the way "
|
||||
"this bank spells a file).\n";
|
||||
}
|
||||
if (r.collapsedCount > 0) {
|
||||
// "Already present" here can only mean a duplicate BY CONTENT inside this same
|
||||
// package (Ε-F2: import never consults another bank's hashes) — deliberately
|
||||
// reworded from bank-package.md:448's "already present" phrasing, which reads
|
||||
// as "already in your project" and is misleading in this direction.
|
||||
detail += " " + std::to_string(r.collapsedCount) +
|
||||
" sample(s) duplicated another entry in this same package by content "
|
||||
"and were written once.\n";
|
||||
}
|
||||
detail += "One undo removes the imported bank and its entries. It does NOT delete "
|
||||
"the imported files -- they stay in the bank folder, referenced by "
|
||||
"nothing, until a prune reclaims them.\n";
|
||||
ShowConsoleMsg(detail.c_str());
|
||||
|
||||
// The console carries the copyable detail; the box makes the outcome unmissable.
|
||||
const std::string summary = "Imported " + std::to_string(r.landedCount) +
|
||||
" sample(s) into a new bank: " +
|
||||
quoted(r.bankDisplayName) + ".";
|
||||
ShowMessageBox(summary.c_str(), kTitle, 0);
|
||||
}
|
||||
|
||||
void reportRollback(const RollbackResult& rollback, std::string& msg) {
|
||||
if (rollback.failedCount > 0) {
|
||||
msg += "\n" + std::to_string(rollback.failedCount) +
|
||||
" partly-imported file(s) could not be removed and are still in the bank "
|
||||
"folder. They are referenced by no bank; a prune will reclaim them.";
|
||||
}
|
||||
}
|
||||
|
||||
void report(const ImportBankResult& r) {
|
||||
switch (r.outcome) {
|
||||
case ImportOutcome::Landed:
|
||||
reportSuccess(r);
|
||||
return;
|
||||
case ImportOutcome::TooNew:
|
||||
reportTooNew(r);
|
||||
return;
|
||||
case ImportOutcome::NoProject:
|
||||
ShowMessageBox("Save the project before importing a bank package -- an "
|
||||
"unsaved project has no bank folder to import into.",
|
||||
kTitle, 0);
|
||||
return;
|
||||
case ImportOutcome::Unreadable:
|
||||
ShowMessageBox("That file could not be opened. Nothing was imported.",
|
||||
kTitle, 0);
|
||||
return;
|
||||
case ImportOutcome::Malformed:
|
||||
// Distinct from TooNew on purpose: the recoveries are opposite -- one is
|
||||
// "install a newer build", this one is "get an intact copy".
|
||||
//
|
||||
// bank-package.md:443 asks for a separate "This package is not well-formed"
|
||||
// message when an entry name carries a separator / ".." / an absolute form.
|
||||
// Not implemented: deserializeManifest returns one indistinguishable nullopt
|
||||
// for that and for ordinary corruption, so it folds into this generic box.
|
||||
// The binding spec (PLAN.md:2678) only requires Malformed != TooNew, which
|
||||
// this still satisfies -- that product-doc row is knowingly left open, not
|
||||
// silently missed.
|
||||
ShowMessageBox("This file is not a readable bank package (corrupt or "
|
||||
"truncated). Nothing was imported.",
|
||||
kTitle, 0);
|
||||
return;
|
||||
case ImportOutcome::IntegrityFailed: {
|
||||
std::string msg = "This bank package is damaged (entry " +
|
||||
quoted(r.failedEntryName) +
|
||||
" failed its integrity check). Nothing was imported.";
|
||||
ShowMessageBox(msg.c_str(), kTitle, 0);
|
||||
return;
|
||||
}
|
||||
case ImportOutcome::WriteFailed: {
|
||||
std::string msg = "Import failed and was rolled back. Nothing was added.";
|
||||
reportRollback(r.rollback, msg);
|
||||
ShowMessageBox(msg.c_str(), kTitle, 0);
|
||||
return;
|
||||
}
|
||||
case ImportOutcome::IndexRejected: {
|
||||
std::string msg = "The bank index rejected the import. Nothing was added.";
|
||||
reportRollback(r.rollback, msg);
|
||||
ShowMessageBox(msg.c_str(), kTitle, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FIRST, before the picker: making the user find and choose a file we have already
|
||||
// decided to refuse is the wrong order.
|
||||
bool ledgerPermits(ReaSamplerSession& session) {
|
||||
const package::LedgerRefusal refusal =
|
||||
package::importLedgerRefusal(session.ledgerStatus());
|
||||
if (refusal == package::LedgerRefusal::None) return true;
|
||||
reportLedgerRefusal(refusal);
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string doImportBankPackage(ReaSamplerSession& session) {
|
||||
if (!ledgerPermits(session)) return {};
|
||||
std::string path;
|
||||
if (!pickPackageForImport(path) || path.empty()) return {};
|
||||
const ImportBankResult r = importBankPackage(session, path);
|
||||
report(r);
|
||||
return r.outcome == ImportOutcome::Landed ? r.bankId : std::string{};
|
||||
}
|
||||
|
||||
std::string doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath) {
|
||||
if (packageAbsPath.empty()) return {};
|
||||
if (!ledgerPermits(session)) return {};
|
||||
const ImportBankResult r = importBankPackage(session, packageAbsPath);
|
||||
report(r);
|
||||
return r.outcome == ImportOutcome::Landed ? r.bankId : std::string{};
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
// package_import_action — the bindable/menu/drop skin over importBankPackage: the
|
||||
// ledger gate (which runs BEFORE the picker, so a refusal never costs the user a file
|
||||
// choice), the picker itself, and every message the import produces.
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// Gate, pick, import, report. The bound action and the panel's bank menu both call
|
||||
// this. Returns the minted bank id on a landed import, "" otherwise (cancelled,
|
||||
// refused, or failed) — a caller that wants to focus the new bank (mirroring
|
||||
// doCreateBank) checks the return rather than reaching back into ImportBankResult.
|
||||
std::string doImportBankPackage(ReaSamplerSession& session);
|
||||
|
||||
// Same, for a .rsbank already named by the user — the panel's file-drop route. The gate
|
||||
// still runs first; only the picker is skipped. Same return contract as doImportBankPackage.
|
||||
std::string doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -36,7 +36,14 @@ detail not covered there:
|
||||
- **`renderOffline` is the one seam both a fresh capture and a recipe replay
|
||||
cross**, which is why the refusal and both transient guards live there rather
|
||||
than in the action bodies — anything placed in `ResolveScopeSource` alone would
|
||||
miss `RunRecaptureFromSource` entirely.
|
||||
miss `RunRecaptureFromSource` entirely. The bounds mode is inside the backend
|
||||
that seam calls, for the same reason: a replay must hand its window over exactly
|
||||
the way a fresh capture does.
|
||||
- **The render window travels in the project's own TIME SELECTION**
|
||||
(`RENDER_BOUNDSFLAG=2`), so `capture` snapshots and restores that selection on every
|
||||
exit path like any other state it borrows. The custom-bounds field floors the window
|
||||
to the millisecond and must not come back — why, in
|
||||
`src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection`.
|
||||
- **FX-bypass guard ordering.** `scope_resolve` reads the M10 provenance-assembly
|
||||
inputs (track/item selection, FX-chain identity) BEFORE the FX-bypass guard
|
||||
neutralizes the in-scope chain — provenance must see the chain as it really is,
|
||||
@@ -45,24 +52,29 @@ detail not covered there:
|
||||
`capture_realtime_shell` cannot block REAPER's UI for the duration of a realtime
|
||||
record, so `begin`/`tick`/`abort` are async by construction and the temp-track +
|
||||
send recipe lives in the shell, not the pure core.
|
||||
- **`RunInsertSelected` is the one deliberate exception to capture-never-places**
|
||||
(see `capture_orchestrator` below) — every other capture entry point writes only
|
||||
a file + index entry.
|
||||
- **This directory hosts TWO placing paths, and neither is a capture placing
|
||||
itself.** `RunInsertSelected` (see `capture_orchestrator` below) places a *bank
|
||||
sample*, on demand, which is why it is the deliberate exception to
|
||||
capture-never-places. `render_in_place` places a render that never entered the
|
||||
bank — the third verb (arrange → arrange, root `CLAUDE.md` §The load-bearing
|
||||
principle). Every other entry point here writes only a file + index entry, and no
|
||||
capture may ever grow a place step.
|
||||
|
||||
## Modules
|
||||
|
||||
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
|
||||
- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). A refused render is MOVED to `<projectDir>/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open; the bank never INDEXES it either way — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened).
|
||||
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` — destination-dependent: on `CaptureDestination::Bank` (the default) the `Sample` is handed to `bank_model`; on `ProjectMedia` the file lands outside the bank and the caller (`render_in_place`) discards the returned `Sample`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
|
||||
- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). Refusal handling is destination-aware (`render_bounds_gate.h`): on `CaptureDestination::Bank`, a refused render is MOVED to `<projectDir>/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened); the bank never INDEXES it either way. On `CaptureDestination::ProjectMedia` the file is left exactly where the renderer wrote it — no move, no bank folder, no bank language in the message — because that render is the project's own media, not the tool's (`docs/product/render-in-place.md` "Where the file goes").
|
||||
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`.
|
||||
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
|
||||
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
|
||||
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
|
||||
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the capture family's deliberate exception to capture-never-places — see the Invariants section above for `render_in_place`, the directory's other placing path, which sits outside the capture family entirely).
|
||||
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing whose pass never got its persist through is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph.
|
||||
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
|
||||
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. It takes the lossless mono collapse on the staged BUFFER (`wav_codec::applyMonoCollapse`, the same predicate the two backends' file-side `collapseCapturedFileToMono` runs) before the hash and before the channel-count read, so the hash, the entry and the written file all come from one buffer — a dead-center render lands 1-channel like any other dead-center capture. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
|
||||
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
|
||||
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
|
||||
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
|
||||
- `capture_realtime_finalize` (`shell/capture`) — the file-side half of the realtime-record shell (Q-W3, T4-08): discovers the file REAPER actually recorded, moves it into the bank, runs the Auto-tail PCM decay-scan trim, and populates the finished `Sample`.
|
||||
- `render_in_place` (`shell/capture`) — the third verb, arrange → arrange: renders the selected track's output over the resolved range through `renderOffline` with `CaptureDestination::ProjectMedia`, then places the result on a brand-new sibling track at the render window's exact start (unsnapped — this placement IS the null test performed automatically), clones the source's colour and its name through the idempotent `captureTrackName`, and settles both tracks' modes in ONE `UNDO_STATE_ALL` block. Sibling nesting comes from the pure `core/capture/track_topology::siblingPlacement`. The source is tagged Design and the result track + its items are tagged `kArrangeModeId` **explicitly and unconditionally** — never `view.activeModeId()`, and never `untag()`, because the panel's auto-tag detector defers to a membership RECORD. It reads and writes NOTHING in the bank: no `session.bank()`, no `session.book()`, no `recordCreated`, no `bumpBankGeneration`; the `Sample` the backend returns is discarded and its `relativePath` is empty by construction. Traffic is one-way — capture may borrow this render, this placement may never be borrowed back into a capture.
|
||||
- `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** The mono collapse needs no change here: `insert.cpp` passes only a path to `InsertMedia`, and REAPER derives the item's channel count from the file itself — a 1-channel WAV yields a mono item for free.
|
||||
- `provenance_shell` — FX-chain identity queries via `TrackFX_*`/`TakeFX_*` APIs; feeds the pure `provenance` fingerprint builder. Stamps `Sample.provenance` on capture; ambiguous/mixed cases record nothing conservatively.
|
||||
- `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // deriveBankPaths
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout / hashWavContent
|
||||
#include "core/capture/wav_codec.h" // applyMonoCollapse / hashWavContent
|
||||
#include "core/model/bank_book.h"
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/model/resample_name.h" // the iteration-chain display name
|
||||
@@ -85,7 +85,12 @@ PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& pr
|
||||
"the staged render was unreadable", request.generation);
|
||||
return prep;
|
||||
}
|
||||
const WavLayout layout = parseWavLayout(prep.bytes);
|
||||
// Before the hash, so nothing measures a buffer it won't write.
|
||||
// `staged.collapsed` goes unread: a rebuild that fails to reparse reverts to the
|
||||
// staged bytes inside applyMonoCollapse itself, so there is nothing left here to react to.
|
||||
CollapsedWav staged = applyMonoCollapse(std::move(prep.bytes));
|
||||
prep.bytes = std::move(staged.bytes);
|
||||
const WavLayout layout = staged.layout;
|
||||
if (!layout.valid || layout.frameCount() == 0) {
|
||||
prep.settled = refuseBake(BakeStatus::StagedMissing,
|
||||
"the staged render is not a usable WAV", request.generation);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#pragma once
|
||||
// bake_landing — landing ONE bake request into the loaded project's bank: read and hash the
|
||||
// staged WAV, resolve replace-vs-add, write the file, index it, seed its lineage. The scan
|
||||
// pass that finds requests across the open tabs and answers them is `bake_land`; this is
|
||||
// what it calls per request, and it neither reads nor writes an ext-state key.
|
||||
// bake_landing — landing ONE bake request into the loaded project's bank: read the staged
|
||||
// WAV, collapse it losslessly to mono when it is dual-mono, hash it, resolve replace-vs-add,
|
||||
// write the file, index it, seed its lineage. The scan pass that finds requests across the
|
||||
// open tabs and answers them is `bake_land`; this is what it calls per request, and it
|
||||
// neither reads nor writes an ext-state key.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
// the one TU that defines the API pointers; here they are extern.
|
||||
//
|
||||
// Drives the RENDER_* project settings via GetSetProjectInfo/_String (source-
|
||||
// selection bits come from the pure render_settings mapping), snapshots and
|
||||
// restores every setting it changes, triggers a render, then populates a Sample.
|
||||
// selection bits come from the pure render_settings mapping) plus the project time
|
||||
// selection, which is where the render window itself travels; snapshots and restores
|
||||
// every one of them, triggers a render, then populates a Sample.
|
||||
// Source-agnostic: never reads the DAW selection itself, only the CaptureRequest
|
||||
// the caller resolved. RENDER_ADDTOPROJ&1 is cleared on every path — never
|
||||
// inserts into the arrange.
|
||||
@@ -38,6 +39,7 @@
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetProjectPathEx
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo_String
|
||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
||||
@@ -60,10 +62,6 @@ namespace {
|
||||
// project — why we set them all explicitly first.
|
||||
constexpr int kActionRenderUsingMostRecentSettings = 42230;
|
||||
|
||||
// RENDER_BOUNDSFLAG 0 = custom time bounds (we set STARTPOS/ENDPOS ourselves
|
||||
// for exact, unrounded bounds). SDK header ~3042.
|
||||
constexpr double kBoundsCustom = 0.0;
|
||||
|
||||
// RENDER_TAILFLAG/TAILMS/NORMALIZE/TRIMEND are driven from the pure
|
||||
// tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block below.
|
||||
|
||||
@@ -175,6 +173,26 @@ void restoreRenderSettings(const RenderSettingsSnapshot& s) {
|
||||
GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true);
|
||||
}
|
||||
|
||||
// The project time selection, snapshotted and restored around the render that carries
|
||||
// its window in it. Separate from ScopedRenderSettings because it is project state
|
||||
// rather than a RENDER_* setting.
|
||||
// GetSet_LoopTimeRange has no project parameter (SDK header ~2670) — it acts on the
|
||||
// active project, which is the one capture() already resolved and renders into.
|
||||
struct ScopedTimeSelection {
|
||||
double start = 0.0;
|
||||
double end = 0.0;
|
||||
|
||||
ScopedTimeSelection() { GetSet_LoopTimeRange(false, false, &start, &end, false); }
|
||||
~ScopedTimeSelection() {
|
||||
// Copies: the setter takes non-const pointers, so the snapshot must not be
|
||||
// what it writes through.
|
||||
double s = start, e = end;
|
||||
GetSet_LoopTimeRange(true, false, &s, &e, false);
|
||||
}
|
||||
ScopedTimeSelection(const ScopedTimeSelection&) = delete;
|
||||
ScopedTimeSelection& operator=(const ScopedTimeSelection&) = delete;
|
||||
};
|
||||
|
||||
// RAII wrapper: guarantees restore on every return path from capture().
|
||||
struct ScopedRenderSettings {
|
||||
RenderSettingsSnapshot snap;
|
||||
@@ -413,16 +431,50 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// Compute the tag ONCE — calling makeUniqueTag() twice would let the file
|
||||
// stem and Sample.id diverge (the counter advances per call).
|
||||
const std::string uniqueTag = makeUniqueTag("");
|
||||
const BankPaths paths =
|
||||
deriveBankPaths(projectDir, request.baseName, uniqueTag);
|
||||
|
||||
// Destination resolves HERE, after the save gate above, so an unsaved project is
|
||||
// still prompted before any path arithmetic runs. ProjectMedia lands outside the
|
||||
// bank folder and leaves relativePath empty — the Sample it produces indexes
|
||||
// nothing (docs/product/render-in-place.md §"Where the file goes").
|
||||
RenderPaths paths;
|
||||
std::string relativePath;
|
||||
if (request.destination == CaptureDestination::Bank) {
|
||||
const BankPaths bank =
|
||||
deriveBankPaths(projectDir, request.baseName, uniqueTag);
|
||||
paths = RenderPaths{bank.absoluteDir, bank.fileName, bank.fileStem};
|
||||
relativePath = bank.relativePath;
|
||||
} else {
|
||||
std::vector<char> recDir(4096, '\0');
|
||||
GetProjectPathEx(proj, recDir.data(), static_cast<int>(recDir.size()));
|
||||
paths = deriveRenderPaths(std::string(recDir.data()), request.baseName,
|
||||
uniqueTag);
|
||||
if (paths.absoluteDir.empty()) {
|
||||
result.status = CaptureStatus::NoProject;
|
||||
result.message = "Could not resolve the project's recording path.";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
ScopedRenderSettings guard(proj);
|
||||
ScopedTimeSelection tsGuard;
|
||||
|
||||
// Custom time bounds so the rendered length equals the requested range with
|
||||
// NO rounding and NO added silence (unless a tail was explicitly requested).
|
||||
GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true);
|
||||
// The window travels in the project's own time selection, which is what makes the
|
||||
// rendered length the requested range with NO rounding and NO added silence (unless
|
||||
// a tail was explicitly requested) — the custom-bounds field floors it to the
|
||||
// millisecond (render_settings.h's kRenderBoundsTimeSelection).
|
||||
//
|
||||
// RENDER_STARTPOS/ENDPOS are written anyway, to the same window. The header
|
||||
// (~3045-3046) documents them as mode-0-only, so on mode 2 this is a cheap,
|
||||
// fully-restored (ScopedRenderSettings) defensive write against that
|
||||
// documentation being an incomplete account of what the renderer reads.
|
||||
GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG",
|
||||
static_cast<double>(kRenderBoundsTimeSelection), true);
|
||||
GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
|
||||
GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
|
||||
{
|
||||
double s = request.startSeconds, e = request.endSeconds;
|
||||
GetSet_LoopTimeRange(true, false, &s, &e, false);
|
||||
}
|
||||
|
||||
// TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact
|
||||
// bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end
|
||||
@@ -453,6 +505,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
GetSetProjectInfo(proj, "RENDER_SRATE",
|
||||
static_cast<double>(effectiveSampleRate), true);
|
||||
}
|
||||
|
||||
GetSetProjectInfo(proj, "RENDER_CHANNELS",
|
||||
static_cast<double>(request.channelCount), true);
|
||||
|
||||
@@ -502,7 +555,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// legitimately produce zero (docs/TODO.md "0-byte render" entry: before this check,
|
||||
// Auto/Manual landed an empty file as CaptureStatus::Ok with channelCount == 0).
|
||||
const BoundsVerdict emptyVerdict =
|
||||
checkRenderedFileNotEmpty(expectedPath, projectDir);
|
||||
checkRenderedFileNotEmpty(expectedPath, projectDir, request.destination);
|
||||
if (emptyVerdict.refused) {
|
||||
result.status = CaptureStatus::BoundsMismatch;
|
||||
result.message = emptyVerdict.message;
|
||||
@@ -538,7 +591,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// yield a different value and desync Sample.id from the file name.
|
||||
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
|
||||
s.displayName = request.label();
|
||||
s.relativePath = paths.relativePath; // project-relative (invariant)
|
||||
s.relativePath = relativePath; // project-relative (invariant); empty off the bank
|
||||
s.sourceMode = request.sourceMode;
|
||||
s.sourceRange.startSeconds = request.startSeconds;
|
||||
s.sourceRange.endSeconds = request.endSeconds;
|
||||
@@ -553,12 +606,14 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// single played note, so no root note is derivable; loop points are set
|
||||
// later by an explicit user action.
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = s;
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = s;
|
||||
result.absolutePath = expectedPath;
|
||||
result.message = "Captured [" +
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] -> " +
|
||||
paths.relativePath + monoCollapseSuffix(collapseOutcome);
|
||||
(relativePath.empty() ? expectedPath : relativePath) +
|
||||
monoCollapseSuffix(collapseOutcome);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,14 @@ enum class WavBitDepth {
|
||||
Float32,
|
||||
};
|
||||
|
||||
// Where the render lands. TWO VALUES, never a caller-supplied path string: the
|
||||
// backend resolves each to a directory itself, which is what makes "write into the
|
||||
// bank folder" inexpressible from the ProjectMedia side and vice versa.
|
||||
enum class CaptureDestination {
|
||||
Bank, // <projectDir>/reasampler_bank — every capture path
|
||||
ProjectMedia, // the project's recording path — the render-in-place verb only
|
||||
};
|
||||
|
||||
// One capture, independent of source mode.
|
||||
struct CaptureRequest {
|
||||
SourceMode sourceMode = SourceMode::MasterMix;
|
||||
@@ -75,6 +83,9 @@ struct CaptureRequest {
|
||||
// The one home for that fallback rule; both backends populate Sample::displayName
|
||||
// from here rather than each spelling the condition out.
|
||||
std::string label() const { return displayName.empty() ? baseName : displayName; }
|
||||
|
||||
// Default Bank: every existing entry point renders into the bank untouched.
|
||||
CaptureDestination destination = CaptureDestination::Bank;
|
||||
};
|
||||
|
||||
// Every failure is an explicit code, never a thrown exception across the REAPER boundary.
|
||||
@@ -95,6 +106,11 @@ struct CaptureResult {
|
||||
CaptureStatus status = CaptureStatus::RenderFailed;
|
||||
Sample sample; // valid only when status == Ok
|
||||
std::string message; // human-readable detail for the console log
|
||||
|
||||
// The file the render actually landed, absolute — the only handle a caller that
|
||||
// banks nothing has on its own output (sample.relativePath is empty on the
|
||||
// ProjectMedia destination). Set on the Ok path only.
|
||||
std::string absolutePath;
|
||||
};
|
||||
|
||||
// Deterministic offline-render backend: master mix / time selection / selected
|
||||
|
||||
@@ -231,7 +231,8 @@ CaptureResult renderOffline(CaptureScope scope,
|
||||
// caller can report success/failure. Load-bearing principle holds: writes a file +
|
||||
// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the
|
||||
// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard),
|
||||
// and the backend restores every RENDER_* setting.
|
||||
// and the backend restores every RENDER_* setting it changed plus the project time
|
||||
// selection it borrowed to carry the render window.
|
||||
//
|
||||
// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id
|
||||
// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8
|
||||
|
||||
@@ -369,6 +369,11 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
|
||||
// recordWindowEnd extends past the range end for a tail mode so the
|
||||
// transport captures the decay; cursor + time selection are restored by restore().
|
||||
// `[verify — DAW]` whether rs/re come back changed on this isSet=true call: the SDK
|
||||
// header names both `double*` but documents no read-back semantics for either
|
||||
// direction, and nothing here reads rs/re again after the call to notice. Lower
|
||||
// stakes than the offline RENDER_* store: completion is driven by the play cursor
|
||||
// reaching the range end (tick(), below), not by re-reading this pair.
|
||||
double rs = request.startSeconds, re = st->recordWindowEnd_;
|
||||
GetSet_LoopTimeRange(true, false, &rs, &re, false);
|
||||
SetEditCurPos(request.startSeconds, false, false);
|
||||
|
||||
@@ -45,6 +45,25 @@ std::string retainRefusedRender(const std::string& renderedPath,
|
||||
renderedPath + ", indexed by nothing. Delete it when done.";
|
||||
}
|
||||
|
||||
// ProjectMedia is the project's own media, never the bank's (docs/product/render-in-place.md
|
||||
// "Where the file goes") -- a refusal takes no custody of it. No move, no bank folder, no
|
||||
// mention of a bank the render was never headed for.
|
||||
std::string leaveRefusedRenderInPlace(const std::string& renderedPath) {
|
||||
return " The render was left where it was written, at " + renderedPath +
|
||||
" -- delete it when done.";
|
||||
}
|
||||
|
||||
// Dispatches the refusal's file-handling sentence by destination, so both verdict
|
||||
// functions below state one true thing about the file rather than the bank sentence
|
||||
// on every destination.
|
||||
std::string refusalOutcome(const std::string& renderedPath,
|
||||
const std::string& projectDir,
|
||||
CaptureDestination destination) {
|
||||
if (destination == CaptureDestination::ProjectMedia)
|
||||
return leaveRefusedRenderInPlace(renderedPath);
|
||||
return retainRefusedRender(renderedPath, projectDir);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string refusedRenderFolder(const std::string& projectDir) {
|
||||
@@ -52,7 +71,8 @@ std::string refusedRenderFolder(const std::string& projectDir) {
|
||||
}
|
||||
|
||||
BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
|
||||
const std::string& projectDir) {
|
||||
const std::string& projectDir,
|
||||
CaptureDestination destination) {
|
||||
BoundsVerdict v;
|
||||
std::error_code ec;
|
||||
const std::uintmax_t size = std::filesystem::file_size(renderedPath, ec);
|
||||
@@ -61,7 +81,7 @@ BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
|
||||
v.refused = true;
|
||||
v.message = "Render at " + renderedPath + " is 0 bytes -- REAPER produced an empty "
|
||||
"file, so there is nothing to check the requested range against." +
|
||||
retainRefusedRender(renderedPath, projectDir);
|
||||
refusalOutcome(renderedPath, projectDir, destination);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -89,7 +109,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
||||
"not be read (locked, missing, or a permissions error), its WAV "
|
||||
"header did not parse, or it declared no sample rate -- so the "
|
||||
"frames it holds were never checked against the requested range." +
|
||||
source + retainRefusedRender(renderedPath, projectDir);
|
||||
source + refusalOutcome(renderedPath, projectDir, request.destination);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -99,6 +119,21 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
||||
const long long actualFrames = static_cast<long long>(layout.frameCount());
|
||||
if (renderHonoredBounds(expectedFrames, actualFrames)) return v;
|
||||
|
||||
// Says whether this shortfall has the known shape: the END alone floored to the
|
||||
// millisecond, which is what REAPER's render was measured doing. Checked against the
|
||||
// END only -- a refusal whose START is also off-grid and independently floored would
|
||||
// not match this shape, and this note's silence on that refusal is this check not
|
||||
// covering it. Excludes 0, which every sub-millisecond window (a legitimate day-one
|
||||
// capture) also floors to, and which would otherwise match a render that produced
|
||||
// nothing.
|
||||
const long long msFlooredEnd =
|
||||
msFlooredEndFrameCount(request.startSeconds, request.endSeconds, rate);
|
||||
const std::string msNote =
|
||||
(msFlooredEnd > 0 && actualFrames == msFlooredEnd)
|
||||
? " Those are exactly the frames this window holds with its end floored to"
|
||||
" the millisecond -- the shape REAPER's render was measured producing."
|
||||
: std::string();
|
||||
|
||||
v.refused = true;
|
||||
v.message = "Render produced " + std::to_string(actualFrames) +
|
||||
" frames but the requested range is " + std::to_string(expectedFrames) +
|
||||
@@ -108,7 +143,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
||||
std::to_string(request.endSeconds) + "s) -> frame indices [" +
|
||||
std::to_string(std::llround(request.startSeconds * rate)) + ", " +
|
||||
std::to_string(std::llround(request.endSeconds * rate)) + ")." +
|
||||
retainRefusedRender(renderedPath, projectDir);
|
||||
msNote + refusalOutcome(renderedPath, projectDir, request.destination);
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// A refused render is MOVED out of the bank, not deleted: while the root cause of a
|
||||
// short render is open (docs/TODO.md), the frames it did print are the evidence — and
|
||||
// nothing may index a file the bank never accepted.
|
||||
// On the Bank destination, a refused render is MOVED out of the bank, not deleted: while
|
||||
// the root cause of a short render is open (docs/TODO.md), the frames it did print are
|
||||
// the evidence — and nothing may index a file the bank never accepted. On ProjectMedia,
|
||||
// the render is the project's own media (docs/product/render-in-place.md "Where the file
|
||||
// goes"), so a refusal leaves it exactly where it was written — no move, no bank folder.
|
||||
struct BoundsVerdict {
|
||||
bool refused = false;
|
||||
std::string message; // console text; meaningful only when refused
|
||||
@@ -22,7 +24,8 @@ struct BoundsVerdict {
|
||||
// frame count is not the window's (render_window::renderHonoredBounds owns the
|
||||
// tolerance and its limits), or the file cannot be measured at all — an unmeasured
|
||||
// render is not a verified one. TailMode::Auto/Manual add frames by design and are
|
||||
// never judged here. `projectDir` is where a refused render is parked.
|
||||
// never judged here. `projectDir` and `request.destination` together decide where a
|
||||
// refused render is parked.
|
||||
BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
||||
const std::string& projectDir,
|
||||
const CaptureRequest& request);
|
||||
@@ -31,11 +34,12 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
||||
// never legitimately produce zero), independent of and ahead of the TailMode::None-only
|
||||
// gate above, which does not run on Auto/Manual at all.
|
||||
BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
|
||||
const std::string& projectDir);
|
||||
const std::string& projectDir,
|
||||
CaptureDestination destination);
|
||||
|
||||
// Where a refused render is retained -- exposed so a multi-unit caller (batch capture)
|
||||
// can name the folder once without duplicating the subfolder name `checkRenderedBounds`
|
||||
// and `checkRenderedFileNotEmpty` already use internally.
|
||||
// Where a refused Bank-destination render is retained -- exposed so a multi-unit caller
|
||||
// (batch capture, Bank-only) can name the folder once without duplicating the subfolder
|
||||
// name `checkRenderedBounds` and `checkRenderedFileNotEmpty` already use internally.
|
||||
std::string refusedRenderFolder(const std::string& projectDir);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// render_in_place.cpp — see render_in_place.h.
|
||||
//
|
||||
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the
|
||||
// one TU that defines the API pointers; here they are extern.
|
||||
|
||||
#include "shell/capture/render_in_place.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_name.h" // captureTrackName
|
||||
#include "core/capture/insert_plan.h" // computeInsertMode / InsertOptions
|
||||
#include "core/capture/render_settings.h" // CaptureScope
|
||||
#include "core/capture/tail_control.h" // TailSetting
|
||||
#include "core/capture/track_topology.h" // siblingPlacement
|
||||
#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId
|
||||
#include "shell/capture/capture.h"
|
||||
#include "shell/capture/capture_orchestrator.h" // renderOffline
|
||||
#include "shell/capture/item_read.h" // itemGuid
|
||||
#include "shell/capture/scope_resolve.h" // ResolveScopeSource / trackName
|
||||
#include "shell/capture/track_guid.h" // guidString
|
||||
#include "shell/panel/panel_input.h" // bankPanelTailSetting
|
||||
#include "shell/persist/session.h"
|
||||
#include "shell/view/view.h" // applyMode / mintManagedLanes
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetCursorPosition
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_GetProjectPathEx
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetTrackColor
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_InsertMedia
|
||||
#define REAPERAPI_WANT_InsertTrackInProject
|
||||
#define REAPERAPI_WANT_SetEditCurPos
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetOnlyTrackSelected
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_TrackList_AdjustWindows
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
void refuse(const std::string& why) {
|
||||
ShowConsoleMsg(("ReaSampler render in place: " + why + "\n").c_str());
|
||||
}
|
||||
|
||||
// Every track's I_FOLDERDEPTH in track order — the flat delta list the pure
|
||||
// sibling arithmetic reads.
|
||||
std::vector<int> folderDepths(ReaProject* proj, int count) {
|
||||
std::vector<int> depths;
|
||||
depths.reserve(static_cast<std::size_t>(count < 0 ? 0 : count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
MediaTrack* tr = GetTrack(proj, i);
|
||||
depths.push_back(tr ? static_cast<int>(
|
||||
GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"))
|
||||
: 0);
|
||||
}
|
||||
return depths;
|
||||
}
|
||||
|
||||
int indexOfTrack(ReaProject* proj, int count, MediaTrack* wanted) {
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (GetTrack(proj, i) == wanted) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
void setTrackName(MediaTrack* tr, const std::string& name) {
|
||||
// GetSetMediaTrackInfo_String takes a writable buffer even on the set path.
|
||||
std::vector<char> buf(name.begin(), name.end());
|
||||
buf.push_back('\0');
|
||||
GetSetMediaTrackInfo_String(tr, "P_NAME", buf.data(), true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RunRenderTrackInPlace(ReaSamplerSession& session) {
|
||||
ResolvedSource src;
|
||||
std::string why;
|
||||
if (!ResolveScopeSource(CaptureScope::Track, src, why)) { refuse(why); return; }
|
||||
if (src.sourceTracks.empty() || !src.sourceTracks.front()) {
|
||||
refuse("no source track resolved"); return;
|
||||
}
|
||||
|
||||
const TailSetting tail = bankPanelTailSetting();
|
||||
const CaptureName name = captureNameFor(src.trackNames, /*ordinal=*/0, "capture");
|
||||
|
||||
CaptureRequest req;
|
||||
req.sourceMode = SourceMode::SelectedTracks;
|
||||
req.startSeconds = src.startSeconds; // exact bounds — no rounding
|
||||
req.endSeconds = src.endSeconds;
|
||||
req.wetDry = 1.0;
|
||||
req.tailMode = tail.mode;
|
||||
req.tailMs = tail.manualMs;
|
||||
req.sampleRate = 0; // follow project rate
|
||||
req.channelCount = 2;
|
||||
req.bitDepth = WavBitDepth::Float32;
|
||||
req.baseName = name.stemBase;
|
||||
req.displayName = name.label;
|
||||
req.destination = CaptureDestination::ProjectMedia;
|
||||
// trackGuids left empty: they exist to stamp provenance onto a Sample this verb
|
||||
// discards. A multi-track selection is refused inside renderOffline, keyed on the
|
||||
// render source, so there is no check to add here.
|
||||
|
||||
const CaptureResult res = renderOffline(CaptureScope::Track, src.sourceTracks, req);
|
||||
if (res.status != CaptureStatus::Ok) { refuse(res.message); return; }
|
||||
|
||||
MediaTrack* source = src.sourceTracks.front();
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
|
||||
const int trackCount = CountTracks(proj);
|
||||
const int srcIndex = indexOfTrack(proj, trackCount, source);
|
||||
if (srcIndex < 0) {
|
||||
refuse("the source track is no longer in the project; the render landed at " +
|
||||
res.absolutePath + " but was not placed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const SiblingPlacement place =
|
||||
siblingPlacement(folderDepths(proj, trackCount), srcIndex);
|
||||
|
||||
// Read ONCE, and only to reapply the mode / decide whether the result landed
|
||||
// visible — never to choose a tag. Both tags below are absolute.
|
||||
const std::string activeMode = session.view().activeModeId();
|
||||
|
||||
Undo_BeginBlock2(nullptr);
|
||||
|
||||
// flags = 0, never 1: flags&1 adds default envelopes/FX, and a default chain
|
||||
// would process a render that already carries the source's FX a second time.
|
||||
InsertTrackInProject(proj, place.insertIndex, /*flags=*/0);
|
||||
MediaTrack* fresh = GetTrack(proj, place.insertIndex);
|
||||
if (!fresh) {
|
||||
// InsertTrackInProject already mutated the project by this point, so the
|
||||
// "no ext-state write -> discard" idiom does not apply here — a discard would
|
||||
// leave the orphaned track un-undoable.
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: render in place (failed to create result track)",
|
||||
-1);
|
||||
refuse("could not create the result track; the render landed at " +
|
||||
res.absolutePath + " but was not placed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Both writes or none — one alone lands the new track at the wrong nesting level,
|
||||
// which is audible in both directions (see siblingPlacement).
|
||||
if (place.precedingIndex >= 0) {
|
||||
if (MediaTrack* preceding = GetTrack(proj, place.precedingIndex))
|
||||
SetMediaTrackInfo_Value(preceding, "I_FOLDERDEPTH",
|
||||
static_cast<double>(place.precedingDepth));
|
||||
}
|
||||
SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH",
|
||||
static_cast<double>(place.newDepth));
|
||||
|
||||
// GetTrackColor returns the colour already OR'd with 0x1000000 and 0 for "no
|
||||
// colour set", which I_CUSTOMCOLOR reads as unused — so one line clones a colour
|
||||
// and the absence of one, with no branch.
|
||||
SetMediaTrackInfo_Value(fresh, "I_CUSTOMCOLOR",
|
||||
static_cast<double>(GetTrackColor(source)));
|
||||
const std::string freshName = captureTrackName(trackName(source));
|
||||
setTrackName(fresh, freshName);
|
||||
|
||||
// After every attribute write, per the SDK header's manual-panel-update caveat.
|
||||
TrackList_AdjustWindows(false);
|
||||
|
||||
// Unsnapped and unrounded, deliberately: this placement IS the null test performed
|
||||
// automatically, so snapping it to the grid would move the audio off the position
|
||||
// it was rendered from. InsertOptions{} defaults give native length and no conform.
|
||||
const double cursorPos = GetCursorPosition();
|
||||
SetOnlyTrackSelected(fresh);
|
||||
SetEditCurPos(src.startSeconds, false, false);
|
||||
// InsertMedia's int return isn't SDK-documented; treated conservatively as
|
||||
// 0 = failure, matching performArrangeDrop — an empty result track would
|
||||
// otherwise be a silent no-op, which is exactly what this verb must not produce.
|
||||
const bool placed =
|
||||
InsertMedia(res.absolutePath.c_str(), computeInsertMode(InsertOptions{})) != 0;
|
||||
SetEditCurPos(cursorPos, false, false);
|
||||
// The new track is left selected, alone — in the headline case the source is being
|
||||
// parked out of sight in the same gesture, so restoring the selection would leave
|
||||
// the user selecting an invisible track.
|
||||
|
||||
// Absolute, not mode-following: the source parks on the bench, the result is an
|
||||
// Arrange member whatever mode was active. Explicit records rather than untag(),
|
||||
// because the record is what the panel's auto-tag detector defers to.
|
||||
MembershipIndex& membership = session.view().membership();
|
||||
membership.tag(guidString(source), kDesignModeId);
|
||||
membership.tag(guidString(fresh), kArrangeModeId);
|
||||
|
||||
// The track is brand new, so its items are exactly the ones just placed. An
|
||||
// untagged item would be handed to the detector, which tags to the active mode.
|
||||
const int itemCount = CountTrackMediaItems(fresh);
|
||||
for (int i = 0; i < itemCount; ++i) {
|
||||
if (MediaItem* it = GetTrackMediaItem(fresh, i)) {
|
||||
const std::string ig = itemGuid(it);
|
||||
if (!ig.empty()) membership.tag(ig, kArrangeModeId);
|
||||
}
|
||||
}
|
||||
|
||||
mintManagedLanes(session.view(), nullptr);
|
||||
applyMode(session.view(), activeMode, nullptr); // a reapply, never a switch
|
||||
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: render selected track to a new track", -1);
|
||||
|
||||
// Persist outside the block. The offline render's own save gate already forced a
|
||||
// saved project, so the Save-As-guarded persist the Design View actions need
|
||||
// cannot have anything to prompt for here.
|
||||
session.saveToActiveProject();
|
||||
|
||||
if (!placed) {
|
||||
refuse("the render landed at " + res.absolutePath +
|
||||
" but REAPER refused to place it — the new track is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Silent on success — the new track is the feedback. Except when it is not: fired
|
||||
// outside Arrange the result track is parked, so a silent success would be
|
||||
// indistinguishable from a no-op.
|
||||
if (activeMode != kArrangeModeId) {
|
||||
ShowConsoleMsg(("ReaSampler render in place: created \"" + freshName +
|
||||
"\" in Arrange (switch to Arrange to see it).\n")
|
||||
.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
// render_in_place — the third verb: render the selected track's output over the
|
||||
// current range to the project's recording path, place it on a new sibling track at
|
||||
// the exact position it was rendered from, and move the source to Design. The bank
|
||||
// is never read, written, or notified (docs/product/render-in-place.md).
|
||||
|
||||
namespace reasampler {
|
||||
class ReaSamplerSession;
|
||||
}
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Resolves, renders, creates + dresses the sibling track, places the file, and
|
||||
// settles both tracks' modes in one undo block. Silent on success (the new track is
|
||||
// the feedback) except when the result lands invisible; ShowConsoleMsg on refusal.
|
||||
void RunRenderTrackInPlace(ReaSamplerSession& session);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,132 @@
|
||||
# src/shell/package — package filesystem + dialog seam
|
||||
|
||||
## Scope
|
||||
|
||||
The filesystem and dialog acts behind bank-package export/import: streaming package
|
||||
file I/O plus the file-status and exclusive-create acts (`package_io`), the
|
||||
UTF-8 path conversion every one of them goes through (`package_path`), the landed-file
|
||||
journal and its rollback delete (`package_rollback`), and the two file pickers
|
||||
(`package_pickers`). Those are bytes-only — the package format (magic, manifest, entry
|
||||
layout) is `core/package`'s business. Beside them sit both promptless verbs:
|
||||
`export_bank` whole, and the import split so its decisions stay testable —
|
||||
`import_landing` (REAPER-free) decides and writes, while `import_bank` owns the only
|
||||
REAPER project state this directory touches (the ext-state persist, the undo block,
|
||||
the generation bump). The export direction touches none of it: an export writes no ext
|
||||
state, opens no undo point and never bumps the generation, and what prompting it needs
|
||||
belongs to its skin (`shell/actions/package_export_action`), not this seam.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Paths cross this seam as UTF-8 narrow strings and are converted through
|
||||
`utf8Path()` before ANY filesystem call.** This is not decoration: on Windows
|
||||
`std::filesystem` decodes a narrow path through the runtime ANSI code page (measured
|
||||
`GetACP() == 1252`), so a bare `fs::path(std::string)` turns `café.rsbank` into
|
||||
`café.rsbank` or fails to open it. Every path a verb hands in or gets back —
|
||||
including `listFolderFileNames`' results, which go through `pathToUtf8()` and never
|
||||
`string()` — is UTF-8. `core/util/file_bytes` has the un-converted shape, which is
|
||||
why `readFilePayload` reads through this module's own `PackageFileReader` instead.
|
||||
- **Atomic package write, to the limit of a rename.** A package accumulates in a
|
||||
`.rsbanktmp` sibling in the destination directory and reaches the destination only
|
||||
through `commit()`'s rename (the mono-collapse temp+rename precedent). A failed,
|
||||
aborted, or abandoned write leaves the destination absent or holding its prior
|
||||
contents. This is process-crash atomic, NOT power-loss atomic: `commit()` flushes
|
||||
and closes but does not `fsync`/`FlushFileBuffers`, so a power cut can still leave a
|
||||
renamed-but-unflushed file. Deliberate — an fsync over a whole sample bank is a real
|
||||
stall, and the failure this design targets is a refused or interrupted export.
|
||||
- **Streaming, both ways — at most ONE entry's payload in memory.** Writes append
|
||||
one payload at a time; reads seek and materialize one range at a time. The claim
|
||||
is structural, not aspirational: every payload crosses this seam as a move-only
|
||||
`PayloadBuffer`, and `PayloadBuffer::alive()` is the seam counter the tests
|
||||
assert against. There is no read-whole-package or write-whole-package entry
|
||||
point; do not add one.
|
||||
- **An empty `PayloadBuffer` is a failure signal, never an entry.** It is the seam's
|
||||
one "nothing to work with" branch, so both `PackageFileWriter::appendPayload` and
|
||||
`writeFileExclusive` refuse it — appending it would let a verb commit framing that
|
||||
claims bytes nobody wrote. `appendRaw(ptr, 0)` stays tolerated: framing has
|
||||
legitimate zero-length edges.
|
||||
- **No overwrite of a bank-folder file, ever — and the create is the check.**
|
||||
`writeLandedFile` lands through `writeFileExclusive` (`O_EXCL` / `_O_EXCL`), so the
|
||||
refusal of an occupied path is one atomic act rather than an `exists()` a concurrent
|
||||
writer could win the race against. Collision handling (auto-rename) remains the
|
||||
import plan's job upstream. The package writer itself DOES replace an existing
|
||||
destination — the export save dialog's own overwrite confirm is the consent — and
|
||||
that asymmetry is deliberate. **Closed, both halves.** `pickPackageSavePath`'s own
|
||||
`.rsbank` re-append (see its Gotcha below) can turn a confirmed path `X` into a write
|
||||
target `X.rsbank` that the dialog never asked about, so `exportBank` re-checks
|
||||
`fileStatus()` on the path actually handed to `PackageFileWriter` — after any
|
||||
extension append — and refuses `RefusedDestinationExists` until the caller sets
|
||||
`allowOverwrite`. The caller does not always re-prompt to get there:
|
||||
`pickPackageSavePath` reports whether it appended (`outAppended`), and
|
||||
`package_export_action` pre-grants `allowOverwrite` whenever it did NOT — an
|
||||
unappended path is exactly what the dialog's own confirm already covered, so asking
|
||||
again would be a second prompt for the same consent. Only an appended path, one the
|
||||
dialog never saw, still costs the verb's own confirm naming that exact path.
|
||||
- **The rollback delete is prune's ONE carve-out, and only HALF of it is structural.**
|
||||
The citation and the full discriminator live at `package_rollback.cpp`'s header.
|
||||
"Did this call create it" is structural: only exclusively-created paths are
|
||||
recorded, resolved absolute at record time so a later CWD change cannot re-aim the
|
||||
delete. "Did anything ever reference it" is a **contract the import verb must
|
||||
honour**: it MUST call `markIndexCommitted()` only AFTER the index write has
|
||||
returned success — calling it before, then having that write fail, strands the
|
||||
landed files with no index entry and a journal that now refuses to roll them
|
||||
back — after which `rollback()` refuses and `writeLandedFile` refuses. (Destroying
|
||||
an armed journal without calling either does NOT roll it back — see
|
||||
`LandedFileJournal`'s own doc comment.) `import_bank` honours it: it calls
|
||||
`markIndexCommitted()` only after `persistBankOp` has returned.
|
||||
- **Integrity is proven before the first byte lands, not undone after.**
|
||||
`landPackage` hashes every declared payload against the manifest and only then
|
||||
creates the bank folder, so a damaged package costs no rollback at all and cannot
|
||||
leave debris behind a rollback that itself failed. The second read of each payload
|
||||
is deliberate on a once-per-gesture path — do not fold it into one
|
||||
hash-and-write pass.
|
||||
- **Both pickers ride `GetUserFileName`** — mode 1 for import, mode 0 for export.
|
||||
There is no platform split and no fallback: `main.cpp` defines `REAPERAPI_IMPLEMENT`
|
||||
without `REAPERAPI_MINIMAL` and aborts the extension load if any single name fails
|
||||
to resolve, so a build that can load us cannot lack it.
|
||||
|
||||
## Modules
|
||||
|
||||
- `package_path` — header-only; the ONE UTF-8-narrow → `fs::path` conversion, so the encoding contract has a single enforcement point.
|
||||
- `package_io` — every filesystem act the verbs need: `PayloadBuffer` (move-only payload + the `alive()` seam counter), `PackageFileWriter` (append-only temp+atomic-rename writer), `PackageFileReader` (seek-and-read one range per call, range-checked against the real file size), `readFilePayload` (one source file as one entry's payload), `fileStatus` (Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot), `writeFileExclusive` (exclusive create + write, self-cleaning on a partial write), and `listFolderFileNames` (bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW.
|
||||
- `package_rollback` — `LandedFileJournal`: `writeLandedFile` (exclusive-create land, path resolved absolute, recorded on success only), `markIndexCommitted` (disarms the journal), and `rollback` (deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW.
|
||||
- `package_pickers` — `pickPackageForImport` and `pickPackageSavePath`, both `GetUserFileName`; `pickPackageSavePath` also reports whether it appended `.rsbank` (`outAppended`), the signal `package_export_action` uses to skip a redundant overwrite confirm. Neither picker can be exercised in a unit test.
|
||||
- `export_bank` — the promptless export verb, in three composable public steps: `surveyBankExport` (the read-only plan, report-before-acting), `digestSources` (measures each entry's length + `hashBytes` digest, one payload at a time), and `writePackageFile` (prefix, then each payload re-read and re-verified against that digest before it is appended, then commit). `exportBank` composes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives **const**: `saveToActiveProject`, `bumpBankGeneration` and `writeAssignmentRequest` are the session's only non-const acts, so a const session cannot reach them and "an export writes no ext state, opens no undo point and never bumps the generation" holds by the type rather than by memory (`pruneReclaim`, the sole file-deletion path, is const too and sits outside this claim). Reads the session through inline accessors only, which is why its tests link and run without a DAW.
|
||||
- `import_landing` — the import's two halves that decide anything: `landPackage` (decode, plan, verify EVERY payload's digest, then land through the journal) and `applyImportedBank` (the new bank's entries plus a birth record per landed file, in one straight-line block). REAPER-free deliberately — all-or-nothing, integrity and birth-record behaviour are assertable without a DAW.
|
||||
- `import_bank` — the promptless import verb over a live `ReaSamplerSession`: the project directory, the minted bank id, the `recordCreated` writer, and the one undo-batched persist. REAPER-facing, so it compiles into the extension module rather than into a library with a test target.
|
||||
|
||||
`package_round_trip_tests` is declared here with no library of its own: it drives the
|
||||
same frozen corpus (`tests/fixtures/package_compat/`) through both verbs, which is where
|
||||
export → import → export payload identity is proven.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- A crash mid-export strands the `.rsbanktmp` sibling. It is not a `.rsbank` (no
|
||||
picker filter matches it), and a later export to the same destination truncates it.
|
||||
A crash mid-import strands a partial bank file under its real name instead — the
|
||||
land is a direct exclusive create, not temp+rename. Either way the debris was never
|
||||
recorded in the tracking ledger, so prune sees a foreign file (not owned, never an
|
||||
orphan) and will not touch it; removal is by hand. `[verify — DAW]` whether the
|
||||
import verb should pre-clean stale debris when it lands.
|
||||
- The picker filter and mode arguments are spelled to `GetUserFileName`'s documented
|
||||
pair format but are `[verify — DAW]` on all three platforms — neither picker is
|
||||
exercised outside a live REAPER session. `GetUserFileName` also takes no owner
|
||||
window, so dialog parenting is REAPER's to do; the superseded Win32 path passed
|
||||
`GetMainHwnd()` explicitly. Also `[verify — DAW]`: whether mode 0's picker appends
|
||||
an extension from `extension_list` when the user omits one — `pickPackageSavePath`
|
||||
re-appends `.rsbank` itself so the returned path is correct regardless of how that
|
||||
lands (the superseded Win32 path had `ofn.lpstrDefExt` for this; `GetUserFileName`
|
||||
has no equivalent parameter). The re-append is suffix-blind: it only skips when the
|
||||
path already ends in `.rsbank`, so a path carrying a DIFFERENT extension gets
|
||||
`.rsbank` appended after it (`mybank.bak` → `mybank.bak.rsbank`), unlike the
|
||||
superseded `ofn.lpstrDefExt`, which appended only when the path had no extension at
|
||||
all. Defensible for a format-locked export, but a real divergence from the old
|
||||
picker's behavior — whoever tests the picker under `[verify — DAW]` should expect
|
||||
the double-extension result on a path that already has one.
|
||||
- `pickPackageSavePath`'s `suggestedPath` doubles as the dialog's starting directory
|
||||
when it is a full path. The verbs should seed it from the project directory —
|
||||
passing a bare name leaves the dialog on REAPER's process working directory, which
|
||||
is its install or resource path.
|
||||
- `readRange(_, 0)` returns an empty buffer — indistinguishable from failure, by
|
||||
design (the one "nothing to work with" branch). **Cross-track contract, not a local
|
||||
rule:** a genuinely zero-length entry cannot round-trip through this seam, so
|
||||
`core/package`'s format layer must not emit one.
|
||||
@@ -0,0 +1,54 @@
|
||||
# The filesystem + dialog seam for bank packages. package_io / package_rollback are
|
||||
# REAPER-free (standard filesystem only), so the pure-library/test helpers fit and
|
||||
# their tests run without a DAW. Both verbs live here too: export_bank whole, and the
|
||||
# import's REAPER-free half (import_landing) — the import's REAPER-facing half
|
||||
# (import_bank.cpp) compiles into the extension module instead.
|
||||
|
||||
reasampler_pure_library(package_io SOURCES package_io.cpp)
|
||||
reasampler_test(package_io LINK package_io)
|
||||
|
||||
reasampler_pure_library(package_rollback SOURCES package_rollback.cpp LINK PUBLIC package_io)
|
||||
reasampler_test(package_rollback LINK package_rollback)
|
||||
|
||||
# export_bank reads the live session through ReaSamplerSession's INLINE accessors only,
|
||||
# so it pulls in no REAPER-facing TU and its tests link (and run) without a DAW.
|
||||
# bank_book / tail_control / origin_ledger / tracking_authority / prune_reconcile /
|
||||
# app_version / view_mode_model are session.h's own transitive includes (BankBook::bank()
|
||||
# in particular is out-of-line, in bank_book.cpp) — declared here, on the library that
|
||||
# actually needs them, rather than left for every consumer to enumerate.
|
||||
reasampler_pure_library(export_bank
|
||||
SOURCES export_bank.cpp
|
||||
LINK PUBLIC export_plan bank_package package_io
|
||||
PRIVATE capture_paths wav_codec bank_book tail_control origin_ledger
|
||||
tracking_authority prune_reconcile app_version view_mode_model)
|
||||
reasampler_test(export_bank
|
||||
LINK export_bank bank_book slot_map view_mode_model tail_control origin_ledger
|
||||
tracking_authority prune_reconcile app_version capture_paths)
|
||||
|
||||
# The import's decisions and its file half, both REAPER-free, so all-or-nothing,
|
||||
# integrity and birth-record behaviour are assertable without a DAW. The REAPER-facing
|
||||
# verb over them (import_bank.cpp) compiles into the extension module instead.
|
||||
reasampler_pure_library(import_landing
|
||||
SOURCES import_landing.cpp
|
||||
LINK PUBLIC import_plan package_rollback bank_book PRIVATE bank_package wav_codec)
|
||||
reasampler_test(import_landing LINK import_landing bank_package app_version wav_codec origin_ledger)
|
||||
|
||||
# The frozen compatibility corpus driven through both verbs in one process — the round
|
||||
# trip is export -> import -> export, so its link set is export_bank's plus the import
|
||||
# half. Fixture path: see tests/package_fixtures.h.
|
||||
reasampler_test(package_round_trip
|
||||
LINK import_landing export_bank bank_package bank_book slot_map view_mode_model
|
||||
tail_control origin_ledger tracking_authority prune_reconcile app_version
|
||||
capture_paths wav_codec)
|
||||
target_compile_definitions(package_round_trip_tests PRIVATE
|
||||
REASAMPLER_PACKAGE_FIXTURE_DIR="${REASAMPLER_PACKAGE_FIXTURE_DIR}")
|
||||
|
||||
# The pickers call the REAPER API, so no test target can exercise them; declared as a
|
||||
# library so the TU stays compiled. reaper_plugin.h pulls SWELL in on non-Windows.
|
||||
add_library(package_pickers STATIC package_pickers.cpp)
|
||||
target_include_directories(package_pickers PUBLIC ${REASAMPLER_SRC_DIR})
|
||||
target_include_directories(package_pickers PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
if(NOT WIN32)
|
||||
# Match the loadable modules: SWELL is provided by the host REAPER at runtime.
|
||||
target_compile_definitions(package_pickers PRIVATE SWELL_PROVIDED_BY_APP)
|
||||
endif()
|
||||
@@ -0,0 +1,184 @@
|
||||
// export_bank.cpp — see export_bank.h for the contract.
|
||||
//
|
||||
// wav_codec is called for hashBytes ONLY. Payload bytes are copied and hashed, never
|
||||
// rebuilt, trimmed, normalized or collapsed — the capture path's mono collapse must
|
||||
// not reach an export.
|
||||
|
||||
#include "shell/package/export_bank.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile — the index's relative -> absolute
|
||||
#include "core/capture/wav_codec.h" // hashBytes
|
||||
#include "core/model/bank_book.h"
|
||||
#include "shell/package/package_io.h"
|
||||
#include "shell/persist/session.h" // ReaSamplerSession — read through its inline book() only
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
package::SourceFileState stateOf(const std::string& absPath) {
|
||||
switch (fileStatus(absPath)) {
|
||||
case FileStatus::Present: return package::SourceFileState::Present;
|
||||
case FileStatus::Unreadable: return package::SourceFileState::Unreadable;
|
||||
case FileStatus::Absent: break;
|
||||
}
|
||||
return package::SourceFileState::Missing;
|
||||
}
|
||||
|
||||
std::vector<std::string> absoluteSources(const std::string& projectDir,
|
||||
const std::vector<std::string>& relativePaths) {
|
||||
std::vector<std::string> out;
|
||||
out.reserve(relativePaths.size());
|
||||
for (const std::string& rel : relativePaths)
|
||||
out.push_back(capture::resolveBankFile(projectDir, rel));
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExportSurvey surveyBankExport(const ReaSamplerSession& session,
|
||||
const std::string& projectDir,
|
||||
const std::string& bankId) {
|
||||
ExportSurvey survey;
|
||||
const Bank* bank = session.book().bank(bankId);
|
||||
if (!bank) return survey;
|
||||
survey.bankFound = true;
|
||||
|
||||
package::ExportInputs inputs;
|
||||
inputs.bankDisplayName = bank->displayName;
|
||||
inputs.slots = bank->slots;
|
||||
for (const model::Sample& s : bank->index.all()) {
|
||||
package::ExportCandidate c;
|
||||
c.sample = s;
|
||||
c.fileState = stateOf(capture::resolveBankFile(projectDir, s.relativePath));
|
||||
inputs.candidates.push_back(std::move(c));
|
||||
}
|
||||
survey.plan = package::planExport(inputs);
|
||||
return survey;
|
||||
}
|
||||
|
||||
bool digestSources(package::PackageManifest& manifest,
|
||||
const std::vector<std::string>& sourceAbsPaths,
|
||||
std::string& outFailedName) {
|
||||
outFailedName.clear();
|
||||
if (sourceAbsPaths.size() != manifest.entries.size()) return false;
|
||||
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
|
||||
const PayloadBuffer payload = readFilePayload(sourceAbsPaths[i]);
|
||||
if (payload.empty()) {
|
||||
outFailedName = manifest.entries[i].fileName;
|
||||
return false;
|
||||
}
|
||||
manifest.entries[i].byteLength = payload.size();
|
||||
manifest.entries[i].byteHash = capture::hashBytes(payload.data(), payload.size());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ExportOutcome writePackageFile(const package::EncodedPackage& encoded,
|
||||
const package::PackageManifest& manifest,
|
||||
const std::vector<std::string>& sourceAbsPaths,
|
||||
const std::string& destAbsPath) {
|
||||
ExportOutcome out;
|
||||
if (sourceAbsPaths.size() != manifest.entries.size() ||
|
||||
encoded.layout.size() != manifest.entries.size()) {
|
||||
out.status = ExportStatus::EncodeFailed;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Every early return below abandons the writer through its destructor, which
|
||||
// removes the temp and leaves the destination untouched.
|
||||
PackageFileWriter writer(destAbsPath);
|
||||
if (!writer.ok() || !writer.appendRaw(encoded.prefix.data(), encoded.prefix.size())) {
|
||||
out.status = ExportStatus::WriteFailed;
|
||||
return out;
|
||||
}
|
||||
std::uint64_t written = encoded.prefix.size();
|
||||
|
||||
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
|
||||
const package::PackageEntry& entry = manifest.entries[i];
|
||||
const PayloadBuffer payload = readFilePayload(sourceAbsPaths[i]);
|
||||
if (payload.empty()) {
|
||||
out.status = ExportStatus::SourceReadFailed;
|
||||
out.offendingName = entry.fileName;
|
||||
return out;
|
||||
}
|
||||
if (payload.size() != entry.byteLength ||
|
||||
capture::hashBytes(payload.data(), payload.size()) != entry.byteHash) {
|
||||
out.status = ExportStatus::SourceChanged;
|
||||
out.offendingName = entry.fileName;
|
||||
return out;
|
||||
}
|
||||
if (!writer.appendPayload(payload)) {
|
||||
out.status = ExportStatus::WriteFailed;
|
||||
return out;
|
||||
}
|
||||
written += payload.size();
|
||||
}
|
||||
|
||||
if (written != encoded.totalSize || !writer.commit()) {
|
||||
out.status = ExportStatus::WriteFailed;
|
||||
return out;
|
||||
}
|
||||
out.status = ExportStatus::Written;
|
||||
out.entriesWritten = manifest.entries.size();
|
||||
out.bytesWritten = written;
|
||||
return out;
|
||||
}
|
||||
|
||||
ExportOutcome exportBank(const ReaSamplerSession& session, const ExportRequest& req) {
|
||||
ExportOutcome out;
|
||||
if (req.projectDir.empty()) {
|
||||
out.status = ExportStatus::NoProjectDir;
|
||||
return out;
|
||||
}
|
||||
|
||||
const ExportSurvey survey = surveyBankExport(session, req.projectDir, req.bankId);
|
||||
if (!survey.bankFound) {
|
||||
out.status = ExportStatus::NoSuchBank;
|
||||
return out;
|
||||
}
|
||||
out.bankDisplayName = survey.plan.manifest.bankDisplayName;
|
||||
out.excluded = survey.plan.excluded;
|
||||
|
||||
if (survey.plan.verdict == package::ExportVerdict::Refused) {
|
||||
out.status = ExportStatus::RefusedUnrepresentable;
|
||||
return out;
|
||||
}
|
||||
if (survey.plan.verdict == package::ExportVerdict::Incomplete && !req.allowIncomplete) {
|
||||
out.status = ExportStatus::RefusedIncomplete;
|
||||
return out;
|
||||
}
|
||||
// The save dialog's own overwrite confirm covered the path the USER chose, which
|
||||
// is not necessarily the path handed here (the picker re-appends `.rsbank`), so
|
||||
// consent for the real target is re-taken by the skin.
|
||||
if (!req.allowOverwrite && fileStatus(req.destAbsPath) == FileStatus::Present) {
|
||||
out.status = ExportStatus::RefusedDestinationExists;
|
||||
return out;
|
||||
}
|
||||
|
||||
package::PackageManifest manifest = survey.plan.manifest;
|
||||
manifest.exportTimestamp = req.exportTimestamp;
|
||||
const std::vector<std::string> sources =
|
||||
absoluteSources(req.projectDir, survey.plan.sourceRelativePaths);
|
||||
|
||||
if (!digestSources(manifest, sources, out.offendingName)) {
|
||||
out.status = ExportStatus::SourceReadFailed;
|
||||
return out;
|
||||
}
|
||||
const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
|
||||
if (!encoded) {
|
||||
out.status = ExportStatus::EncodeFailed;
|
||||
return out;
|
||||
}
|
||||
|
||||
ExportOutcome written = writePackageFile(*encoded, manifest, sources, req.destAbsPath);
|
||||
written.bankDisplayName = out.bankDisplayName;
|
||||
written.excluded = std::move(out.excluded);
|
||||
return written;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,95 @@
|
||||
// shell/package/export_bank — the promptless bank-export verb: survey, digest,
|
||||
// stream, commit. No prompts and no message boxes (shell/actions/
|
||||
// package_export_action is the skin). The session arrives CONST, which is how "an
|
||||
// export writes no ext state, opens no undo point and never bumps the bank
|
||||
// generation" is enforced rather than remembered — those three acts
|
||||
// (saveToActiveProject, bumpBankGeneration, writeAssignmentRequest) are exactly the
|
||||
// session members that are non-const (session.h:113,108,147). Constness does not
|
||||
// block every mutation, though: pruneReclaim (session.h:140-141) is const and is
|
||||
// the system's sole file-deletion path — irrelevant to export, but not something a
|
||||
// const session forbids in general. Blocking I/O: UI-thread actions only.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/package/bank_package.h"
|
||||
#include "core/package/export_plan.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
struct ExportRequest {
|
||||
std::string projectDir; // absolute; the root the index's relative paths hang off
|
||||
std::string bankId;
|
||||
std::string destAbsPath; // the .rsbank to write
|
||||
std::int64_t exportTimestamp = 0; // manifest envelope; the caller's clock read
|
||||
// Both default false and are set ONLY after the skin's explicit confirm: one
|
||||
// lists what is absent, the other names the destination being replaced.
|
||||
bool allowIncomplete = false;
|
||||
bool allowOverwrite = false;
|
||||
};
|
||||
|
||||
enum class ExportStatus {
|
||||
Written,
|
||||
NoSuchBank,
|
||||
NoProjectDir,
|
||||
RefusedIncomplete,
|
||||
RefusedUnrepresentable,
|
||||
RefusedDestinationExists,
|
||||
SourceReadFailed, // a file the plan classified Present would not read, or is empty
|
||||
SourceChanged, // a payload's bytes moved between the digest pass and the stream pass
|
||||
EncodeFailed,
|
||||
WriteFailed,
|
||||
};
|
||||
|
||||
struct ExportOutcome {
|
||||
ExportStatus status = ExportStatus::WriteFailed;
|
||||
std::size_t entriesWritten = 0;
|
||||
std::uint64_t bytesWritten = 0;
|
||||
std::string bankDisplayName;
|
||||
std::vector<package::ExcludedEntry> excluded;
|
||||
std::string offendingName; // the entry a SourceReadFailed / SourceChanged names
|
||||
};
|
||||
|
||||
struct ExportSurvey {
|
||||
bool bankFound = false;
|
||||
package::ExportPlan plan;
|
||||
};
|
||||
|
||||
// Report-before-acting: the same plan exportBank recomputes, with nothing written.
|
||||
// Read-only against both the project and the filesystem.
|
||||
ExportSurvey surveyBankExport(const ReaSamplerSession& session,
|
||||
const std::string& projectDir,
|
||||
const std::string& bankId);
|
||||
|
||||
// Fills each manifest entry's byteLength and byteHash from its source file — the
|
||||
// digest pass, one payload in memory at a time. False with `outFailedName` set when a
|
||||
// source will not read or is empty; a zero-length entry cannot round-trip the
|
||||
// format's own seam, so it is a failure here rather than an entry.
|
||||
bool digestSources(package::PackageManifest& manifest,
|
||||
const std::vector<std::string>& sourceAbsPaths,
|
||||
std::string& outFailedName);
|
||||
|
||||
// Streams one package to `destAbsPath`: the encoded prefix, then each payload re-read
|
||||
// from `sourceAbsPaths` (parallel to `manifest.entries`) and re-checked against the
|
||||
// length and digest recorded for it before it is appended — so the digest the
|
||||
// manifest claims describes the bytes actually written, not the bytes a concurrent
|
||||
// edit replaced. Any failure abandons the writer, leaving the destination absent or
|
||||
// holding its prior contents.
|
||||
//
|
||||
// Public because that atomicity is this function's property: proving it needs a
|
||||
// failure injected mid-stream, which is a call to this seam, not to exportBank.
|
||||
ExportOutcome writePackageFile(const package::EncodedPackage& encoded,
|
||||
const package::PackageManifest& manifest,
|
||||
const std::vector<std::string>& sourceAbsPaths,
|
||||
const std::string& destAbsPath);
|
||||
|
||||
// The verb: plan, gate on the verdict and the destination, digest, encode, stream.
|
||||
ExportOutcome exportBank(const ReaSamplerSession& session, const ExportRequest& req);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,97 @@
|
||||
// import_bank.cpp — see import_bank.h for the contract. The REAPER-facing half of the
|
||||
// import: the project directory, the minted bank id, the birth records, and the one
|
||||
// undo-batched persist. Every decision it makes is in import_landing / import_plan.
|
||||
//
|
||||
// main.cpp owns the API pointers; this TU gets them extern. DAW-verified, not unit tested.
|
||||
|
||||
#include "shell/package/import_bank.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // projectDirOfRpp
|
||||
#include "shell/bank_ops/bank_ops.h" // persistBankOp — one bank op is one Ctrl-Z
|
||||
#include "shell/persist/session.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_genGuid
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string activeProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return capture::projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
// The model mints no ids (it stays pure and deterministic), so the shell does — the
|
||||
// same GUID pair bankOpCreate uses.
|
||||
std::string mintBankId() {
|
||||
GUID g{};
|
||||
genGuid(&g);
|
||||
char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract)
|
||||
guidToString(&g, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
void fillPlanCounts(ImportBankResult& out, const package::ImportPlan& plan) {
|
||||
out.bankDisplayName = plan.bankDisplayName;
|
||||
out.seedBankName = plan.seedBankName;
|
||||
out.bankNameAdjusted = plan.bankNameAdjusted;
|
||||
out.landedCount = plan.landCount;
|
||||
out.collisionRenameCount = plan.collisionRenameCount;
|
||||
out.sanitizeRenameCount = plan.sanitizeRenameCount;
|
||||
out.collapsedCount = plan.collapseCount;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ImportBankResult importBankPackage(ReaSamplerSession& session,
|
||||
const std::string& packageAbsPath) {
|
||||
ImportBankResult out;
|
||||
|
||||
// A per-import disambiguator, the same shape capture and ingest file under.
|
||||
const std::string uniqueTag =
|
||||
std::to_string(static_cast<std::int64_t>(std::time(nullptr)));
|
||||
|
||||
LandedFileJournal journal;
|
||||
const ImportLanding landing = landPackage(packageAbsPath, activeProjectDir(),
|
||||
session.book(), uniqueTag, journal);
|
||||
out.outcome = landing.outcome;
|
||||
out.header = landing.header;
|
||||
out.failedEntryName = landing.failedEntryName;
|
||||
out.rollback = landing.rollback;
|
||||
fillPlanCounts(out, landing.plan);
|
||||
if (landing.outcome != ImportOutcome::Landed) return out;
|
||||
|
||||
const std::string bankId = mintBankId();
|
||||
const bool applied = applyImportedBank(
|
||||
session.book(), bankId, landing.plan,
|
||||
[&session](const model::Sample& s) {
|
||||
session.recordCreated(s, tracking::OriginKind::PackageImport);
|
||||
});
|
||||
if (!applied) {
|
||||
out.outcome = ImportOutcome::IndexRejected;
|
||||
out.rollback = journal.rollback();
|
||||
return out;
|
||||
}
|
||||
out.bankId = bankId;
|
||||
|
||||
// Generation bump + persist ride inside one undo block, so a Ctrl-Z takes the whole
|
||||
// import back out of the index. It does NOT un-write the files — the summary says so.
|
||||
persistBankOp(session, "ReaSampler: import bank package", /*bumpGeneration=*/true);
|
||||
// Only now: the files are referenced, so prune's self-cleanup carve-out no longer
|
||||
// covers them (see package_rollback.h).
|
||||
journal.markIndexCommitted();
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
// shell/package/import_bank — the promptless import verb: one package becomes one NEW
|
||||
// bank in the live session, completely or not at all. No prompts, no message boxes, no
|
||||
// picker — it reports and the action skin (shell/actions/package_import_action) speaks.
|
||||
// The ledger gate is the skin's, because it must refuse BEFORE a file is even chosen.
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "core/package/import_plan.h"
|
||||
#include "shell/package/import_landing.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
struct ImportBankResult {
|
||||
ImportOutcome outcome = ImportOutcome::Unreadable;
|
||||
package::PackageHeader header; // TooNew names the writer's build from here
|
||||
|
||||
std::string bankId; // the minted id — meaningful only when outcome == Landed
|
||||
std::string bankDisplayName; // the bank actually created
|
||||
std::string seedBankName; // what the package asked to be called
|
||||
bool bankNameAdjusted = false;
|
||||
|
||||
int landedCount = 0;
|
||||
int collisionRenameCount = 0; // renamed: the package's own name was already taken
|
||||
int sanitizeRenameCount = 0; // renamed: not spelled the way this tool spells a bank file
|
||||
int collapsedCount = 0;
|
||||
|
||||
std::string failedEntryName;
|
||||
RollbackResult rollback;
|
||||
};
|
||||
|
||||
// Lands `packageAbsPath` as a new bank in `session`, in ONE undo point, bumping the
|
||||
// bank generation so live instances reload. Places no timeline item. On any failure
|
||||
// nothing remains on disk and the book is untouched.
|
||||
ImportBankResult importBankPackage(ReaSamplerSession& session,
|
||||
const std::string& packageAbsPath);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,149 @@
|
||||
// import_landing.cpp — see import_landing.h for the contract. REAPER-free: standard
|
||||
// filesystem only, so every property this file decides is unit-testable.
|
||||
|
||||
#include "shell/package/import_landing.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/wav_codec.h" // hashBytes — the digest the manifest records
|
||||
#include "core/package/bank_package.h"
|
||||
#include "shell/package/package_io.h"
|
||||
#include "shell/package/package_path.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using package::EntryAction;
|
||||
using package::PackageEntrySpan;
|
||||
|
||||
namespace {
|
||||
|
||||
ImportLanding refusal(ImportOutcome outcome, const package::PackageHeader& header) {
|
||||
ImportLanding out;
|
||||
out.outcome = outcome;
|
||||
out.header = header;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Reads the package head incrementally: requiredPrefixSize may grow its answer as
|
||||
// fields arrive, so ask, read to the count, ask again. False means these bytes can
|
||||
// never frame a package, or the file is shorter than its own header claims.
|
||||
bool readPrefix(PackageFileReader& reader, std::vector<std::uint8_t>& prefix) {
|
||||
for (;;) {
|
||||
const auto need = package::requiredPrefixSize(prefix);
|
||||
if (!need) return false;
|
||||
if (prefix.size() >= *need) return true;
|
||||
PayloadBuffer head = reader.readRange(0, *need);
|
||||
if (head.size() != *need) return false;
|
||||
prefix.assign(head.data(), head.data() + head.size());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ImportLanding landPackage(const std::string& packageAbsPath,
|
||||
const std::string& projectDir,
|
||||
const BankBook& destination,
|
||||
const std::string& uniqueTag,
|
||||
LandedFileJournal& journal) {
|
||||
const package::PackageHeader noHeader;
|
||||
if (projectDir.empty()) return refusal(ImportOutcome::NoProject, noHeader);
|
||||
|
||||
PackageFileReader reader(packageAbsPath);
|
||||
if (!reader.ok()) return refusal(ImportOutcome::Unreadable, noHeader);
|
||||
|
||||
std::vector<std::uint8_t> prefix;
|
||||
if (!readPrefix(reader, prefix)) return refusal(ImportOutcome::Malformed, noHeader);
|
||||
|
||||
const package::DecodedPackage dec = package::decodePackage(prefix, reader.fileSize());
|
||||
if (dec.status == package::PackageReadability::TooNew)
|
||||
return refusal(ImportOutcome::TooNew, dec.header);
|
||||
if (dec.status != package::PackageReadability::Readable)
|
||||
return refusal(ImportOutcome::Malformed, dec.header);
|
||||
|
||||
const std::string bankDir = package::bankFolderDir(projectDir);
|
||||
|
||||
ImportLanding out;
|
||||
out.header = dec.header;
|
||||
out.plan = package::planImport(dec.manifest, destination, projectDir,
|
||||
listFolderFileNames(bankDir), uniqueTag);
|
||||
|
||||
// Integrity first, over EVERY declared entry — including one the plan collapses,
|
||||
// since a package that fails its own digest is refused whole rather than partly
|
||||
// trusted. Nothing is on disk yet, so a failure here needs no rollback.
|
||||
for (std::size_t i = 0; i < dec.layout.size(); ++i) {
|
||||
const PackageEntrySpan& span = dec.layout[i];
|
||||
// The format refuses a zero-length entry on encode; one arriving anyway cannot
|
||||
// be told from a failed read at this seam, so it is not well-formed input.
|
||||
if (span.length == 0) return refusal(ImportOutcome::Malformed, dec.header);
|
||||
|
||||
PayloadBuffer payload = reader.readRange(span.offset, span.length);
|
||||
if (payload.size() != span.length) {
|
||||
out.outcome = ImportOutcome::IntegrityFailed;
|
||||
out.failedEntryName = span.name;
|
||||
return out;
|
||||
}
|
||||
if (capture::hashBytes(payload.data(), payload.size()) !=
|
||||
dec.manifest.entries[i].byteHash) {
|
||||
out.outcome = ImportOutcome::IntegrityFailed;
|
||||
out.failedEntryName = span.name;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
fs::create_directories(utf8Path(bankDir), ec); // idempotent; the write reports failure
|
||||
|
||||
for (const package::PlannedEntry& e : out.plan.entries) {
|
||||
if (e.action != EntryAction::Land) continue;
|
||||
const PackageEntrySpan& span = dec.layout[e.manifestIndex];
|
||||
PayloadBuffer payload = reader.readRange(span.offset, span.length);
|
||||
if (payload.size() == span.length &&
|
||||
journal.writeLandedFile(bankDir + "/" + e.destFileName, payload)) {
|
||||
continue;
|
||||
}
|
||||
out.outcome = ImportOutcome::WriteFailed;
|
||||
out.failedEntryName = e.destFileName;
|
||||
out.rollback = journal.rollback();
|
||||
return out;
|
||||
}
|
||||
|
||||
out.outcome = ImportOutcome::Landed;
|
||||
return out;
|
||||
}
|
||||
|
||||
bool applyImportedBank(BankBook& book, const std::string& bankId,
|
||||
const package::ImportPlan& plan, const RecordBirth& recordBirth) {
|
||||
// An empty std::function throws std::bad_function_call on invoke; every real caller
|
||||
// supplies one, so an empty one here is a caller bug, not a runtime condition to
|
||||
// recover from — enforce the contract rather than let it surface as an uncaught
|
||||
// exception out of an extension action.
|
||||
assert(recordBirth && "applyImportedBank: RecordBirth must not be empty");
|
||||
if (!book.createBank(bankId, plan.bankDisplayName)) return false;
|
||||
|
||||
BankModel* index = book.index(bankId);
|
||||
for (const package::PlannedEntry& e : plan.entries) {
|
||||
if (e.action != EntryAction::Land) continue;
|
||||
const AddResult added = index->add(e.sample);
|
||||
// planImport already deduped Land entries by hash against an empty destination
|
||||
// bank (this same freshly-created one), so a Collapsed add here would mean the
|
||||
// plan and the book disagree — that would silently undercount reportSuccess's
|
||||
// landedCount rather than fail loudly.
|
||||
assert(added == AddResult::Added && "planImport's Land entries must not collapse");
|
||||
(void)added;
|
||||
// Unconditional on the add's outcome: the file exists either way, and an
|
||||
// unrecorded file is permanently unreclaimable.
|
||||
recordBirth(e.sample);
|
||||
}
|
||||
|
||||
book.bank(bankId)->slots = plan.slots;
|
||||
book.reconcileSlots();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
// shell/package/import_landing — the import's filesystem half and its index half,
|
||||
// both REAPER-free so the all-or-nothing, integrity and birth-record properties are
|
||||
// assertable without a DAW. The verb that drives them against a live session is
|
||||
// import_bank; the REAPER-facing reporting is shell/actions/package_import_action.
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "core/model/bank_book.h"
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/package/import_plan.h"
|
||||
#include "core/package/package_format.h"
|
||||
#include "shell/package/package_rollback.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// How a landing ended. Every value but Landed means NOTHING is on disk and NO index was
|
||||
// touched. NoProject/Unreadable/Malformed/TooNew refuse before a byte is written.
|
||||
// IntegrityFailed also refuses before any write — the full-package digest verification
|
||||
// runs to completion first (landPackage) — so it needs no rollback either. WriteFailed
|
||||
// is the only outcome that actually wrote and then rolled back. IndexRejected is never
|
||||
// returned by landPackage/this struct — it is import_bank's own outcome, minted after a
|
||||
// successful landing when the book itself refuses the create.
|
||||
enum class ImportOutcome {
|
||||
Landed,
|
||||
NoProject, // unsaved project: there is no bank folder to land into
|
||||
Unreadable, // the package file could not be opened
|
||||
Malformed, // not a well-formed RSBK: corrupt, truncated, or trailing garbage
|
||||
TooNew, // minReaderVersion above this build's ladder
|
||||
IntegrityFailed, // an entry's payload did not match its recorded digest; pre-write refusal
|
||||
WriteFailed, // a write failed partway; the landed files were rolled back
|
||||
IndexRejected, // never set here — see the comment above; import_bank's outcome only
|
||||
};
|
||||
|
||||
struct ImportLanding {
|
||||
ImportOutcome outcome = ImportOutcome::Unreadable;
|
||||
// Meaningful from the moment the header parsed — a TooNew refusal names the
|
||||
// writer's build, which is the only part of that message a user can act on.
|
||||
package::PackageHeader header;
|
||||
package::ImportPlan plan;
|
||||
std::string failedEntryName; // IntegrityFailed / WriteFailed
|
||||
RollbackResult rollback; // WriteFailed only — IntegrityFailed leaves it default
|
||||
};
|
||||
|
||||
// Streams `packageAbsPath` into the project's bank folder: decode, plan, verify EVERY
|
||||
// payload's digest, then land. Verification runs to completion before the first write,
|
||||
// so a damaged package costs no rollback at all. Mutates no index and holds at most
|
||||
// one payload at a time. `journal` is left armed on success — the caller applies the
|
||||
// plan to the book and only then disarms it.
|
||||
ImportLanding landPackage(const std::string& packageAbsPath,
|
||||
const std::string& projectDir,
|
||||
const BankBook& destination,
|
||||
const std::string& uniqueTag,
|
||||
LandedFileJournal& journal);
|
||||
|
||||
// Called for every landed file, in the same straight-line block as its bank add —
|
||||
// core/tracking/CLAUDE.md's no-silent-gaps invariant, kept structural by passing the
|
||||
// writer in rather than letting a caller add first and record later.
|
||||
using RecordBirth = std::function<void(const model::Sample&)>;
|
||||
|
||||
// Adds the plan's landed entries to a NEW bank under `bankId`. False (no mutation)
|
||||
// only if the book refuses the create — the name was minted free against this same
|
||||
// book, so that means the book changed underneath the plan.
|
||||
bool applyImportedBank(BankBook& book, const std::string& bankId,
|
||||
const package::ImportPlan& plan, const RecordBirth& recordBirth);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,276 @@
|
||||
// package_io.cpp — see package_io.h for the seam's contract. Non-throwing at the
|
||||
// boundary about filesystem_error: every filesystem call uses the error_code form.
|
||||
// Allocation can still throw bad_alloc — readRange's sanity ceiling exists to keep
|
||||
// that surface small, not to remove it.
|
||||
|
||||
#include "shell/package/package_io.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
#include "shell/package/package_path.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#include <share.h>
|
||||
#include <sys/stat.h>
|
||||
#else
|
||||
#include <cerrno>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
std::atomic<int> g_alivePayloads{0};
|
||||
std::atomic<int> g_peakAlivePayloads{0};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PayloadBuffer
|
||||
|
||||
PayloadBuffer::PayloadBuffer(std::vector<std::uint8_t> bytes)
|
||||
: bytes_(std::move(bytes)), counted_(!bytes_.empty()) {
|
||||
if (counted_) {
|
||||
const int now = g_alivePayloads.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
int peak = g_peakAlivePayloads.load(std::memory_order_relaxed);
|
||||
while (now > peak && !g_peakAlivePayloads.compare_exchange_weak(
|
||||
peak, now, std::memory_order_relaxed)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PayloadBuffer::~PayloadBuffer() { release(); }
|
||||
|
||||
PayloadBuffer::PayloadBuffer(PayloadBuffer&& other) noexcept
|
||||
: bytes_(std::move(other.bytes_)), counted_(other.counted_) {
|
||||
// The count transfers with the bytes — a move must never double-count.
|
||||
other.bytes_.clear();
|
||||
other.counted_ = false;
|
||||
}
|
||||
|
||||
PayloadBuffer& PayloadBuffer::operator=(PayloadBuffer&& other) noexcept {
|
||||
if (this != &other) {
|
||||
release();
|
||||
bytes_ = std::move(other.bytes_);
|
||||
counted_ = other.counted_;
|
||||
other.bytes_.clear();
|
||||
other.counted_ = false;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
int PayloadBuffer::alive() { return g_alivePayloads.load(std::memory_order_relaxed); }
|
||||
int PayloadBuffer::highWaterMark() { return g_peakAlivePayloads.load(std::memory_order_relaxed); }
|
||||
|
||||
void PayloadBuffer::release() {
|
||||
if (counted_) g_alivePayloads.fetch_sub(1, std::memory_order_relaxed);
|
||||
counted_ = false;
|
||||
bytes_.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PackageFileWriter
|
||||
|
||||
PackageFileWriter::PackageFileWriter(const std::string& destAbsPath)
|
||||
: destPath_(utf8Path(destAbsPath)), tempPath_(destPath_) {
|
||||
tempPath_ += ".rsbanktmp"; // += concatenates; / would make it a child
|
||||
out_.open(tempPath_, std::ios::binary | std::ios::trunc);
|
||||
ok_ = static_cast<bool>(out_);
|
||||
}
|
||||
|
||||
PackageFileWriter::~PackageFileWriter() {
|
||||
if (!done_) abort();
|
||||
}
|
||||
|
||||
bool PackageFileWriter::appendRaw(const std::uint8_t* data, std::size_t len) {
|
||||
if (!ok_ || done_) return false;
|
||||
if (len == 0) return true;
|
||||
out_.write(reinterpret_cast<const char*>(data),
|
||||
static_cast<std::streamsize>(len));
|
||||
ok_ = static_cast<bool>(out_);
|
||||
return ok_;
|
||||
}
|
||||
|
||||
bool PackageFileWriter::appendPayload(const PayloadBuffer& payload) {
|
||||
if (payload.empty()) {
|
||||
ok_ = false; // the stream is now short of what the framing will claim
|
||||
return false;
|
||||
}
|
||||
return appendRaw(payload.data(), payload.size());
|
||||
}
|
||||
|
||||
bool PackageFileWriter::commit() {
|
||||
if (done_) return false;
|
||||
if (ok_) {
|
||||
out_.flush();
|
||||
ok_ = static_cast<bool>(out_);
|
||||
}
|
||||
out_.close();
|
||||
if (!ok_) {
|
||||
abort();
|
||||
return false;
|
||||
}
|
||||
// rename() replaces the destination in one step (the mono-collapse precedent):
|
||||
// prior contents survive until the replacement is known-complete, and a failed
|
||||
// rename self-cleans the temp rather than littering it.
|
||||
std::error_code ec;
|
||||
fs::rename(tempPath_, destPath_, ec);
|
||||
if (ec) {
|
||||
fs::remove(tempPath_, ec);
|
||||
done_ = true;
|
||||
ok_ = false;
|
||||
return false;
|
||||
}
|
||||
done_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void PackageFileWriter::abort() {
|
||||
if (done_) return;
|
||||
out_.close();
|
||||
std::error_code ec;
|
||||
fs::remove(tempPath_, ec);
|
||||
done_ = true;
|
||||
ok_ = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PackageFileReader
|
||||
|
||||
PackageFileReader::PackageFileReader(const std::string& srcAbsPath) {
|
||||
const fs::path path = utf8Path(srcAbsPath);
|
||||
std::error_code ec;
|
||||
const std::uintmax_t sz = fs::file_size(path, ec);
|
||||
if (ec) return;
|
||||
in_.open(path, std::ios::binary);
|
||||
if (!in_) return;
|
||||
size_ = static_cast<std::uint64_t>(sz);
|
||||
ok_ = true;
|
||||
}
|
||||
|
||||
PayloadBuffer PackageFileReader::readRange(std::uint64_t offset, std::uint64_t length) {
|
||||
// Overflow-safe range check: length is capped by the real file size before any
|
||||
// allocation happens, so a hostile offset/length pair cannot demand the moon.
|
||||
// Also rejected here rather than truncated: a length that would not fit in
|
||||
// size_t (possible on a 32-bit build, where streamsize below stays 64-bit and
|
||||
// so would read past a truncated allocation) and a length past the sanity
|
||||
// ceiling, which exists so a merely large-but-real file size can't still hand
|
||||
// std::vector a multi-gigabyte demand.
|
||||
//
|
||||
// `length > size_` short-circuits before the two branches below ever see a real
|
||||
// file, so neither is reachable without a genuine >4 GiB fixture — this guard
|
||||
// ships unexercised by test_package_io.cpp, which covers past-the-end,
|
||||
// starts-at-the-end, and zero-length only. The ordering (cheap size check first)
|
||||
// is deliberate and correct; it is not reordered to make the branch testable.
|
||||
constexpr std::uint64_t kMaxReadRangeBytes = std::uint64_t{4} << 30; // 4 GiB
|
||||
if (!ok_ || length == 0 || length > size_ || offset > size_ - length ||
|
||||
length > kMaxReadRangeBytes ||
|
||||
length > static_cast<std::uint64_t>(std::numeric_limits<std::size_t>::max())) {
|
||||
return PayloadBuffer{};
|
||||
}
|
||||
in_.clear(); // a prior failed read must not poison this one
|
||||
in_.seekg(static_cast<std::streamoff>(offset));
|
||||
if (!in_) return PayloadBuffer{};
|
||||
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(length));
|
||||
in_.read(reinterpret_cast<char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(length));
|
||||
if (static_cast<std::uint64_t>(in_.gcount()) != length) return PayloadBuffer{};
|
||||
return PayloadBuffer(std::move(bytes));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Deliberately NOT core/util/file_bytes: that loader takes an unconverted narrow path
|
||||
// (see package_path.h), and this seam's own reader already goes through utf8Path.
|
||||
PayloadBuffer readFilePayload(const std::string& absPath) {
|
||||
PackageFileReader reader(absPath);
|
||||
return reader.readRange(0, reader.fileSize());
|
||||
}
|
||||
|
||||
FileStatus fileStatus(const std::string& absPath) {
|
||||
const fs::path path = utf8Path(absPath);
|
||||
std::error_code ec;
|
||||
const fs::file_status st = fs::status(path, ec);
|
||||
// status() reports not_found through the type AND sets ec, so the type is the
|
||||
// discriminator; an ec with any other type is a real access failure.
|
||||
if (st.type() == fs::file_type::not_found) return FileStatus::Absent;
|
||||
if (ec || !fs::is_regular_file(st)) return FileStatus::Unreadable;
|
||||
std::ifstream probe(path, std::ios::binary);
|
||||
return probe ? FileStatus::Present : FileStatus::Unreadable;
|
||||
}
|
||||
|
||||
bool writeFileExclusive(const std::string& absPath, const PayloadBuffer& payload) {
|
||||
if (payload.empty()) return false;
|
||||
const fs::path path = utf8Path(absPath);
|
||||
|
||||
int fd = -1;
|
||||
#ifdef _WIN32
|
||||
if (_wsopen_s(&fd, path.wstring().c_str(),
|
||||
_O_WRONLY | _O_CREAT | _O_EXCL | _O_BINARY, _SH_DENYNO,
|
||||
_S_IREAD | _S_IWRITE) != 0) {
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644);
|
||||
#endif
|
||||
if (fd < 0) return false;
|
||||
|
||||
bool ok = true;
|
||||
std::size_t written = 0;
|
||||
while (written < payload.size()) {
|
||||
// Chunked because the Windows _write count is an unsigned int, not size_t.
|
||||
const std::size_t chunk =
|
||||
std::min<std::size_t>(payload.size() - written, 1u << 20);
|
||||
#ifdef _WIN32
|
||||
const int n = _write(fd, payload.data() + written,
|
||||
static_cast<unsigned int>(chunk));
|
||||
#else
|
||||
const ssize_t n = ::write(fd, payload.data() + written, chunk);
|
||||
if (n < 0 && errno == EINTR) continue; // a signal on the UI thread isn't a failure
|
||||
#endif
|
||||
if (n <= 0) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
written += static_cast<std::size_t>(n);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
_close(fd);
|
||||
#else
|
||||
::close(fd);
|
||||
#endif
|
||||
|
||||
if (!ok) {
|
||||
// Self-cleanup, not deletion authority: this call created the file moments
|
||||
// ago and nothing has ever referenced it (prune_fs.cpp's carve-out).
|
||||
std::error_code ec;
|
||||
fs::remove(path, ec);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::vector<std::string> listFolderFileNames(const std::string& dirAbsPath) {
|
||||
std::vector<std::string> names;
|
||||
std::error_code ec;
|
||||
// Manual iterator form (it.increment(ec)) keeps the loop non-throwing on a
|
||||
// mid-iteration failure, matching prune_fs's enumerate.
|
||||
fs::directory_iterator it(utf8Path(dirAbsPath), ec);
|
||||
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
|
||||
const auto& entry = *it;
|
||||
std::error_code reg_ec;
|
||||
if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials
|
||||
names.push_back(pathToUtf8(entry.path().filename())); // never .string(): ANSI
|
||||
}
|
||||
std::sort(names.begin(), names.end());
|
||||
return names;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,132 @@
|
||||
// shell/package/package_io — every filesystem act the export/import verbs need:
|
||||
// streaming package read/write, whole-file payload read, folder listing, file status,
|
||||
// and the exclusive create that lands one bank file. Bytes only — what a package
|
||||
// contains is core/package's business. Blocking I/O: UI-thread actions only, never
|
||||
// the audio thread.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// One entry's payload. Move-only, because a copy would silently double the bytes the
|
||||
// seam promises to hold at most one of; alive() is the counter that makes that
|
||||
// promise assertable instead of aspirational.
|
||||
class PayloadBuffer {
|
||||
public:
|
||||
PayloadBuffer() = default;
|
||||
explicit PayloadBuffer(std::vector<std::uint8_t> bytes);
|
||||
~PayloadBuffer();
|
||||
PayloadBuffer(PayloadBuffer&& other) noexcept;
|
||||
PayloadBuffer& operator=(PayloadBuffer&& other) noexcept;
|
||||
PayloadBuffer(const PayloadBuffer&) = delete;
|
||||
PayloadBuffer& operator=(const PayloadBuffer&) = delete;
|
||||
|
||||
const std::uint8_t* data() const { return bytes_.data(); }
|
||||
std::size_t size() const { return bytes_.size(); }
|
||||
bool empty() const { return bytes_.empty(); }
|
||||
|
||||
// Buffers currently holding at least one byte, process-wide.
|
||||
static int alive();
|
||||
// The largest alive() has ever been, process-wide. A point-in-time alive() == 0
|
||||
// check after a call returns cannot fail on a whole-package-in-memory shape that
|
||||
// allocated N buffers and freed them all one at a time — highWaterMark() can,
|
||||
// since it is never reset.
|
||||
static int highWaterMark();
|
||||
|
||||
private:
|
||||
void release();
|
||||
|
||||
std::vector<std::uint8_t> bytes_;
|
||||
bool counted_ = false;
|
||||
};
|
||||
|
||||
// Streaming atomic writer; paths cross this seam as UTF-8 narrow strings and are held
|
||||
// as fs::path internally. The temp sibling is created in the DESTINATION's own
|
||||
// directory so commit()'s rename never crosses a volume — a cross-device rename
|
||||
// degrades to a copy and stops being atomic. commit() REPLACES an existing
|
||||
// destination (the deliberate asymmetry against LandedFileJournal; see CLAUDE.md).
|
||||
class PackageFileWriter {
|
||||
public:
|
||||
explicit PackageFileWriter(const std::string& destAbsPath);
|
||||
~PackageFileWriter();
|
||||
PackageFileWriter(const PackageFileWriter&) = delete;
|
||||
PackageFileWriter& operator=(const PackageFileWriter&) = delete;
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
// Framing/header bytes. False on a failed or already-finished writer. A zero
|
||||
// length is accepted — framing has legitimate zero-length edges.
|
||||
bool appendRaw(const std::uint8_t* data, std::size_t len);
|
||||
// One entry's bytes. Also false — and the writer poisoned — on an EMPTY payload:
|
||||
// empty is this seam's one "nothing to work with" signal, so accepting it would
|
||||
// let a verb commit a package whose framing claims bytes nobody wrote.
|
||||
bool appendPayload(const PayloadBuffer& payload);
|
||||
// Flush, close, rename over the destination. False (and self-cleaning: the temp
|
||||
// is removed, the destination untouched) on any failure or on a second call.
|
||||
bool commit();
|
||||
// Close and remove the temp; the destination is never touched. Idempotent.
|
||||
void abort();
|
||||
|
||||
const std::filesystem::path& destPath() const { return destPath_; }
|
||||
const std::filesystem::path& tempPath() const { return tempPath_; }
|
||||
|
||||
private:
|
||||
std::filesystem::path destPath_;
|
||||
std::filesystem::path tempPath_;
|
||||
std::ofstream out_;
|
||||
bool ok_ = false;
|
||||
bool done_ = false;
|
||||
};
|
||||
|
||||
// Seek-and-read reader: exactly one payload is materialized per readRange call, and
|
||||
// there is deliberately no read-whole-file entry point. Empty buffer on ANY failure —
|
||||
// unopenable file, zero length, out of range, short read — so the caller has one
|
||||
// "nothing to work with" branch. Use fileStatus() when the two must be told apart.
|
||||
class PackageFileReader {
|
||||
public:
|
||||
explicit PackageFileReader(const std::string& srcAbsPath);
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
std::uint64_t fileSize() const { return size_; }
|
||||
// Bytes [offset, offset+length). Range-checked against the real file size, so a
|
||||
// hostile layout can never demand an allocation past the file's end, and capped
|
||||
// against a 4 GiB sanity ceiling so a merely large-but-real file can't still
|
||||
// force a multi-gigabyte allocation out of one call.
|
||||
PayloadBuffer readRange(std::uint64_t offset, std::uint64_t length);
|
||||
|
||||
private:
|
||||
std::ifstream in_;
|
||||
std::uint64_t size_ = 0;
|
||||
bool ok_ = false;
|
||||
};
|
||||
|
||||
// One source file read whole as one entry's payload — a bank file IS the streaming
|
||||
// unit. Empty on any failure, per PackageFileReader — including a source file over
|
||||
// readRange's 4 GiB ceiling, which reads as empty exactly like an unreadable file;
|
||||
// fileStatus() cannot tell the two apart either, since it only checks openability.
|
||||
PayloadBuffer readFilePayload(const std::string& absPath);
|
||||
|
||||
// Export must tell a missing indexed file from an unreadable one in its refusal
|
||||
// message; readFilePayload deliberately cannot, since both fail to an empty buffer.
|
||||
enum class FileStatus { Present, Absent, Unreadable };
|
||||
FileStatus fileStatus(const std::string& absPath);
|
||||
|
||||
// Creates absPath and writes the payload, failing if ANYTHING already occupies the
|
||||
// path. The create IS the existence check (O_EXCL / CREATE_NEW), so nothing can slip
|
||||
// in between: an exists()-then-write pair would let a file created in that window be
|
||||
// overwritten and then deleted by a rollback that believes it wrote it. Refuses an
|
||||
// empty payload, and removes its own partial file on a mid-write failure. Not
|
||||
// temp+rename — an exclusive rename has no portable spelling, and the debris a crash
|
||||
// leaves here is unrecorded and unindexed either way.
|
||||
bool writeFileExclusive(const std::string& absPath, const PayloadBuffer& payload);
|
||||
|
||||
// Bare file names (regular files only, never a path) in dirAbsPath, UTF-8, sorted so
|
||||
// callers see a deterministic order; empty on a missing or unreadable folder.
|
||||
std::vector<std::string> listFolderFileNames(const std::string& dirAbsPath);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,26 @@
|
||||
// shell/package/package_path — the ONE narrow-string <-> fs::path conversion pair for
|
||||
// this seam. std::filesystem decodes a narrow path through the RUNTIME ANSI code page
|
||||
// on Windows (measured: GetACP() == 1252 here), never UTF-8, so a bare
|
||||
// fs::path(std::string) turns every non-ASCII path this repo's UTF-8 convention
|
||||
// produces into mojibake. u8path is the C++17 spelling; it is deprecated in C++20, so
|
||||
// a standard bump replaces both bodies here rather than at every call site.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
inline std::filesystem::path utf8Path(const std::string& utf8) {
|
||||
return std::filesystem::u8path(utf8);
|
||||
}
|
||||
|
||||
// u8string() returns std::u8string in C++20 — this is the one place that narrows it
|
||||
// back to std::string, so a standard bump only widens this one body.
|
||||
inline std::string pathToUtf8(const std::filesystem::path& path) {
|
||||
const auto u8 = path.u8string();
|
||||
return std::string(u8.begin(), u8.end());
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,66 @@
|
||||
// package_pickers.cpp — see package_pickers.h. GetUserFileName cannot be null here:
|
||||
// main.cpp defines REAPERAPI_IMPLEMENT without REAPERAPI_MINIMAL, so the generated
|
||||
// resolver walks the FULL table, and it aborts the extension load if any single name
|
||||
// fails to resolve. A fallback picker would be unreachable code.
|
||||
|
||||
#include "shell/package/package_pickers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetUserFileName
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// GetUserFileName's documented pair format: label|pattern|label|pattern.
|
||||
const char kExtList[] =
|
||||
"ReaSampler bank package (*.rsbank)|*.rsbank|All files (*.*)|*.*";
|
||||
|
||||
bool runPicker(int mode, const char* caption, const char* initial,
|
||||
std::string& outAbsPath) {
|
||||
outAbsPath.clear();
|
||||
char buf[4096];
|
||||
buf[0] = '\0';
|
||||
if (!GetUserFileName(mode, caption, initial, kExtList, buf,
|
||||
static_cast<int>(sizeof(buf)))) {
|
||||
return false;
|
||||
}
|
||||
outAbsPath = buf;
|
||||
return !outAbsPath.empty();
|
||||
}
|
||||
|
||||
bool hasCaseInsensitiveSuffix(const std::string& path, const std::string& suffix) {
|
||||
if (path.size() < suffix.size()) return false;
|
||||
return std::equal(suffix.rbegin(), suffix.rend(), path.rbegin(),
|
||||
[](unsigned char a, unsigned char b) {
|
||||
return std::tolower(a) == std::tolower(b);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool pickPackageForImport(std::string& outAbsPath) {
|
||||
return runPicker(1, "Import bank package", "", outAbsPath);
|
||||
}
|
||||
|
||||
bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPath,
|
||||
bool* outAppended) {
|
||||
// [verify — DAW] GetUserFileName takes no owner window, so the dialog's parenting
|
||||
// is REAPER's to do; the previous Win32 path passed GetMainHwnd() explicitly.
|
||||
if (!runPicker(0, "Export bank package", suggestedPath.c_str(), outAbsPath)) {
|
||||
return false;
|
||||
}
|
||||
// GetUserFileName has no lpstrDefExt equivalent (the old Win32 picker's
|
||||
// ofn.lpstrDefExt = L"rsbank"); whether mode 0 appends one itself from
|
||||
// kExtList is [verify — DAW], so append it ourselves whenever it's missing.
|
||||
const bool appended = !hasCaseInsensitiveSuffix(outAbsPath, ".rsbank");
|
||||
if (appended) outAbsPath += ".rsbank";
|
||||
if (outAppended) *outAppended = appended;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,29 @@
|
||||
// shell/package/package_pickers — the two package file pickers, both on REAPER's own
|
||||
// GetUserFileName (mode 1 = existing file, mode 0 = new file). No native/platform
|
||||
// split: the SDK's save mode is not optional on any build that can load this
|
||||
// extension. Paths in and out are UTF-8, matching this tree's established practice
|
||||
// for narrow strings crossing the REAPER API (see instrument_drop_win.cpp's
|
||||
// path.u8string() to TrackFX_SetPreset, or prune_fs.cpp's CP_UTF8 conversion) — the
|
||||
// SDK header itself never says "UTF-8".
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// True with outAbsPath set iff the user chose a file.
|
||||
bool pickPackageForImport(std::string& outAbsPath);
|
||||
|
||||
// suggestedPath is a bare file name ("MyBank.rsbank") or a full path — a full one
|
||||
// also seeds the dialog's starting directory, which is how a caller keeps the picker
|
||||
// off REAPER's process working directory. True with outAbsPath set iff the user chose
|
||||
// a destination; the dialog's own overwrite confirm has already run by then, against
|
||||
// the path the user actually chose — NOT necessarily outAbsPath, if the `.rsbank`
|
||||
// re-append below fires. outAppended, when non-null, is set to whether it fired: the
|
||||
// caller's signal that its own overwrite consent may not cover the returned path
|
||||
// (see this directory's CLAUDE.md).
|
||||
bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPath,
|
||||
bool* outAppended = nullptr);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,51 @@
|
||||
// package_rollback.cpp — see package_rollback.h. The rollback delete below runs under
|
||||
// the ONE carve-out from prune's exclusive file-deletion authority, stated at
|
||||
// src/shell/persist/prune_fs.cpp:5-11. That discriminator has two clauses and this
|
||||
// journal makes only the FIRST structural: "did this call create it" is guaranteed by
|
||||
// recording exclusively-created paths, but "did anything ever reference it" is a
|
||||
// claim about the caller's ordering — hence markIndexCommitted(), which the import
|
||||
// verb must fire at the index commit so a later rollback() refuses instead of
|
||||
// deleting indexed files.
|
||||
|
||||
#include "shell/package/package_rollback.h"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include "shell/package/package_path.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
bool LandedFileJournal::writeLandedFile(const std::string& destPath,
|
||||
const PayloadBuffer& payload) {
|
||||
if (indexCommitted_) return false;
|
||||
|
||||
std::error_code ec;
|
||||
const fs::path resolved = fs::absolute(utf8Path(destPath), ec);
|
||||
if (ec) return false;
|
||||
const std::string absPath = pathToUtf8(resolved);
|
||||
|
||||
if (!writeFileExclusive(absPath, payload)) return false;
|
||||
paths_.push_back(absPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
RollbackResult LandedFileJournal::rollback() {
|
||||
RollbackResult result;
|
||||
if (indexCommitted_) {
|
||||
result.refused = true;
|
||||
return result;
|
||||
}
|
||||
for (const std::string& path : paths_) {
|
||||
std::error_code ec;
|
||||
const bool removed = fs::remove(utf8Path(path), ec);
|
||||
if (removed) ++result.deletedCount;
|
||||
else if (ec) ++result.failedCount;
|
||||
else ++result.alreadyAbsentCount; // no error, nothing there
|
||||
}
|
||||
paths_.clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,59 @@
|
||||
// shell/package/package_rollback — the files ONE import call has landed, as a
|
||||
// journal: writes record themselves on success, and rollback() deletes exactly what
|
||||
// is recorded. The deletion carve-out this satisfies, and the half of it the caller
|
||||
// still owns, are at package_rollback.cpp's header.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "shell/package/package_io.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
struct RollbackResult {
|
||||
int deletedCount = 0;
|
||||
int alreadyAbsentCount = 0; // vanished between land and rollback — not a failure
|
||||
int failedCount = 0; // locked / permission — recorded, never thrown
|
||||
bool refused = false; // markIndexCommitted() ran: nothing was deleted
|
||||
};
|
||||
|
||||
// Destroying an armed (uncommitted, un-rolled-back) journal is NOT an implicit
|
||||
// rollback — the caller must call rollback() itself on the failure path it wants
|
||||
// to undo. That's the fail-safe direction: a journal dropped by an unrelated early
|
||||
// return leaves the landed files in place rather than silently deleting them.
|
||||
class LandedFileJournal {
|
||||
public:
|
||||
// Lands one payload at destPath through the exclusive create (which refuses an
|
||||
// occupied path outright — a bank-folder file is never overwritten, and collision
|
||||
// handling is the import plan's job upstream) and records it on success. An empty
|
||||
// payload is refused, per writeFileExclusive. Relative paths are resolved against
|
||||
// the process CWD before the write, so the journal's record is always absolute
|
||||
// and a later CWD change cannot re-aim the delete. Refused once
|
||||
// markIndexCommitted() has run.
|
||||
bool writeLandedFile(const std::string& destPath, const PayloadBuffer& payload);
|
||||
|
||||
// Disarms the journal: the index mutation these files back is committed, so they
|
||||
// are now referenced bytes and the carve-out no longer covers them. This is the
|
||||
// half of prune's discriminator the journal cannot make structural on its own —
|
||||
// the import verb MUST call this only AFTER the index write has returned success.
|
||||
// Calling it before, then having that write fail, strands the landed files with
|
||||
// no index entry and a journal that now refuses to roll them back.
|
||||
void markIndexCommitted() { indexCommitted_ = true; }
|
||||
bool indexCommitted() const { return indexCommitted_; }
|
||||
|
||||
// Deletes exactly the recorded files and clears the journal, so a second call is
|
||||
// a no-op. Hard unlink, not trash: nothing ever referenced these bytes. Refuses
|
||||
// (deleting nothing, keeping the record) once markIndexCommitted() has run.
|
||||
RollbackResult rollback();
|
||||
|
||||
const std::vector<std::string>& landedPaths() const { return paths_; }
|
||||
bool empty() const { return paths_.empty(); }
|
||||
|
||||
private:
|
||||
std::vector<std::string> paths_;
|
||||
bool indexCommitted_ = false;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -48,7 +48,7 @@ live in `shell/bank_ops`, a sibling directory, not here.
|
||||
## Modules
|
||||
|
||||
- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into eight TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU.
|
||||
- `panel_window` — SWELL dialog lifecycle + dialog proc + drop-target opt-in.
|
||||
- `panel_window` — SWELL dialog lifecycle + dialog proc + drop-target opt-in. The drop splits by extension: a `.rsbank` is a whole bank and routes to the package-import action (one NEW bank each), everything else keeps the audio-ingest route.
|
||||
- `panel_layout` — toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read).
|
||||
- `panel_render` — the WM_PAINT draw.
|
||||
- `panel_input` — click/wheel/keyboard routing + the new-content auto-tag timer.
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include "shell/panel/panel_state.h"
|
||||
#include "shell/panel/panel_bank_ops.h"
|
||||
|
||||
#include "shell/actions/package_export_action.h" // doBankPackageExport — the export skin
|
||||
#include "shell/actions/package_import_action.h" // doImportBankPackage — the menu's import row
|
||||
#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs
|
||||
#include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate
|
||||
|
||||
@@ -241,7 +243,9 @@ enum : unsigned int {
|
||||
kMenuDelete,
|
||||
kMenuEvacuate,
|
||||
kMenuCreate,
|
||||
kMenuExport, // export this bank as a .rsbank package
|
||||
kMenuRemove, // remove selected sample(s) from the source bank
|
||||
kMenuImportPackage, // land a .rsbank as a NEW bank (never merges into this one)
|
||||
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
|
||||
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
|
||||
};
|
||||
@@ -249,7 +253,7 @@ enum : unsigned int {
|
||||
} // namespace
|
||||
|
||||
// Shows the right-click context menu for a named-bank TAB: activate / rename / delete
|
||||
// / evacuate that bank, plus a create entry. Drives the id-keyed ops.
|
||||
// / evacuate / export that bank, plus a create entry. Drives the id-keyed ops.
|
||||
void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
||||
if (!book()) return;
|
||||
const Bank* bk = book()->bank(bankId);
|
||||
@@ -265,8 +269,10 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
||||
menuAppend(menu, kMenuRename, "Rename...");
|
||||
menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty);
|
||||
menuAppend(menu, kMenuDelete, "Delete...");
|
||||
menuAppend(menu, kMenuExport, "Export as package...");
|
||||
menuSeparator(menu);
|
||||
menuAppend(menu, kMenuCreate, "New bank...");
|
||||
menuAppend(menu, kMenuImportPackage, "Import bank package...");
|
||||
|
||||
const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
|
||||
g_panel.hwnd, nullptr);
|
||||
@@ -277,7 +283,20 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
||||
case kMenuRename: doRenameBank(bankId); break;
|
||||
case kMenuEvacuate: doEvacuateBank(bankId); break;
|
||||
case kMenuDelete: doDeleteBank(bankId); break;
|
||||
case kMenuExport: doBankPackageExport(*g_panel.session, bankId); break;
|
||||
case kMenuCreate: doCreateBank(); break;
|
||||
// Always a NEW bank, never a merge into the right-clicked one — the row sits
|
||||
// here because this is the panel's bank menu, not because it targets this bank.
|
||||
case kMenuImportPackage:
|
||||
if (g_panel.session) {
|
||||
const std::string id = doImportBankPackage(*g_panel.session);
|
||||
if (!id.empty()) { // landed — show the freshly-imported bank
|
||||
g_panel.shownBankId = id;
|
||||
g_panel.focusedRegion = Region::Banks;
|
||||
invalidatePanel();
|
||||
}
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Compiled into the reaper_reasampler MODULE, without REAPERAPI_IMPLEMENT (main.cpp
|
||||
// owns the API pointers). DAW-verified, not unit-tested.
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
@@ -163,11 +164,19 @@ bool detectNewContent() {
|
||||
std::map<std::string, std::vector<std::string>> trackItemGuids;
|
||||
enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids);
|
||||
|
||||
const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
|
||||
std::vector<std::string> added = g_panel.contentBaseline.observe(live);
|
||||
if (added.empty()) return false; // first poll after open, or nothing new this tick
|
||||
|
||||
ViewModeModel& model = g_panel.session->view();
|
||||
|
||||
// An explicit tag wins: this detector classifies content the USER made, not
|
||||
// content the tool made and already classified.
|
||||
added.erase(std::remove_if(added.begin(), added.end(),
|
||||
[&](const std::string& g) {
|
||||
return model.membership().query(g) != nullptr;
|
||||
}),
|
||||
added.end());
|
||||
|
||||
// Which of `added` are items (the manual-lane map keys every item; track GUIDs never
|
||||
// appear there). Used below to exclude sibling new items from a track's PRE-EXISTING
|
||||
// mode set — a drop plus its own new siblings must not count each other as prior.
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
|
||||
#include "shell/panel/draw_kit.h"
|
||||
#include "shell/actions/ingest.h"
|
||||
#include "shell/actions/package_import_action.h"
|
||||
#include "core/package/import_plan.h"
|
||||
#include "core/version/app_version.h"
|
||||
#include "shell/persist/session.h" // ReaSamplerSession::ledgerStatus() — panel_state.h only forward-declares it
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
||||
@@ -28,6 +32,7 @@
|
||||
#define REAPERAPI_WANT_DockWindowActivate
|
||||
#define REAPERAPI_WANT_DockWindowRemove
|
||||
#define REAPERAPI_WANT_GetMainHwnd
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
// main.cpp owns the module instance handle.
|
||||
@@ -40,12 +45,24 @@ PanelState g_panel;
|
||||
|
||||
namespace {
|
||||
|
||||
bool isPackagePath(const std::string& path) {
|
||||
static const std::string kExt = ".rsbank";
|
||||
if (path.size() <= kExt.size()) return false;
|
||||
std::string tail = path.substr(path.size() - kExt.size());
|
||||
for (char& c : tail)
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||
return tail == kExt;
|
||||
}
|
||||
|
||||
// DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; each path is then
|
||||
// queried by index (length first, excludes NUL, then a sized buffer). DragFinish
|
||||
// always frees the shell-allocated drop buffer. Multi-file drop imports all into
|
||||
// the active bank (bank-fill only — no assignment to any live instance).
|
||||
// always frees the shell-allocated drop buffer. A .rsbank is a whole bank, not audio,
|
||||
// so it routes to the import verb (one new bank each); everything else keeps the
|
||||
// existing ingest route — multi-file drop imports all into the active bank (bank-fill
|
||||
// only, no assignment to any live instance).
|
||||
void handleDropFiles(HDROP hDrop) {
|
||||
std::vector<std::string> paths;
|
||||
std::vector<std::string> packages;
|
||||
const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0);
|
||||
paths.reserve(count);
|
||||
for (UINT i = 0; i < count; ++i) {
|
||||
@@ -54,9 +71,26 @@ void handleDropFiles(HDROP hDrop) {
|
||||
std::vector<char> buf(static_cast<std::size_t>(len) + 1, '\0');
|
||||
DragQueryFile(hDrop, i, buf.data(), static_cast<UINT>(buf.size()));
|
||||
std::string p(buf.data());
|
||||
if (!p.empty()) paths.push_back(std::move(p));
|
||||
if (p.empty()) continue;
|
||||
if (isPackagePath(p)) packages.push_back(std::move(p));
|
||||
else paths.push_back(std::move(p));
|
||||
}
|
||||
DragFinish(hDrop);
|
||||
if (g_panel.session && !packages.empty()) {
|
||||
// One refusal block for the whole drop, not one per dropped .rsbank: the gate
|
||||
// decision is the same for all N (session state does not change mid-drop), so
|
||||
// checking it here first avoids doImportBankPackageFile's own per-file gate
|
||||
// check printing the identical console block N times.
|
||||
const package::LedgerRefusal refusal =
|
||||
package::importLedgerRefusal(g_panel.session->ledgerStatus());
|
||||
if (refusal != package::LedgerRefusal::None) {
|
||||
ShowConsoleMsg(
|
||||
package::ledgerRefusalMessage(refusal, version::extStateNamespace()).c_str());
|
||||
} else {
|
||||
for (const std::string& pkg : packages)
|
||||
doImportBankPackageFile(*g_panel.session, pkg);
|
||||
}
|
||||
}
|
||||
if (!paths.empty()) ingestDroppedFiles(paths);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,19 @@ public:
|
||||
// treatment.
|
||||
void recordCreated(const model::Sample& sample, tracking::OriginKind kind);
|
||||
|
||||
// Whether a birth record can be written at all this session — the status WITHOUT
|
||||
// the records. That is not a hole in the pairing rule above: the rule exists so an
|
||||
// absent record is never read as a definite answer, and this exposes strictly less
|
||||
// than the pair. The package import gates on it before it opens a file picker.
|
||||
//
|
||||
// A tradeoff, not the only route: `pruneDryRun()` already exposes the same degraded
|
||||
// pair via `PruneReport::ledgerUnreadable`/`ledgerFutureVersion`, with no new
|
||||
// accessor needed. Rejected because that route is genuinely worse for a gate: it
|
||||
// drags a full bank-folder enumeration and every live instance's FX scan onto a
|
||||
// check that only needs to know "can I write a record", and it shapes an import
|
||||
// decision as an answer borrowed from prune's report rather than the session's own.
|
||||
tracking::LedgerStatus ledgerStatus() const { return trackingStatus_; }
|
||||
|
||||
// The version that last wrote the active project: PreVersioning (no
|
||||
// stamp), Unknown (malformed), or Stamped.
|
||||
const version::WritingVersion& writingVersion() const { return writingVersion_; }
|
||||
|
||||
@@ -34,7 +34,10 @@ decide membership or mode rules.
|
||||
value BEFORE parking; on toggle-back restore FROM the snapshot, never to a
|
||||
hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag
|
||||
to its captured value — the phase's trust anchor, the analog of the capture null
|
||||
test.
|
||||
test. Per-FX offline is snapshotted WITH each FX's identity (`TrackFX_GetFXGUID`)
|
||||
and restored through `core/view/fx_offline`, so a chain reordered while the track
|
||||
was parked cannot land one plugin's state on another; an FX gone at restore time
|
||||
is dropped and reported to the console, never restored onto its old slot.
|
||||
- **GUID-keyed, reorder-safe.** Membership/snapshot keys on track GUID
|
||||
(`GetTrackGUID`), never track index; tolerates unknown/stale GUIDs (pruned on
|
||||
reconcile).
|
||||
@@ -100,3 +103,12 @@ applies the resulting lane state to live tracks.
|
||||
under whatever mode id is active at that point, not the one the user undid back
|
||||
to. Pre-existing: `snapshots_` already carries this same model-vs-undo split;
|
||||
the solo cache inherits it rather than introducing it. Not fixed here.
|
||||
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
|
||||
attached to its plugin across a chain mutation while parked. That is
|
||||
`[verify — DAW]` (see `fxGuidString` in `view.cpp`) and SWS issue #802 is a
|
||||
known reason it might not hold: `SNM_MoveOrRemoveTrackFX` reportedly leaves
|
||||
the FXID lines behind on reorder rather than moving them with the plugin. If
|
||||
confirmed, an SWS-driven reorder of a parked track's chain — not a native
|
||||
drag-reorder — can produce wrong-plugin restores or mass drops through
|
||||
`resolveFxRestore`. Do not design around this pre-emptively; if native
|
||||
reorder is clean (the likely case), only the SWS path degrades.
|
||||
|
||||
+71
-20
@@ -14,6 +14,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "shell/capture/item_read.h"
|
||||
#include "core/view/fx_offline.h"
|
||||
#include "core/view/lane_keys.h"
|
||||
#include "core/view/solo_cache.h"
|
||||
#include "shell/capture/track_guid.h"
|
||||
@@ -27,9 +28,12 @@
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||
#define REAPERAPI_WANT_TrackFX_GetFXGUID
|
||||
#define REAPERAPI_WANT_TrackFX_GetOffline
|
||||
#define REAPERAPI_WANT_TrackFX_SetOffline
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#define REAPERAPI_WANT_TrackList_AdjustWindows
|
||||
@@ -123,6 +127,40 @@ MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& hand
|
||||
return nullptr; // stale/deleted GUID — pruned by being skipped
|
||||
}
|
||||
|
||||
// The FX's own durable identity, braced exactly like the track GUID keys. Empty
|
||||
// when REAPER reports none — an FX we cannot name is one we cannot restore, and
|
||||
// fx_offline treats it that way rather than guessing at its slot. Lifetime is
|
||||
// settled: the string copy is taken immediately and the GUID* is never held
|
||||
// past this call (reaper_plugin_functions.h:7348 documents no null contract for
|
||||
// TrackFX_GetFXGUID; treating null as "no identity" is the safe read).
|
||||
//
|
||||
// [verify — DAW] STABILITY across a chain mutation is not settled the same way:
|
||||
// confirm the GUID for one FX instance survives a native drag-reorder, an SWS
|
||||
// move (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not
|
||||
// follow the plugin after that call, i.e. wrong-plugin restores or mass drops
|
||||
// through fx_offline on that path specifically), a save/reload round trip, and
|
||||
// two live instances of one plugin type staying distinguishable. See
|
||||
// src/shell/view/CLAUDE.md's Gotchas for the SWS-path risk this leaves open.
|
||||
std::string fxGuidString(MediaTrack* tr, int fx) {
|
||||
GUID* g = TrackFX_GetFXGUID(tr, fx);
|
||||
if (!g) return {};
|
||||
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
|
||||
guidToString(g, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// The chain as it stands now: identity by current slot. Snapshot, park and
|
||||
// restore all address FX through this one plain 0..TrackFX_GetCount-1
|
||||
// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so
|
||||
// whatever it covers, all three cover identically.
|
||||
std::vector<std::string> liveFxGuids(MediaTrack* tr) {
|
||||
const int fxCount = TrackFX_GetCount(tr);
|
||||
std::vector<std::string> guids;
|
||||
guids.reserve(static_cast<std::size_t>(fxCount));
|
||||
for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx));
|
||||
return guids;
|
||||
}
|
||||
|
||||
// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO;
|
||||
// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract).
|
||||
TrackSnapshot snapshotTrack(MediaTrack* tr) {
|
||||
@@ -132,12 +170,13 @@ TrackSnapshot snapshotTrack(MediaTrack* tr) {
|
||||
snap.mainSend = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
|
||||
snap.fxEnable = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));
|
||||
|
||||
int fxCount = TrackFX_GetCount(tr);
|
||||
snap.fxOffline.reserve(static_cast<std::size_t>(fxCount));
|
||||
for (int fx = 0; fx < fxCount; ++fx) {
|
||||
snap.fxOffline.push_back(TrackFX_GetOffline(tr, fx) ? 1 : 0);
|
||||
const std::vector<std::string> guids = liveFxGuids(tr);
|
||||
snap.fxOffline.reserve(guids.size());
|
||||
for (std::size_t fx = 0; fx < guids.size(); ++fx) {
|
||||
snap.fxOffline.push_back(
|
||||
FxOfflineState{guids[fx], TrackFX_GetOffline(tr, static_cast<int>(fx)) ? 1 : 0});
|
||||
}
|
||||
return snap;
|
||||
return snap; // fxKeying stays Identity — a live capture always knows the chain
|
||||
}
|
||||
|
||||
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags) {
|
||||
@@ -155,20 +194,13 @@ void parkFxOffline(MediaTrack* tr) {
|
||||
}
|
||||
}
|
||||
|
||||
// Restores per-FX offline from the snapshot verbatim — each slot back to its
|
||||
// captured value, never a blanket "online" — bounds-checked against the live
|
||||
// FX count (prune-safe if the chain changed while parked).
|
||||
//
|
||||
// HAZARD (open, tracked in docs/TODO.md): this remaps by slot INDEX, not
|
||||
// plugin identity. If the FX chain reshuffled while parked, snapshot slot k
|
||||
// restores onto whatever plugin now occupies slot k. Accepted for now;
|
||||
// identity-based reconciliation is future hardening.
|
||||
void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
|
||||
int fxCount = TrackFX_GetCount(tr);
|
||||
for (const FxOfflineOp& op : fxOffline) {
|
||||
if (op.fxIndex < 0 || op.fxIndex >= fxCount) continue;
|
||||
TrackFX_SetOffline(tr, op.fxIndex, op.offline);
|
||||
}
|
||||
// Restores per-FX offline from the snapshot verbatim — never a blanket "online".
|
||||
// Which live FX each captured state belongs to is resolveFxRestore's call, and
|
||||
// what it could not place comes back for the caller to report.
|
||||
FxRestoreDrops restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
|
||||
const FxRestoreResolution res = resolveFxRestore(fxOffline, liveFxGuids(tr));
|
||||
for (const FxOfflineWrite& w : res.writes) TrackFX_SetOffline(tr, w.fxIndex, w.offline);
|
||||
return res.drops;
|
||||
}
|
||||
|
||||
// Managed-lane application: the pure planner keys LanePlayOps by the lane's
|
||||
@@ -451,6 +483,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
}
|
||||
|
||||
// RESTORE: apply verbatim, then drop the consumed snapshot.
|
||||
FxRestoreDrops fxDrops;
|
||||
int fxDropTracks = 0;
|
||||
for (const TrackPlan& tp : plan.restore) {
|
||||
if (tp.flags.empty()) continue;
|
||||
const std::string& guid = tp.flags.front().guid;
|
||||
@@ -458,10 +492,27 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
|
||||
applyFlags(tr, tp.flags);
|
||||
restoreFxOffline(tr, tp.fxOffline);
|
||||
const FxRestoreDrops drops = restoreFxOffline(tr, tp.fxOffline);
|
||||
if (drops.total() > 0) {
|
||||
fxDrops.add(drops);
|
||||
++fxDropTracks;
|
||||
}
|
||||
model.clearSnapshot(guid);
|
||||
}
|
||||
|
||||
// Captured FX state that could not be applied is REPORTED. Silence here would
|
||||
// read to the user as "restore worked" while an FX sat at whatever state the
|
||||
// park left it in. Sent with the "!SHOW:" prefix (reaper_plugin_functions.h:6536)
|
||||
// so it never force-opens the console window: applyMode's reapply path also
|
||||
// runs unattended on project load (see the reconcile comment above), and this
|
||||
// one call site can't tell that case apart from an interactive toggle/tag-edit
|
||||
// reapply — both call in with target == active — so splitting loud-on-toggle
|
||||
// from quiet-on-load would need a flag threaded from every caller, several of
|
||||
// which are outside this change. Quiet-always is the safe default: the message
|
||||
// still lands in the console for whoever opens it, on every path.
|
||||
const std::string fxDropMsg = describeFxRestoreDrops(fxDrops, fxDropTracks);
|
||||
if (!fxDropMsg.empty()) ShowConsoleMsg(("!SHOW:" + fxDropMsg).c_str());
|
||||
|
||||
// MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows
|
||||
// and every other managed lane is silenced+hidden. Empty for a D1-only
|
||||
// project, leaving that behavior byte-identical.
|
||||
|
||||
Reference in New Issue
Block a user