#include "capture_paths.h" #include #include #include #include #include // std::memcmp #include namespace reasampler { std::string hashBytes(const std::uint8_t* data, std::size_t len) { // FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity. // Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/). constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL; constexpr std::uint64_t kPrime = 1099511628211ULL; std::uint64_t h = kOffsetBasis; for (std::size_t i = 0; i < len; ++i) { h ^= static_cast(data[i]); h *= kPrime; } // Format as 16-digit lowercase hex (zero-padded) for a fixed-length string. char buf[17]; std::snprintf(buf, sizeof(buf), "%016llx", static_cast(h)); return std::string(buf); } std::string hashWavContent(const std::vector& bytes) { // Walk the RIFF/WAVE container and feed only the `fmt ` body and `data` body // through FNV-1a, prefixed with the domain-separation tag byte 'W' (0x57). // Any render-varying metadata chunks (bext, iXML, LIST, SMED, etc.) are skipped. // If the file does not parse as RIFF/WAVE with both fmt and data chunks, fall back // to whole-file hashBytes (no prefix) so an unrecognized file still gets a hash. // // The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates // FNV state instead of recording geometry — no second parser, same logic. // FNV-1a 64-bit constants (same as hashBytes). constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL; constexpr std::uint64_t kPrime = 1099511628211ULL; // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes. auto tagEq = [&](std::size_t off, const char* tag) -> bool { return off + 4 <= bytes.size() && std::memcmp(bytes.data() + off, tag, 4) == 0; }; auto readU32LE = [&](std::size_t off) -> std::uint32_t { return static_cast(bytes[off]) | (static_cast(bytes[off + 1]) << 8) | (static_cast(bytes[off + 2]) << 16) | (static_cast(bytes[off + 3]) << 24); }; bool isWav = bytes.size() >= 12 && tagEq(0, "RIFF") && tagEq(8, "WAVE"); if (isWav) { // Accumulate FNV-1a starting with the domain-separation tag byte 'W'. std::uint64_t h = kOffsetBasis; auto feedByte = [&](std::uint8_t b) { h ^= static_cast(b); h *= kPrime; }; bool haveFmt = false; bool haveData = false; // Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a // whole-file hash of different bytes that happen to be the same length. feedByte(static_cast('W')); std::size_t pos = 12; while (pos + 8 <= bytes.size()) { const std::size_t bodyOffset = pos + 8; const std::uint32_t bodySize = readU32LE(pos + 4); if (tagEq(pos, "fmt ")) { // Feed the entire fmt body (all fields, including format tag, channels, // sample rate, bits-per-sample — everything that defines the audio format). if (bodyOffset + bodySize <= bytes.size()) { for (std::uint32_t i = 0; i < bodySize; ++i) feedByte(bytes[bodyOffset + i]); haveFmt = true; } } else if (tagEq(pos, "data")) { // Feed the entire PCM payload. if (bodyOffset + bodySize <= bytes.size()) { for (std::uint32_t i = 0; i < bodySize; ++i) feedByte(bytes[bodyOffset + i]); haveData = true; } } // All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped. // Advance past this chunk's body, honoring RIFF even-byte padding. std::size_t advance = bodySize; if (advance & 1u) ++advance; // RIFF pad byte if (advance > bytes.size() - bodyOffset) break; // overrun guard pos = bodyOffset + advance; } if (haveFmt && haveData) { char buf[17]; std::snprintf(buf, sizeof(buf), "%016llx", static_cast(h)); return std::string(buf); } // Falls through to whole-file fallback if chunks were missing/malformed. } // Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old // per-call hashBytes). No prefix tag: identical to hashBytes(data, size). return hashBytes(bytes.data(), bytes.size()); } std::string normalizeSlashes(const std::string& path) { std::string out = path; for (char& c : out) { if (c == '\\') c = '/'; } // Strip a single trailing slash so joins do not double up. Preserve a lone // "/" (root) — stripping it would turn root into empty. if (out.size() > 1 && out.back() == '/') { out.pop_back(); } #ifdef _WIN32 // Windows paths are case-insensitive. Fold to lowercase so that two paths // that differ only in drive-letter or component casing compare equal (e.g. // "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is // preserved (the filesystem is case-sensitive; folding would be wrong). for (char& c : out) c = static_cast(std::tolower(static_cast(c))); #endif return out; } std::string sanitizeStem(const std::string& baseName) { std::string out; out.reserve(baseName.size()); for (unsigned char c : baseName) { const bool keep = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; out.push_back(keep ? static_cast(c) : '_'); } // Collapse to a stable default if nothing usable survived (e.g. all spaces). // A stem of only separators ('.', '_', '-') is also unhelpful as a name. bool hasAlnum = false; for (unsigned char c : out) { if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { hasAlnum = true; break; } } if (out.empty() || !hasAlnum) { return "capture"; } return out; } BankPaths deriveBankPaths(const std::string& projectDir, const std::string& baseName, const std::string& uniqueTag) { const std::string dir = normalizeSlashes(projectDir); std::string stem = sanitizeStem(baseName); if (!uniqueTag.empty()) { stem += "_" + sanitizeStem(uniqueTag); } const std::string fileName = stem + ".wav"; // Precondition: the capture shell must resolve a non-empty project directory // before calling this function. An empty projectDir would produce a bare // relative "reasampler_bank" path — the silent default-location fallback this // tool explicitly forbids. Assert in debug; leave absoluteDir empty in release // so any caller that ignores the precondition fails loudly at the render/stat // step rather than silently writing to CWD. assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty"); BankPaths p; p.fileStem = stem; // stem only — REAPER appends extension p.fileName = fileName; p.relativePath = std::string(kBankSubfolder) + "/" + fileName; // absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the // directory itself; RENDER_PATTERN supplies the file name separately). // Empty when precondition is violated (dir empty) — caller must not proceed. p.absoluteDir = dir.empty() ? std::string{} : dir + "/" + kBankSubfolder; return p; } std::string bankRelativeForName(const std::string& fileName) { if (fileName.empty()) return {}; // The SAME expression deriveBankPaths uses for relativePath, kept in one place so // the two spellings can never drift (Phase R spelling-consistency invariant). return std::string(kBankSubfolder) + "/" + fileName; } std::string resolveBankFile(const std::string& projectDir, const std::string& relativePath) { // No default-location fallback (CLAUDE.md invariant): an empty project dir or // relative path yields empty, not a bare relative path resolved against CWD. if (projectDir.empty() || relativePath.empty()) { return {}; } const std::string dir = normalizeSlashes(projectDir); const std::string rel = normalizeSlashes(relativePath); if (dir.empty() || rel.empty()) { return {}; } return dir + "/" + rel; } BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, const std::string& newProjectDir) { BankRelocation r; if (oldProjectDir.empty() || newProjectDir.empty()) { return r; // needed=false, empty dirs — nothing to relocate } const std::string oldDir = normalizeSlashes(oldProjectDir); const std::string newDir = normalizeSlashes(newProjectDir); r.oldBankDir = oldDir + "/" + kBankSubfolder; r.newBankDir = newDir + "/" + kBankSubfolder; // A Save (in place) leaves the project dir unchanged — nothing to relocate. // Only a Save-As to a different directory needs the bank moved. r.needed = (oldDir != newDir); return r; } ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& lastGuid, const std::string& lastPath, const std::string& currentGuid, const std::string& currentPath) { // 1. The GUID is the identity of record and is checked FIRST. A different // stored GUID means a genuinely different project is active — Load ITS index. // This catches the regression that pointer-primary classification missed: // REAPER RECYCLES ReaProject* addresses across close/open, so a reopened / // new project can reuse the previous project's address (sameProjectObject == // true) while carrying a different stored GUID. Deciding on the pointer alone // then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is // immune to address recycling, so it leads. Also covers new/unsaved<->saved // transitions (one GUID empty, the other not) and switching between two // distinct saved projects. if (currentGuid != lastGuid) { return ProjectTransition::Load; } // From here currentGuid == lastGuid (they are equal; both may be empty for // unsaved projects). The pointer now disambiguates the same-GUID case. // 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our // GUID onto a distinct project object. Load its (own) index; never relocate. // Two unsaved projects (both GUIDs empty, distinct objects) also land here — // Load, so switching between them installs the right in-memory state. if (!sameProjectObject) { return ProjectTransition::Load; } // 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object // identity is proven and the record identity is unchanged — only the .rpp // moved). Also the first save of an unsaved project (both GUIDs empty, old // path empty): SaveAsRelocate is safe there because deriveRelocationPlan // no-ops on the empty old dir (empty-GUID safety preserved) while poll() // mints a GUID. if (currentPath != lastPath) { return ProjectTransition::SaveAsRelocate; } // 4. Same object, same GUID, same path — Save in place / idle tick. return ProjectTransition::NoOp; } } // namespace reasampler