#pragma once // capture_paths — the REAPER-free path arithmetic behind offline capture. The // capture shell resolves the current project directory via REAPER APIs, then // hands the raw strings here. Forward-slash form throughout, no filesystem // access; the bank subfolder name is a fixed constant. #include #include #include #include namespace reasampler::capture { // The project-relative bank subfolder. All captured wavs live here so the bank // travels with the .rpp. inline constexpr const char* kBankSubfolder = "reasampler_bank"; // A resolved pair of paths for one capture: where REAPER must be told to write // (absolute, because RENDER_FILE wants a directory REAPER can create/open) and // what we store in the BankModel (project-relative, because the index is // relative-paths-only — CLAUDE.md precision invariant). struct BankPaths { std::string absoluteDir; // /reasampler_bank (forward slash) std::string relativePath; // reasampler_bank/ (index value) std::string fileName; // .wav (full file name) std::string fileStem; // (RENDER_PATTERN — REAPER appends the extension) }; // Normalizes a path to forward slashes and strips any trailing slash (does not // consult the filesystem). On Windows (_WIN32) also lowercases the result so // paths differing only in casing compare equal; macOS/Linux preserve case. std::string normalizeSlashes(const std::string& path); // Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps // [A-Za-z0-9._-], replaces every other byte with '_', and collapses to // "capture" if nothing usable remains. Deterministic. std::string sanitizeStem(const std::string& baseName); // Derives the bank paths for one capture: baseName is the sanitized file-stem // source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so // repeated captures don't collide. Produces "[_].wav". BankPaths deriveBankPaths(const std::string& projectDir, const std::string& baseName, const std::string& uniqueTag); // The project-relative index spelling for a bank file known only by its file // name (bare entry, no directory) — the prune shell uses this to spell an // enumerated folder entry the SAME way deriveBankPaths spelled it at capture // time; a divergence here could make a referenced file look like an orphan. std::string bankRelativeForName(const std::string& fileName); // --- Persist-side path arithmetic ------------------------------------------- // // The index stores relative paths only; on project load the persist shell // turns each relativePath back into an absolute path against the current // project directory — the inverse of deriveBankPaths. // Returns "/" forward-slashed, or empty if either // input is empty (no default-location fallback — an unsaved/unset project // fails loudly rather than resolving against CWD). std::string resolveBankFile(const std::string& projectDir, const std::string& relativePath); // The project directory that holds a .rpp: parent directory, forward-slashed, // trailing slash stripped. Empty in -> empty out (an unsaved project reports // an empty .rpp path). Pure so the VST3 instrument resolves audio paths the // same way persist does. std::string projectDirOfRpp(const std::string& rppPath); // A relocation plan for the physical bank folder on Save-As to a new project // location. The index's relative paths do NOT change (they are relative to the // project dir, which moved with the .rpp), so relocation is purely a folder // move. Both dirs are absolute, forward-slashed, trailing-slash-stripped. struct BankRelocation { std::string oldBankDir; // /reasampler_bank std::string newBankDir; // /reasampler_bank bool needed = false; // false when old==new (Save in place, not Save-As) }; // Derives the relocation plan: `needed` is true iff the normalized old/new // project dirs differ (a genuine Save-As-to-new-dir); empty dirs/needed=false // when either input is empty. BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, const std::string& newProjectDir); // --- Project-identity transition --------------------------------------------- // // What the persist timer must do on each tick. GUID is checked FIRST because // two prior pointer-primary/GUID-only designs each broke a real case: a // GUID-only check misreads a Save-As fork as the same project (fork and // parent share a GUID on disk); a pointer-primary check misreads REAPER // recycling a closed project's ReaProject* address onto an unrelated project // (a different project, same recycled pointer, read as NoOp/SaveAsRelocate — // the bank never reloads). Checking GUID first catches recycling; the pointer // (sameProjectObject) then separates a forked sibling (Load) from a genuine // Save-As (SaveAsRelocate). enum class ProjectTransition { NoOp, // same object, same GUID, same location — nothing to do Load, // a different project is active — load ITS index from ext state SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank }; // Classifies what a poll tick observed. sameProjectObject is passed as a bool // (not the raw pointer) to keep the classifier REAPER-free and testable; // lastGuid/lastPath is the project persist last acted on, currentGuid/ // currentPath the now-active project (both "" if unsaved/unwritten). // Evaluated in order: currentGuid!=lastGuid -> Load; !sameProjectObject -> // Load (forked sibling); currentPath!=lastPath -> SaveAsRelocate (also covers // first save of an unsaved project); else NoOp. ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& lastGuid, const std::string& lastPath, const std::string& currentGuid, const std::string& currentPath); } // namespace reasampler::capture