From 12ffe377e5adb3d53c495e8d1b87b7f82f1ea258 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 20:49:23 -0400 Subject: [PATCH] Cut core/capture and core/version comment bloat ~45% (comments only, zero code change) --- src/core/capture/batch_capture.cpp | 8 +- src/core/capture/batch_capture.h | 68 ++++---- src/core/capture/capture_paths.cpp | 71 ++------- src/core/capture/capture_paths.h | 173 ++++++-------------- src/core/capture/capture_realtime.cpp | 63 +++----- src/core/capture/capture_realtime.h | 220 ++++++++------------------ src/core/capture/insert_plan.cpp | 19 +-- src/core/capture/insert_plan.h | 32 ++-- src/core/capture/render_settings.cpp | 81 +++------- src/core/capture/render_settings.h | 213 ++++++++----------------- src/core/capture/tail_control.cpp | 24 +-- src/core/capture/tail_control.h | 58 +++---- src/core/capture/wav_codec.cpp | 68 +++----- src/core/capture/wav_codec.h | 141 +++++------------ src/core/version/app_version.cpp | 39 +---- src/core/version/app_version.h | 188 ++++++++++------------ 16 files changed, 475 insertions(+), 991 deletions(-) diff --git a/src/core/capture/batch_capture.cpp b/src/core/capture/batch_capture.cpp index 3f5ff1d..f6ff1c8 100644 --- a/src/core/capture/batch_capture.cpp +++ b/src/core/capture/batch_capture.cpp @@ -1,5 +1,5 @@ -// batch_capture.cpp — pure logic for M11 batch capture. See header. -// NO REAPER types; unit-tested by tests/test_batch_capture.cpp. +// batch_capture.cpp — pure logic for batch capture. See header. +// Unit-tested by tests/test_batch_capture.cpp. #include "core/capture/batch_capture.h" @@ -12,9 +12,7 @@ std::vector planCaptureUnits(const std::vector& ranges) units.reserve(ranges.size()); int ordinal = 0; for (const BatchRange& r : ranges) { - // Drop empty/inverted ranges — the offline backend refuses end<=start too, so - // planning one would only manufacture a guaranteed per-unit failure. Ordinals - // count kept units so the reported numbering is contiguous. + // Drop empty/inverted ranges — the offline backend refuses end<=start too. if (!(r.endSeconds > r.startSeconds)) continue; ++ordinal; units.push_back({ordinal, r.startSeconds, r.endSeconds}); diff --git a/src/core/capture/batch_capture.h b/src/core/capture/batch_capture.h index e1353a7..392b992 100644 --- a/src/core/capture/batch_capture.h +++ b/src/core/capture/batch_capture.h @@ -1,29 +1,24 @@ #pragma once -// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires -// N captures: one bank sample per selected item / per razor area). +// batch_capture — the REAPER-free logic behind batch capture (one action fires N +// captures: one bank sample per selected item / per razor area). // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW -// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas) -// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are -// unit-tested outside the DAW: +// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library +// only. The batch shell reads the DAW state (selected items -> exact bounds; +// each track's P_RAZOREDITS -> areas) and hands the raw ranges here: // -// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered -// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds. -// Empty/inverted ranges are DROPPED (mirrors the offline backend's own -// end>start guard) so a zero-length item/area never produces a stray render. -// Order is preserved: unit ordinals count only the KEPT units, so a batch of -// three valid items yields ordinals 1,2,3 regardless of dropped neighbors. -// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary -// (succeeded / failed counts + the ordered list of failures) so the shell can -// report a mixed result with one console line and no partial-corruption -// ambiguity. The AGGREGATION is pure; the render loop that feeds it is shell. +// 1. planCaptureUnits: an ordered list of (start,end) ranges -> an ordered +// list of CaptureUnit, each with a 1-based ordinal and validated bounds. +// Empty/inverted ranges are dropped (mirrors the offline backend's own +// end>start guard); ordinals count only the kept units, so three valid +// items yield 1,2,3 regardless of dropped neighbors. +// 2. BatchOutcome: order-preserving aggregation of per-unit results into a +// summary (succeeded/failed counts + ordered failures) for one console +// line with no partial-corruption ambiguity. // -// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a -// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a -// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the -// shell passes the scope straight through to each render, unchanged from the -// single-capture path. +// Range is the only thing that varies per unit here. FX scope (item vs track) is +// a per-action constant the shell already owns; item-batch uses item scope, +// razor-batch uses track scope, passed through unchanged from the single-capture +// path. #include #include @@ -31,10 +26,10 @@ namespace reasampler::capture { -// One capture in a batch: an exact source range plus its 1-based ordinal within the -// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's -// unique tag is 1-second-granular, so a fast batch could otherwise collide N files -// onto one name) and labels a failure in the summary. +// One capture in a batch: an exact source range plus its 1-based ordinal within +// the kept set. The ordinal disambiguates per-unit file stems (the offline +// backend's unique tag is 1-second-granular, so a fast batch could otherwise +// collide N files onto one name) and labels a failure in the summary. struct CaptureUnit { int ordinal = 0; // 1-based, counts kept units only double startSeconds = 0.0; // exact — no rounding @@ -42,20 +37,18 @@ struct CaptureUnit { }; // A source range handed in by the shell (a selected item's [pos, pos+len] or one -// razor area's [start, end]). Kept as a distinct type from CaptureUnit so the input -// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share -// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with -// bank_model's SourceRange, which carries PPQ fields this planner does not need. +// razor area's [start, end]). Named BatchRange (not SourceRange) to avoid +// collision with bank_model's SourceRange, which carries PPQ fields this planner +// doesn't need. struct BatchRange { double startSeconds = 0.0; double endSeconds = 0.0; }; // Validates + orders a batch's source ranges into capture units. Preserves input -// order; DROPS every range with end <= start (empty/inverted) so no stray render is -// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected -// item / no razor area) yields an empty plan — the shell reports "nothing to batch" -// and writes nothing (the same no-op posture the single-capture path takes). +// order; drops every range with end <= start; assigns 1-based ordinals over the +// kept units. An empty input yields an empty plan — the shell reports "nothing +// to batch" and writes nothing. std::vector planCaptureUnits(const std::vector& ranges); // The per-unit verdict the shell records after each render attempt, in unit order. @@ -65,10 +58,9 @@ struct BatchUnitResult { std::string detail; // failure reason (empty on success) — for the summary }; -// Order-preserving aggregation of a batch's per-unit results. Built incrementally by -// the shell (record() after each unit) so a mid-batch failure is captured without -// aborting the remaining units (no partial corruption: each unit is independent, and -// the selection is restored on every exit path by the shell's RAII guard). +// Order-preserving aggregation of a batch's per-unit results. Built incrementally +// by the shell (record() after each unit) so a mid-batch failure doesn't abort +// the remaining units — each unit is independent. class BatchOutcome { public: // Records one unit's verdict. Order of calls IS the reported order. diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index 575af3e..5d50501 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -6,25 +6,16 @@ namespace reasampler::capture { -// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec -// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the -// layout parse so hashing and decoding cannot desynchronize. - 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. + // Strip a trailing slash but preserve a lone "/" (root). 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; @@ -39,8 +30,7 @@ std::string sanitizeStem(const std::string& baseName) { 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. + // Collapse to a stable default if nothing alnum survived. bool hasAlnum = false; for (unsigned char c : out) { if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || @@ -66,21 +56,16 @@ BankPaths deriveBankPaths(const std::string& projectDir, } 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. + // Precondition: caller must resolve a non-empty project directory — an + // empty one would otherwise fall back to a bare relative path (forbidden). + // Assert in debug; leave absoluteDir empty in release so a caller that + // ignores it fails at the render/stat step, not silently onto 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; @@ -88,15 +73,12 @@ BankPaths deriveBankPaths(const std::string& projectDir, 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). + // Same expression deriveBankPaths uses, so the two spellings can't drift. 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 {}; } @@ -109,10 +91,7 @@ std::string resolveBankFile(const std::string& projectDir, } std::string projectDirOfRpp(const std::string& rppPath) { - // An unsaved project reports an empty .rpp path; keep it empty so downstream - // resolution refuses (no default-location fallback). Mirrors the former persist shell's - // projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes. - if (rppPath.empty()) return {}; + if (rppPath.empty()) return {}; // unsaved project: keep empty, no fallback std::string dir = std::filesystem::path(rppPath).parent_path().string(); return normalizeSlashes(dir); } @@ -128,9 +107,7 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, 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); + r.needed = (oldDir != newDir); // Save-in-place leaves the dir unchanged return r; } @@ -139,42 +116,16 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject, 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. + // See capture_paths.h for the GUID-primary rationale and rule order. 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; + return ProjectTransition::Load; // forked sibling: same GUID, different object } - - // 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; } diff --git a/src/core/capture/capture_paths.h b/src/core/capture/capture_paths.h index bd188e3..8ca0157 100644 --- a/src/core/capture/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -1,16 +1,8 @@ #pragma once -// capture_paths — the REAPER-free path arithmetic behind offline capture. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The capture shell resolves the -// current project directory via REAPER APIs, then hands the raw strings here so -// the fiddly, easy-to-get-wrong path arithmetic (bank subfolder, unique file -// name, absolute render dir, project-relative index path) is unit-tested outside -// the DAW. -// -// Path convention: this module works in forward-slash form and does NOT touch -// the filesystem. The bank subfolder name is a fixed constant so the same -// project always resolves the same bank location (determinism). +// 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 @@ -20,7 +12,7 @@ namespace reasampler::capture { // The project-relative bank subfolder. All captured wavs live here so the bank -// travels with the .rpp (CONTEXT.md §Settled decisions: per-project 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 @@ -34,150 +26,87 @@ struct BankPaths { std::string fileStem; // (RENDER_PATTERN — REAPER appends the extension) }; -// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent) -// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte -// format — so this module holds path arithmetic only, with no RIFF chunk knowledge. - -// Normalizes a path to forward slashes and strips any trailing slash. Empty in -// -> empty out. Pure string transform (does not consult the filesystem). -// Platform case rule: on Windows (_WIN32) the result is also lowercased so that -// paths differing only in drive-letter or component casing compare equal (Windows -// paths are case-insensitive). On macOS/Linux the case is preserved exactly (those -// filesystems are case-sensitive). +// 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 (spaces, slashes, quotes, control) -// with '_', and collapses to "capture" if nothing usable remains. Deterministic: -// the same input always yields the same stem (feeds bit-identical file naming). +// [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. -// projectDir : absolute directory of the current .rpp (any slash style) -// baseName : human base for the file stem (sanitized) -// uniqueTag : caller-supplied disambiguator appended to the stem (e.g. a -// timestamp or counter) so repeated captures do not collide. -// Also sanitized. May be empty. -// Produces "[_].wav". The relativePath is always project-relative and -// forward-slashed so it satisfies BankModel::add's relative-only invariant. +// 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 — -// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder -// entry the SAME way deriveBankPaths spelled it at capture time. By construction it -// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder + -// "/" + fileName), so a file the capture path created and a directory listing of that -// same file resolve to the byte-identical relative string — the safety-critical -// spelling-consistency the prune core's exact-string match depends on (a divergence -// here could make a referenced file look like an orphan). fileName is a bare entry -// name (no directory component); the caller supplies forward-slash-free names from the -// folder enumeration. Empty in -> empty out. +// 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 (M4) -------------------------------------- +// --- Persist-side path arithmetic ------------------------------------------- // -// The index stores relative paths only; on project load the persist shell must -// turn each entry's relativePath back into an absolute path against the CURRENT -// project directory (so a project opened from a new location still resolves its -// bank). This is the inverse of the relativePath the capture path produced. -// -// projectDir : absolute directory of the current .rpp (any slash style) -// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav") -// -// Returns "/" forward-slashed. Returns empty when -// either input is empty (no default-location fallback — CLAUDE.md invariant) so -// a caller that ignores an unsaved/unset project fails loudly rather than -// resolving against CWD. +// 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: its parent directory, forward-slashed, -// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty -// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the -// no-default-location invariant). This is the M4 convention persist uses to place -// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves -// audio paths the SAME way persist does rather than re-implementing the derivation. +// 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 is what moved with the .rpp), so relocation is purely a -// folder move: copy/move the whole bank subfolder from the old project dir to -// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped. +// 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 from the old and new project directories. -// oldProjectDir : project dir the bank currently sits under (any slash style) -// newProjectDir : project dir the .rpp was just saved to (any slash style) -// `needed` is true iff the normalized dirs differ (a genuine Save-As-to-new-dir). -// Returns a plan with empty dirs and needed=false when either input is empty. +// 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 (W12 combined identity fix) ----------------- +// --- Project-identity transition --------------------------------------------- // -// What the persist timer must do on each tick. Identity rests on TWO facts, -// layered GUID-PRIMARY: -// 1. the minted GUID — content-based identity of record, stored in ext state. -// It is IMMUNE to REAPER recycling a closed project's ReaProject* address, -// so it is checked FIRST. -// 2. sameProjectObject — did the same live ReaProject* stay active across the -// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to -// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID -// onto a distinct object) vs a genuine Save-As (one object, new path). -// -// This fix layers both prior designs, GUID-primary. M4 (GUID-only) broke Save-As -// forks: Save-As copies the whole .rpp incl. our stored GUID, so a fork and its -// parent share a GUID on disk. W10 (pointer-primary, GUID voided) broke pointer -// RECYCLING: REAPER reuses a closed project's address, so a reopened/new project -// can present the previous project's pointer with a different stored GUID — -// pointer-primary read that as NoOp/SaveAsRelocate and the bank never reloaded. -// Checking the GUID first catches recycling; the pointer then separates a fork -// (same GUID, different object -> Load) from a Save-As (same GUID, same object, -// new path -> relocate). -// -// The load-bearing rule: a DIFFERENT record identity (GUID) is always a Load; a -// DIFFERENT project object with the same GUID is a fork Load, never a relocate. +// 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 : true iff the SAME ReaProject* stayed active across the two -// ticks (poll() computes `proj == lastProject_`). The pure -// classifier takes the bool, not the raw pointer, to stay -// REAPER-free and testable. -// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved) -// lastPath : that project's .rpp path when last seen ("" if unsaved) -// currentGuid : the GUID stored in the now-active project's ext state ("" if -// unsaved or never written) -// currentPath : the now-active project's .rpp path ("" if unsaved) -// -// Rules (evaluated in EXACTLY this order): -// 1. currentGuid != lastGuid -> Load (different record identity: -// recycled pointer w/ different GUID, -// new/unsaved<->saved, or two distinct -// saved projects) -// 2. !sameProjectObject -> Load (same GUID, different object: -// forked sibling, or two unsaved projects) -// 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID, -// new path: genuine Save-As, or first save -// of an unsaved project — relocate no-ops -// on the empty old dir, poll() mints a GUID) -// 4. otherwise -> NoOp (same object, same GUID, same path) -// -// The GUID (identity of record) leads; the pointer only disambiguates the same-GUID -// case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved -// projects never physically relocate) is preserved because an empty old project dir -// makes deriveRelocationPlan's `needed` false. +// 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, diff --git a/src/core/capture/capture_realtime.cpp b/src/core/capture/capture_realtime.cpp index 2ff05e4..eb61f82 100644 --- a/src/core/capture/capture_realtime.cpp +++ b/src/core/capture/capture_realtime.cpp @@ -1,6 +1,5 @@ -// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See -// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp. -// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.) +// capture_realtime.cpp — pure logic for the realtime-record backend. See header. +// Unit-tested by tests/test_capture_realtime.cpp. #include "core/capture/capture_realtime.h" @@ -9,11 +8,8 @@ namespace reasampler::capture { RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) { RecordModePlan p; - // Stereo vs mono output recording, latency-compensated either way so the - // recorded file lines up with the source. A request asking for <= 1 channel - // records mono-out; anything else records stereo-out. (Higher channel counts - // still record stereo-out here — REAPER's output-record modes are mono/stereo - // only; a >2-channel realtime capture is out of scope for this increment.) + // REAPER's output-record modes are mono/stereo only; >2 channels still + // records stereo-out (a >2-channel realtime capture is out of scope). p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp : kRecModeStereoOutLatComp; @@ -26,42 +22,32 @@ RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) { } OutputTap outputTapForWetDry(double wetDry) { - // Fully wet (1.0) taps post-fader; any dry-ward value taps pre-FX — the true - // pre-FX dry that offline render cannot produce (the realtime backend's whole - // reason to exist for the M10 null test). PostFxPreFader is an explicit future - // option, not reachable from the wet/dry axis, so it is not returned here. return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx; } Sample sampleFromRecordedCapture(const RecordedCapture& cap) { Sample s; - // Same id shape as the offline path: "cap--" would need the file - // name; here the recorded file name is the tail of relativePath. Keep the id - // stable + unique via the tag, and include the relative path tail so two - // captures with the same tag (impossible in practice) still differ. + // Relative path tail included so two same-tag captures (shouldn't happen) still differ. s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath; s.displayName = cap.displayName; - s.relativePath = cap.relativePath; // project-relative (invariant) + s.relativePath = cap.relativePath; s.sourceMode = cap.sourceMode; s.sourceRange.startSeconds = cap.startSeconds; s.sourceRange.endSeconds = cap.endSeconds; - // PPQ/beats deferred (musical-placement concern) — identical to the offline path. + // PPQ/beats deferred (musical-placement concern), as offline. s.wetDry = cap.wetDry; s.trackGuids = cap.trackGuids; s.channelCount = cap.channelCount; s.sampleRate = cap.sampleRate; // 0 when project rate was unknown s.lengthSeconds = cap.endSeconds - cap.startSeconds; s.captureTempo = cap.captureTempo; - s.captureTimeSigNum = cap.captureTimeSigNum; // L7 F1 meter stamp (0/0 = unstamped) + s.captureTimeSigNum = cap.captureTimeSigNum; // 0/0 = unstamped s.captureTimeSigDenom = cap.captureTimeSigDenom; - s.tier = Tier::Scratch; // captures land in scratch by default - // contentHash set by the caller (capture_realtime.cpp) after the file is - // finalized and on disk — the hash is over the finished file bytes. Left empty - // here because sampleFromRecordedCapture runs before the file exists (the - // mapping is pure / DAW-free); the shell patches it in after the move+trim. - // Phase S seam fields (rootNote / loop) left empty (D-B) — same reasoning as the - // offline path: a realtime record of wet output is not a single played note, so - // no root note is derivable; loop points are set by a later explicit action. + s.tier = Tier::Scratch; + // contentHash is left empty: this mapping runs before the file exists on + // disk; the shell patches the hash in after the move+trim. + // rootNote/loop left empty: a realtime record of wet output isn't a single + // played note, so no root note is derivable; loop points are a later action. s.createdTimestamp = cap.createdTimestamp; return s; } @@ -72,20 +58,15 @@ RecordPhase advanceRecordPhase(RecordPhase current, double rangeEndSeconds) { switch (current) { case RecordPhase::Recording: { - // Transport stopped while we still expected to be recording -> the user - // (or REAPER) stopped early. Move to the flush wait and finalize whatever - // was captured up to the stop. + // Stopped early (user or REAPER) -> finalize what was captured so far. if (!inputs.transport.recording) return RecordPhase::Finalizing; - // Reached the range end (latency-compensated play position). >= (not >) - // so a cursor landing exactly on the end completes. + // >= (not >): a cursor landing exactly on the end completes. if (inputs.transport.playPosition >= rangeEndSeconds) return RecordPhase::Finalizing; - // Self-defense (review §3): the transport is running but the play cursor - // is not advancing to the end (stuck / looping). Without this the machine - // stays in Recording forever, leaking the temp track + armed sink. Force - // the flush wait once wall-clock exceeds the nominal duration + margin. + // Self-defense: a stuck/looping transport that never reaches end would + // otherwise stay in Recording forever, leaking the temp track + armed sink. const double ceiling = (rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds; if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing; @@ -94,22 +75,16 @@ RecordPhase advanceRecordPhase(RecordPhase current, } case RecordPhase::Finalizing: { - // The transport is stopped; wait for REAPER to flush/close the recorded - // take on the audio thread. Finalize (move + Sample) only once the file - // exists AND is stable (review §2) — moving it early races the flush and - // yields a truncated / missing capture. + // Moving the file before it's stable would race REAPER's flush and + // yield a truncated/missing capture. if (inputs.fileReady) return RecordPhase::Done; - // Bound the wait: a file that never stabilizes fails cleanly rather than - // hanging the in-flight state for the session. if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds) return RecordPhase::Failed; return RecordPhase::Finalizing; } - // Terminal phases are sticky: once the verdict is in, a later tick (a stray - // extra call before the shell has finished tearing down) must not flip it. case RecordPhase::Done: case RecordPhase::Failed: default: diff --git a/src/core/capture/capture_realtime.h b/src/core/capture/capture_realtime.h index d497e8b..b26805b 100644 --- a/src/core/capture/capture_realtime.h +++ b/src/core/capture/capture_realtime.h @@ -1,27 +1,11 @@ #pragma once -// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8). -// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module -// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp / -// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.) -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The realtime shell drives the -// transport, the temp track, the send routing, and the file move — -// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong -// pieces are split out here and unit-tested outside the DAW: -// -// 1. the record-mode/recipe bookkeeping: given a capture scope + a desired -// FX-tap point (post-fader / pre-FX / post-FX-pre-fader), the I_RECMODE and -// I_RECMODE_FLAGS integer values the temp track must carry. -// 2. the recorded-file -> Sample mapping: given a finished capture (the -// recorded file's project-relative path + the request's own bounds/format), -// the populated Sample handed to bank_model. Mirrors the inline Sample -// population OfflineRenderBackend does — factored out so it is tested once, -// without a DAW, and shared shape with the offline path is guaranteed. -// -// The I_RECMODE / I_RECMODE_FLAGS bit MEANINGS are transcribed verbatim from -// reaper_plugin_functions.h line ~2197-2198 (see kRecMode* constants); the CHOICE -// of which values each scope needs is this module's logic and is tested. +// capture_realtime — the REAPER-free logic behind the realtime-record backend. +// The shell drives the transport, temp track, send routing, and file move; the +// pure pieces split out here and unit-tested outside the DAW are: (1) record- +// mode bookkeeping — scope + FX-tap point -> I_RECMODE/I_RECMODE_FLAGS values +// (bit MEANINGS transcribed verbatim from reaper_plugin_functions.h ~2197-2198; +// the CHOICE of value per scope is this module's tested logic) — and (2) the +// recorded-file -> Sample mapping (mirrors OfflineRenderBackend's population). #include #include @@ -35,26 +19,17 @@ using model::Sample; using model::Tier; using model::SourceMode; -// --- I_RECMODE values (verbatim from SDK header ~2197) ----------------------- -// -// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none, -// 3=stereo out w/latency compensation, 4=midi output, 5=mono out, -// 6=mono out w/ latency compensation, 7=midi overdub, 8=midi replace. -// -// We record a track's OUTPUT (the scoped signal routed into the temp track), -// latency-compensated, so the recorded file lines up sample-accurately with the -// source. Stereo vs mono is chosen by the request's channel count. +// I_RECMODE (verbatim from SDK header ~2197): 0=input, 1=stereo out, 2=none, +// 3=stereo out w/latency comp, 4=midi output, 5=mono out, 6=mono out w/latency +// comp, 7=midi overdub, 8=midi replace. We record a track's OUTPUT, latency- +// compensated, so the recorded file lines up sample-accurately with the source. inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp -// --- I_RECMODE_FLAGS values (verbatim from SDK header ~2198) ------------------ -// -// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode -// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). -// -// This is the ONLY documented pre-FX tap in the whole SDK — offline render has no -// pre-FX bit (see render_settings.h note + the M10 null-test note in PLAN.md). -// The realtime backend is therefore the true pre-FX "dry" path. +// I_RECMODE_FLAGS (verbatim from SDK header ~2198): &3=output recording mode +// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). This is the only documented +// pre-FX tap in the SDK — offline render has no pre-FX bit — so the realtime +// backend is the true pre-FX "dry" path. inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet) inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry) inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader @@ -68,41 +43,34 @@ enum class OutputTap { }; // The concrete record-mode values a temp track must carry to capture the scoped -// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags` -// sets the &3 output-recording tap bits (higher bits are left at their default 0 -// here — we only own the tap-point bits). +// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); +// `recModeFlags` sets the &3 output-recording tap bits (we only own those bits). struct RecordModePlan { int recMode = kRecModeStereoOutLatComp; int recModeFlags = kRecOutPostFader; }; -// Maps (channelCount, tap) to the record-mode values. -// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp. -// tap -> the &3 output-recording bits. -// Pure so the "which I_RECMODE for N channels + this tap" rule is unit-tested -// without a DAW; the shell reads the request and applies these via -// SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS). +// Maps (channelCount, tap) to the record-mode values: channelCount <= 1 -> +// mono-out latency-comp, else stereo-out; tap -> the &3 bits. The shell applies +// these via SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS). RecordModePlan recordModePlanFor(int channelCount, OutputTap tap); -// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any -// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing -// capability). Kept pure + separate from recordModePlanFor so the wet/dry -> -// tap decision is tested on its own; PostFxPreFader is not selected by wetDry -// (it is an explicit future option, not on the wet/dry axis). +// Maps a wetDry value to the output tap point: 1.0 (fully wet) -> PostFader, +// anything less -> PreFx (true dry — the realtime backend's distinguishing +// capability over offline render). PostFxPreFader is not reachable from wetDry. OutputTap outputTapForWetDry(double wetDry); // --- Recorded-file -> Sample mapping ---------------------------------------- -// + // The inputs a finished realtime capture yields, gathered by the shell into a -// pure struct so the Sample population is a single tested transform (mirror of -// the inline population in OfflineRenderBackend::capture). +// pure struct so Sample population is a single tested transform (mirrors the +// inline population in OfflineRenderBackend::capture). struct RecordedCapture { - // Project-relative path of the recorded file (relative-paths-only invariant; - // the shell resolves REAPER's recorded absolute path back to project-relative). + // Project-relative path of the recorded file (the shell resolves REAPER's + // absolute path back to project-relative). std::string relativePath; - // The disambiguating tag that named the file (feeds the Sample id, so id and - // file name stay consistent — same discipline as the offline path). + // The disambiguating tag that named the file (feeds the Sample id). std::string uniqueTag; // Echoed from the request (exact bounds — no re-measuring the file). @@ -115,60 +83,41 @@ struct RecordedCapture { int channelCount = 0; - // TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer - // populates these five fields before calling sampleFromRecordedCapture — the - // finalize path (capture_realtime_finalize.cpp) leaves them at their defaults - // and instead calls the shared stampCaptureSample(result.sample, ...) right - // after, which writes Sample::sampleRate/captureTempo/captureTimeSigNum/ - // captureTimeSigDenom/createdTimestamp directly, overwriting whatever - // sampleFromRecordedCapture set from these. Kept (not deleted) because the pure - // unit tests still construct/assert them directly; removing the fields is a - // struct-shape decision out of scope here. + // Left at defaults here — capture_realtime_finalize.cpp calls + // stampCaptureSample(result.sample, ...) afterward, overwriting these five + // from the live project. Kept because the pure unit tests still assert them. int sampleRate = 0; // 0 when the project rate was unknown (as offline) - double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo) - // Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime). - // 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out). - int captureTimeSigNum = 0; + double captureTempo = 0.0; // BPM at capture time + int captureTimeSigNum = 0; // 0/0 = unstamped int captureTimeSigDenom = 0; - std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock) + std::int64_t createdTimestamp = 0; // unix epoch seconds }; -// Builds the Sample for a finished realtime capture. Deliberately identical in -// shape to OfflineRenderBackend's population: exact request bounds (no rounding), -// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start. -// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline). +// Builds the Sample for a finished realtime capture: exact request bounds, +// scratch tier, empty content hash, lengthSeconds = end - start. PPQ/beats +// left 0 (deferred, as offline). Sample sampleFromRecordedCapture(const RecordedCapture& cap); -// --- Async record-phase state machine (M8 rework) ---------------------------- +// --- Async record-phase state machine ---------------------------------------- // -// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport on -// REAPER's audio thread and returns immediately — it does NOT block until the range -// completes). The completion decision — "given where the transport is now, should -// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and -// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is -// factored out here; the REAPER shell only reads the transport/clock/file and applies -// the verdict (stop, wait for the file to flush, then finalize/abort + restore). +// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport +// on REAPER's audio thread and returns immediately — it does not block until the +// range completes). The completion decision — keep waiting, stop-and-flush, +// finalize, or give up — is pure and unit-tested without a DAW; the shell only +// reads the transport/clock/file and applies the verdict. // -// The lifecycle has TWO waits, not one: -// 1. the RECORD wait (Recording): the transport is running; we wait for the play -// cursor to reach the range end — OR the user stops early — OR a wall-clock -// safety ceiling trips (a started-but-never-advancing transport, §3 of review). -// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes -// the recorded take on the AUDIO thread — the file may not be fully written/closed -// for a tick or two. We defer the file move until the file exists AND is stable -// (§2 of review), bounded by a flush ceiling so a file that never appears fails -// cleanly rather than hanging. +// Two waits, not one: +// 1. RECORD wait (Recording): transport running; wait for the play cursor to +// reach the range end, OR the user stops early, OR a wall-clock safety +// ceiling trips (a started-but-never-advancing transport). +// 2. FLUSH wait (Finalizing): transport stopped but REAPER closes/flushes the +// recorded take on the audio thread — the file may lag a tick or two. +// Defer the move until the file exists AND is stable, bounded by a flush +// ceiling so a file that never appears fails cleanly instead of hanging. -// Where an in-progress capture is in its lifecycle. -// Recording — live: transport running, shell keeps ticking. -// Finalizing — live-but-stopped: transport halted, shell stops the transport once -// then ticks waiting for the recorded file to flush/stabilize. -// Done — terminal: the file is flushed + stable, finalize (move + Sample) now. -// Failed — terminal: the flush ceiling tripped without a stable file — give up -// (RenderFailed) + restore. (A record that produced NO file at all also -// lands here via the shell's finalize returning RenderFailed.) -// Only Recording and Finalizing are live phases the shell advances per tick; Done and -// Failed are the shell's verdict to act on (finalize-or-fail, then restore). +// Where an in-progress capture is in its lifecycle: Recording (live, transport +// running) and Finalizing (live-but-stopped, waiting for flush) are the two +// waits above; Done/Failed are terminal — the shell's verdict to act on. enum class RecordPhase { Recording, Finalizing, @@ -176,63 +125,34 @@ enum class RecordPhase { Failed }; -// A distilled transport reading for the pure transition, so the state machine never -// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` -// is GetPlayPositionEx (latency-compensated what-you-hear position). +// A distilled transport reading so the state machine never touches a REAPER +// type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` is +// GetPlayPositionEx (latency-compensated). struct TransportReading { bool recording = false; double playPosition = 0.0; }; -// Everything the pure transition needs beyond the current phase, gathered by the -// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling -// decision (the shell only reads and reports; it never decides a transition itself). +// Everything the pure transition needs beyond the current phase, gathered by +// the shell each tick (the shell only reads and reports; never decides). struct RecordTickInputs { TransportReading transport; - - // Wall-clock seconds since begin() (the shell reads a steady clock). Drives the - // record safety ceiling: a transport that starts but never advances to the range - // end (stuck / looping) would otherwise keep the machine in Recording forever. - double elapsedSeconds = 0.0; - - // Wall-clock seconds spent in the Finalizing phase (since the transport stop). - // Drives the flush ceiling: bound the deferred-finalize wait so a file that never - // stabilizes fails cleanly instead of hanging. - double finalizingSeconds = 0.0; - - // Whether the recorded take's file exists AND is stable/closed this tick (the - // shell resolves the take source path and checks size-stable-across-a-tick). - // Only consulted in Finalizing. - bool fileReady = false; + double elapsedSeconds = 0.0; // wall-clock since begin() — record ceiling + double finalizingSeconds = 0.0; // wall-clock in Finalizing — flush ceiling + bool fileReady = false; // recorded file exists+stable (Finalizing only) }; -// --- Safety ceilings (named constants, review §2/§3) ------------------------- -// -// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form -// the record wall-clock ceiling. Generous so a normal record (with pre-roll, count-in, -// or transport latency) never trips it; tight enough that a stuck transport is force- -// terminated within a few seconds of overrun. +// Record ceiling margin added to nominal duration: generous enough that +// pre-roll/count-in/latency never trips it, tight enough a stuck transport is +// force-terminated within seconds. inline constexpr double kRecordMarginSeconds = 5.0; -// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the -// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the -// take on the audio thread within a tick or two in practice; this is a generous bound. +// Max wall-clock Finalizing waits for the file to flush/stabilize before +// giving up (REAPER closes the take within a tick or two in practice). inline constexpr double kFinalizeFlushCeilingSeconds = 5.0; -// The pure transition: given the current phase, this tick's inputs, and the record -// range end, return the next phase. Total + deterministic. -// -// From Recording: -// * recording AND cursor < end AND under the record ceiling -> Recording (wait) -// * recording AND cursor >= end -> Finalizing (reached end) -// * NOT recording -> Finalizing (stopped early) -// * recording BUT over the record ceiling (end-start+margin)-> Finalizing (stuck: forced) -// From Finalizing: -// * fileReady -> Done (flushed + stable) -// * over the flush ceiling without a stable file -> Failed (give up) -// * otherwise -> Finalizing (keep flushing) -// Done and Failed are sticky: feeding a terminal phase back returns it unchanged, so a -// late tick before teardown finishes cannot flip the verdict (the idempotence the +// The pure transition (total + deterministic). Done/Failed are sticky — a late +// tick before teardown finishes cannot flip the verdict (the idempotence the // shell's single-restore relies on). RecordPhase advanceRecordPhase(RecordPhase current, const RecordTickInputs& inputs, diff --git a/src/core/capture/insert_plan.cpp b/src/core/capture/insert_plan.cpp index b58d56c..ccc73ea 100644 --- a/src/core/capture/insert_plan.cpp +++ b/src/core/capture/insert_plan.cpp @@ -7,14 +7,14 @@ namespace reasampler::capture { namespace { // Base target bits (mode&3). We use only 0 (current track) and 1 (new track). -constexpr int kBaseCurrentTrack = 0; // add to current track -constexpr int kBaseNewTrack = 1; // add new track +constexpr int kBaseCurrentTrack = 0; +constexpr int kBaseNewTrack = 1; // Tempo-conform bits, verbatim from the header doc-comment. -constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x -constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x -constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x -constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo +constexpr int kMatchTempo1x = 8; +constexpr int kMatchTempoHalf = 16; +constexpr int kMatchTempoDbl = 32; +constexpr int kDontPreservePitch = 64; } // namespace @@ -24,8 +24,7 @@ int computeInsertMode(const InsertOptions& opts) { switch (opts.conform) { case TempoConform::None: - // No tempo bits: native length, no stretch. (Also never &4.) - return mode; + return mode; // native length, no stretch; never &4 case TempoConform::Ratio1x: mode |= kMatchTempo1x; break; @@ -37,9 +36,7 @@ int computeInsertMode(const InsertOptions& opts) { break; } - // Tempo bits are set (conform != None). Add the pitch-shift bit only when the - // caller asked NOT to preserve pitch. When conform == None we already returned - // above, so this can never fire without a tempo bit present. + // Reached only when a tempo bit is set (None already returned above). if (!opts.preservePitch) mode |= kDontPreservePitch; diff --git a/src/core/capture/insert_plan.h b/src/core/capture/insert_plan.h index 2bb45e9..e5ec5b0 100644 --- a/src/core/capture/insert_plan.h +++ b/src/core/capture/insert_plan.h @@ -1,34 +1,32 @@ #pragma once -// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing -// the InsertMedia `mode` bitmask from a small options struct. +// insert_plan — the REAPER-free logic behind the `insert` shell: computing the +// InsertMedia `mode` bitmask from a small options struct. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW -// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to -// get wrong and its bits are load-bearing for the "no silent time-stretch" -// invariant, so it is factored here and unit-tested. The REAPER-bound placement -// (InsertMedia call, edit-cursor movement, undo block) lives in insert.cpp and is -// DAW-verified. +// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library +// only. The InsertMedia bitfield is easy to get wrong and its bits are +// load-bearing for the "no silent time-stretch" invariant, so it's factored here +// and unit-tested. The REAPER-bound placement (InsertMedia call, edit-cursor +// movement, undo block) lives in insert.cpp and is DAW-verified. // -// The bit meanings below are transcribed VERBATIM from the authoritative header +// Bit meanings below are transcribed VERBATIM from the authoritative header // doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia): // mode: 0=add to current track, 1=add new track, 3=add to selected items as // takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x, // &16=try to match tempo 0.5x, &32=try to match tempo 2x, // &64=don't preserve pitch when matching tempo, ... -// We intentionally use only the base target (0/1) and the tempo-conform bits -// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is -// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals). +// We use only the base target (0/1) and the tempo-conform bits (&8/&16/&32/&64). +// We NEVER set &4 (stretch/loop to fit time selection) — the silent-time-stretch +// path the tool forbids. #include namespace reasampler::capture { // Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3). -// We expose only the two placement targets M6 needs; "add as takes" (3) is a -// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's -// convention for base modes 0/1 (the header names no explicit edit-cursor bit; -// see the flagged runtime assumption in insert.cpp). +// We expose only the two placement targets needed here; "add as takes" (3) is +// out of scope. Both insert at the edit cursor — REAPER's convention for base +// modes 0/1 (the header names no explicit edit-cursor bit; see the flagged +// runtime assumption in insert.cpp). enum class InsertTarget { NewTrack, // mode base 1: add a new track for the item CurrentTrack, // mode base 0: add to the current/selected track diff --git a/src/core/capture/render_settings.cpp b/src/core/capture/render_settings.cpp index a83c5ca..1effb01 100644 --- a/src/core/capture/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -10,9 +10,7 @@ namespace reasampler::capture { double autoTrimEndRatio() { - // Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is - // the single source of truth (header ~3062: RENDER_TRIMEND is an amplitude ratio, - // "0.5 means -6.02 dB"). For -72 dB this is ~= 0.00025119. + // Amplitude ratio = 10^(dB/20) (header ~3062). For -72 dB this is ~0.00025119. return std::pow(10.0, kAutoTrimThresholdDb / 20.0); } @@ -20,8 +18,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { TailRenderSettings t; switch (mode) { case TailMode::None: - // Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off, - // disable-all normalize (the current default), no trim. + // Exact bounds — byte-identical to the pre-tail capture. t.tailFlag = kTailFlagNone; t.tailMs = 0.0; t.normalize = kNormalizeDisableAll; @@ -29,12 +26,10 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { return t; case TailMode::Auto: - // Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence - // bit (32768) — every other postprocessing bit clear. A fixed-threshold - // trailing-silence trim is a pure boundary decision (it scales/limits/fades - // nothing), so it re-introduces none of the coloring the disable-all bit - // guarded against, and two identical requests trim at the identical sample - // -> bit-identical repeats hold (spec §surgical normalize). + // Surgical normalize: only the trim-ending-silence bit set, every other + // postprocessing bit clear. A fixed-threshold trim scales/limits/fades + // nothing, so identical requests trim at the identical sample -> holds + // the bit-identical-repeats invariant. t.tailFlag = kTailFlagCustomBounds; t.tailMs = kMaxTailMs; t.normalize = kNormalizeTrimEnd; @@ -42,10 +37,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { return t; case TailMode::Manual: - // Fixed tail, no trim -> keep the disable-all normalize exactly as the - // no-tail path does. Clamp to the 8 s cap even here: the runaway guard - // applies whether the length came from the Auto default or an explicit - // request (spec §Manual override). Negative requests floor to 0. + // Clamped to the cap regardless of source; negative floors to 0. t.tailFlag = kTailFlagCustomBounds; t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs); t.normalize = kNormalizeDisableAll; @@ -60,14 +52,10 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds, double manualTailMs) { switch (mode) { case TailMode::None: - // Exact — no extra recording (byte-identical to today's realtime capture). - return rangeEndSeconds; + return rangeEndSeconds; // exact, no extra recording case TailMode::Auto: - // The 8 s runaway cap past the range end; the decay-trim shortens it later. - return rangeEndSeconds + kMaxTailSeconds; + return rangeEndSeconds + kMaxTailSeconds; // runaway cap; decay-trim shortens later case TailMode::Manual: - // Fixed window: range + the set length, clamped to the 8 s cap (the same - // runaway guard the offline Manual path applies). Negative floors to 0. return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0; } // Unreachable for a valid enum; fail closed to exact bounds (never a stray tail). @@ -75,42 +63,36 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds, } RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) { - // `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future - // dry work (M10 null test), but it does not affect this mapping. FX scoping is - // handled by fxBypassPlanFor, not by these render bits. + // wetDry doesn't affect this mapping (seam for future dry work); FX scoping + // is handled by fxBypassPlanFor, not by these render bits. RenderSettingsChoice c; switch (mode) { case SourceMode::MasterMix: case SourceMode::TimeSelection: - // Master IS the mix — wet-only; &(1|2)==0, no source bits. - c.settings = kRenderMasterMix; + c.settings = kRenderMasterMix; // wet-only, no source bits c.supported = true; return c; case SourceMode::SelectedTracks: - // Selected tracks via master (&128) — wet (post-FX). Header ~3041. c.settings = kRenderSelTracksViaMaster; c.supported = true; return c; case SourceMode::SelectedItems: - // Selected media items, rendered to ONE file (single-file bit) so a - // multi-item selection yields a single bank entry, not N wavs. + // Single-file bit so a multi-item selection yields one bank entry. c.settings = kRenderSelItems | kRenderSingleFile; c.supported = true; return c; case SourceMode::RazorArea: - // Render razor edits to ONE file (same single-file rationale as items). c.settings = kRenderRazorEdits | kRenderSingleFile; c.supported = true; return c; case SourceMode::Realtime: - // Not an offline-render source — the realtime backend (M8) owns it. c.settings = kRenderMasterMix; - c.supported = false; + c.supported = false; // not an offline-render source return c; } // Unreachable for a valid enum; fail closed (unsupported) rather than render. @@ -135,17 +117,13 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) { FxBypassPlan p; switch (scope) { case CaptureScope::Item: - // Item = take/item FX ONLY. Bypass the item's own track FX, every - // ancestor's FX, and the master's FX. (Take FX live in the item and - // are always rendered — there is no track to bypass them from.) + // Take FX live in the item and are always rendered — bypass everything else. p.bypassSelfFx = true; p.bypassAncestorFx = true; p.bypassMaster = true; return p; case CaptureScope::Track: - // Track = item FX + the selected track's OWN FX. Keep self FX; bypass - // every ancestor (parent/folder) and the master. Parent/master GAIN - // still applies (I_FXEN is FX-only) — documented boundary. + // Keep self FX; bypass every ancestor (parent/folder) and the master. p.bypassSelfFx = false; p.bypassAncestorFx = true; p.bypassMaster = true; @@ -158,24 +136,19 @@ std::vector parseRazorEdits(const std::string& razorString) { std::vector ranges; std::istringstream in(razorString); - // The string is space-separated TRIPLES: . - // A track-audio area's third token is the literal two-char string `""`; an - // envelope-lane area's is a GUID `{…}`. We keep only track-audio triples. std::string startTok, endTok, guidTok; while (in >> startTok >> endTok >> guidTok) { - // Envelope-lane areas carry a real GUID; skip them (razor captures track audio only). - // A track-audio area's GUID token is the empty quoted string `""`. + // Skip envelope-lane areas (real GUID); keep only track-audio (`""`). if (guidTok != "\"\"") continue; - // Parse the two time tokens. std::stod throws on garbage — guard so one - // malformed triple does not abort the whole parse. + // std::stod throws on garbage — guard so one malformed triple doesn't + // abort the whole parse. double start = 0.0, end = 0.0; try { std::size_t sp = 0, ep = 0; start = std::stod(startTok, &sp); end = std::stod(endTok, &ep); - // Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse - // is a malformed area, not a valid range. + // Reject trailing garbage (e.g. "1.0x") — a partial parse is malformed. if (sp != startTok.size() || ep != endTok.size()) continue; } catch (...) { continue; @@ -197,23 +170,13 @@ RazorRange razorUnionBounds(const std::vector& ranges) { } const std::vector& captureActionTable() { - // Built once (function-local static): two SCOPE actions, item + track. Both - // exact bounds by default; the tail mode a capture applies is read from the - // docked-panel setting at fire time (tail_control + bank_panel), so tail is NOT - // a per-action variant. Ids are FOREVER-STABLE — never edit a shipped string. - // Each action infers its range (razor-else-time) at fire time and enforces its - // FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET / - // CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in - // main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise - // mirror-unregistered) — to capture the master you render a track. + // FOREVER-STABLE ids — never edit a shipped string. No master capture + // action (its id was retired; do not reintroduce it). static const std::vector table = { - // Item scope — item/take FX only. Suffix + phrase are channel-agnostic; the shell - // composes the FOREVER-STABLE id (prefix + "CAPTURE_ITEM") and the display name. {"CAPTURE_ITEM", "capture selected item(s)", "item", CaptureScope::Item}, - // Track scope — item FX + the track's own FX. {"CAPTURE_TRACK", "capture selected track(s)", "track", CaptureScope::Track}, diff --git a/src/core/capture/render_settings.h b/src/core/capture/render_settings.h index 4c88323..a80e9eb 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -1,26 +1,9 @@ #pragma once -// render_settings — the REAPER-free logic behind the capture action family. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The capture shell (capture.cpp) and -// action layer (main.cpp) read the actual DAW state (time selection, selected -// tracks/items, razor strings, the ancestor-track chain) and hand the raw values -// here so the genuinely-pure, easy-to-get-wrong pieces are unit-tested outside -// the DAW: -// -// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only). -// 2. a P_RAZOREDITS string -> the list of (start,end) ranges + their union bound. -// 3. range inference: razor-present -> razor union, else time selection. Range -// is a SOURCE choice orthogonal to the capture scope. -// 4. the FX-scope bypass plan: given a scope + an ancestor-chain length, which -// tracks' FX to bypass so each scope hears only the FX it should (the M7 -// "items captured through parent FX" defect is corrected here). -// 5. the capture-action table (id string, description, scope) — the taxonomy, -// in one place so main.cpp iterates it instead of hand-listing. -// -// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from -// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of -// which bits each source mode sets is this module's logic and is tested. +// render_settings — the REAPER-free logic behind the capture action family: +// sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union, +// razor-else-time inference, the FX-scope bypass plan, and the capture-action +// table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from +// reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested. #include #include @@ -32,67 +15,44 @@ namespace reasampler::capture { using model::SourceMode; // --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) -- -// -// Only the bits this module actually uses are named. Values are the documented bit -// weights; the DOC of each is the SDK header's, not a guess. inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits inline constexpr int kRenderSelItems = 32; // &32 selected media items inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits -// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no -// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render -// mechanism (see fxBypassPlan below) — bypassing the FX-enable of the tracks that -// fall outside a scope — NOT by any render bit. All capture actions render wet -// (post the FX that remain enabled); the scope decides which FX remain enabled. +// kRenderPreFaderStems (&8192) is deliberately NOT used — REAPER offline render +// has no true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render +// mechanism (see fxBypassPlan below), not by any render bit. All capture actions +// render wet; the scope decides which FX remain enabled. inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file // --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ---------- // -// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release -// decay past the range end. Every offline capture renders custom-time-bounds, so -// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047). -// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside -// the DAW exactly like renderSettingsFor; the backend just applies them. -// -// RENDER_NORMALIZE bit meanings (verbatim from SDK header ~3051): -// &32768 = trim ending silence (the surgical Auto path) -// &(4<<16) = disable all render postprocessing (the None/Manual path) +// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG, +// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE +// (verbatim, header ~3051): &32768 = trim ending silence (Auto path); +// &(4<<16) = disable all render postprocessing (None/Manual path). inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all -// RENDER_TAILFLAG &1 = apply tail for custom time bounds (header ~3047). We render -// custom bounds unconditionally, so this is the only tail bit that ever applies. inline constexpr int kTailFlagNone = 0; -inline constexpr int kTailFlagCustomBounds = 1; // &1 +inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047 -// Auto-trim trailing-silence threshold. -72 dB is quiet enough that the trimmed -// region is inaudible decay, loud enough to not chase a reverb's infinite noise -// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from -// this dB, never the reverse. +// Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND +// ratio derives from this dB, never the reverse). Daniel-set. inline constexpr double kAutoTrimThresholdDb = -72.0; -// Max tail rendered past the range end. The runaway guard: a non-decaying or -// looping signal never crosses the trim threshold, so this caps the render. -// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths. +// Runaway guard: max tail rendered past the range end, so a non-decaying or +// looping signal doesn't render forever. Daniel-set; shared by offline+realtime. inline constexpr double kMaxTailSeconds = 8.0; inline constexpr double kMaxTailMs = 8000.0; -// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents -// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20). -// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and -// a future config change to the dB does not require hand-recomputing the ratio. -// -// std::pow is not constexpr before C++26, so this is a function, not a constant. -// For -72 dB: 10^(-72/20) = 10^(-3.6) ~= 0.00025119 (the value the DAW confirm targets). +// Derived linear amplitude ratio for RENDER_TRIMEND (header ~3062: an amplitude +// ratio, "0.5 means -6.02 dB", i.e. 10^(dB/20)) from kAutoTrimThresholdDb. +// Function not constant: std::pow isn't constexpr before C++26. double autoTrimEndRatio(); -// The three tail states (docs/product/capture-tail.md §The three tail states): -// None — exact bounds, no tail. Byte-identical to the pre-tail capture. The -// default and the ONLY mode for null-test / verify captures. -// Auto — generous 8 s tail then trim trailing silence to -72 dB (surgical -// normalize). The user-facing tail-on option (panel toggle). -// Manual — a fixed tail length (clamped to the 8 s cap), no trim. +// The three tail states — see src/core/capture/CLAUDE.md. enum class TailMode { None, Auto, @@ -100,9 +60,9 @@ enum class TailMode { }; // The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS -// the backend already sets. `trimEnd` is meaningful only when the trim-end normalize -// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads -// these four fields straight onto GetSetProjectInfo. +// the backend already sets. `trimEnd` is meaningful only when the trim-end +// normalize bit is set (Auto). The backend reads these straight onto +// GetSetProjectInfo. struct TailRenderSettings { int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1) double tailMs = 0.0; // RENDER_TAILMS @@ -110,54 +70,36 @@ struct TailRenderSettings { double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set) }; -// Maps a tail mode (+ the requested manual tail ms) to its RENDER_* values. -// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is -// clamped to kMaxTailMs — the runaway guard applies whether the length came from -// the Auto default or an explicit request (spec §Manual override). Pure + tested. +// Maps a tail mode (+ requested manual tail ms, used only for Manual) to its +// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source. TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs); -// The REALTIME record-window end (in project seconds) a tail mode records to, given -// the request's exact range end (docs/product/capture-tail.md §The realtime path). -// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so -// the window end is where the transport actually stops: -// None -> rangeEndSeconds (exact — no extra recording). -// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later). -// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim). -// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and -// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned -// end to the record time selection. Shared -72 dB / 8 s constants are the same ones -// the offline tail uses (single source of truth). +// The realtime record-window end (project seconds): realtime does NOT drive +// RENDER_*, it records a generous window and trims later, so this is where the +// transport actually stops. None -> exact rangeEndSeconds; Auto -> +8s runaway +// cap; Manual -> + clamp(manualTailMs, kMaxTailMs)/1000. double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds, double manualTailMs); // The RENDER_SETTINGS value for a given source mode. `supported` is false only -// for SourceMode::Realtime (that is the M8 backend, not offline render). +// for SourceMode::Realtime (that backend doesn't use offline render). struct RenderSettingsChoice { int settings = kRenderMasterMix; bool supported = true; // false => not an offline-render source (e.g. Realtime) }; // Maps a source mode to its RENDER_SETTINGS value (which content the render -// covers). FX scoping is orthogonal — done by fxBypassPlan, not by these bits. -// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest -// as the seam for future dry work (M10 null test). -// -// CONFIRMED (SDK header ~3041): -// MasterMix / TimeSelection -> master mix (0). -// SelectedTracks -> &128 selected tracks via master. -// SelectedItems -> &32 | single-file (one wav, not one-per-item). -// RazorArea -> &4096| single-file. +// covers); FX scoping is orthogonal (done by fxBypassPlan). `wetDry` is +// accepted but ignored — retained as the seam for future dry work. CONFIRMED +// (SDK header ~3041): MasterMix/TimeSelection -> 0; SelectedTracks -> &128; +// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file. RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); -// --- Capture scope: the FX-scope invariant (Daniel, critical) ---------------- +// --- Capture scope: the FX-scope invariant ------------------------------------ // -// Two FX scopes. The render RANGE (razor-else-time) is orthogonal to the scope. -// Item -> item/take FX ONLY (no track, no parent/folder, no master FX). -// Track -> item FX + the selected track's OWN track FX (no parent/folder/master). -// There is NO master scope: to capture the master you render a track instead. The -// master track's FX/gain/pan are still NEUTRALIZED as part of the out-of-scope -// chain for both item and track captures (bypassMaster below) — master is a -// bypass target, not a capture scope. +// See src/core/capture/CLAUDE.md for the scope contract. There is NO master +// scope; the master track's FX/gain/pan are still NEUTRALIZED as part of the +// out-of-scope chain (bypassMaster below) — master is a bypass target only. enum class CaptureScope { Item, Track, @@ -169,34 +111,26 @@ SourceMode sourceModeForScope(CaptureScope scope); // --- Range inference: razor-else-time (orthogonal to scope) ------------------- // -// Every scope action infers its render range the same way: if a razor area is -// present, use the razor union; otherwise use the time selection. Razor is a -// range SOURCE, not a capture mode (the M7 four-mode model conflated them). +// Razor-present -> razor union; otherwise time selection. Razor is a range +// source, not a capture mode. enum class RangeSource { Razor, // a razor area is present -> use its union bound TimeSelection, // no razor -> use the time selection }; -// Picks the range source. Pure so the "razor wins when present" rule is tested -// without a DAW; the shell supplies whether any razor area was found. +// Picks the range source. Pure so "razor wins when present" is tested without +// a DAW; the shell supplies whether any razor area was found. RangeSource inferRangeSource(bool hasRazorArea); // --- FX-bypass plan: which tracks' FX to bypass for a scope ------------------- // -// Given a CaptureScope, returns three boolean flags: whether to bypass (a) the -// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX, -// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to -// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and -// clears I_FXEN on each flagged track, snapshotting first (RAII restore). -// -// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan. -// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN -// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item -// capture rendered via master does NOT bake in the parent/folder/master fader -// level (Daniel: the capture is likely re-routed through that chain later). PAN -// is deliberately left untouched (D_PAN is coupled to D_WIDTH/D_PANLAW — a clean -// neutralize is non-trivial; flagged as a follow-up, not half-done). This plan -// selects the SET; the guard applies both the FX bypass and the gain neutralize. +// Given a CaptureScope, returns three boolean flags: bypass (a) the captured +// track's OWN FX, (b) every ancestor (parent/folder) track's FX, (c) the +// master FX. The caller (FxBypassGuard, shell) walks the ancestor chain via +// GetParentTrack, clears I_FXEN on each flagged track (RAII restore), and also +// neutralizes D_VOL/D_PAN/D_WIDTH/D_PANLAW to unity/center on the same set — +// I_FXEN alone doesn't touch a track's volume/pan. This plan selects the set; +// the guard applies both the FX bypass and the neutralize. struct FxBypassPlan { bool bypassSelfFx = false; // the captured track's own FX bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX @@ -213,38 +147,24 @@ struct RazorRange { }; // Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated -// TRIPLES of . The envelope GUID is "" (an empty -// quoted string, i.e. the literal two chars `""`) for a track-audio area and a -// GUID like {…} for an envelope-lane area. -// -// Returns only the track-audio ranges (envelope-lane triples are skipped — razor -// captures target track audio, not envelope lanes). Malformed/short trailing tokens are ignored, not fatal. -// A range with end <= start is dropped (no negative/empty areas leak through). +// TRIPLES of , envGuid == `""` for a track-audio +// area vs a GUID for an envelope-lane area. Returns only track-audio ranges +// (envelope-lane triples skipped); malformed trailing tokens are ignored, not +// fatal; a range with end <= start is dropped. std::vector parseRazorEdits(const std::string& razorString); // The union bound (min start, max end) of a set of razor ranges — the exact -// window the offline render must cover so every area is inside the rendered file. -// Returns {0,0} for an empty input (caller treats that as "no razor area"). +// window the offline render must cover. {0,0} for empty input ("no razor area"). RazorRange razorUnionBounds(const std::vector& ranges); // --- Capture-action taxonomy (the bindable set main.cpp registers) ----------- // -// One row per bindable SCOPE action: item and track. The range each captures -// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action -// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads -// at fire time (see tail_control + bank_panel), so a single pair of actions covers -// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos). +// One row per bindable scope action (item/track); range inference and tail +// mode are read at fire time, not baked into the row. The row stores only the +// channel-agnostic command-id SUFFIX + description PHRASE; the registering +// shell composes the full channel-qualified id/name via app_version. // -// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the -// tail after the family prefix) and a description PHRASE (the label after the "ReaSampler: -// " lead). The registering shell composes the full, channel-qualified id/name via -// app_version's channelCommandId / channelActionName (commandIdPrefix + suffix / -// actionDisplayPrefix + phrase). This keeps the pure table free of any channel branch: -// stable rebuilds the exact shipped id "CEREBELLUM_REASAMPLER_CAPTURE_TRACK" from -// prefix + "CAPTURE_TRACK"; beta yields "CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK". -// -// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id) — never -// change a shipped value. baseName feeds the file stem (sanitized by capture_paths). +// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id). struct CaptureActionDef { const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix) const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase @@ -252,14 +172,11 @@ struct CaptureActionDef { CaptureScope scope; // FX scope (item / track) }; -// The capture-action table. Iterated by main.cpp to register the family and route -// each fired command back to its definition. Kept here (pure) so the taxonomy is -// one testable list, not scattered registration code. +// The capture-action table. Iterated by main.cpp to register the family and +// route each fired command back to its definition. // -// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to capture -// the master you render a track. Razor is an inferred range, not a mode, and each -// scope enforces its FX-scope invariant via fxBypassPlanFor. The tail mode each -// capture applies is read from the docked-panel setting, not baked into the row. +// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to +// capture the master you render a track. const std::vector& captureActionTable(); } // namespace reasampler::capture diff --git a/src/core/capture/tail_control.cpp b/src/core/capture/tail_control.cpp index 01b9ad8..1c5563a 100644 --- a/src/core/capture/tail_control.cpp +++ b/src/core/capture/tail_control.cpp @@ -1,4 +1,4 @@ -// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor. +// tail_control — pure implementation. See tail_control.h. #include "core/capture/tail_control.h" @@ -19,14 +19,10 @@ TailMode cycleTailMode(TailMode current) { } double clampManualMs(double manualMs) { - // Same runaway guard the pure tailRenderSettingsFor applies to Manual: floor a - // negative request to 0, cap at the 8 s ceiling. return std::clamp(manualMs, 0.0, kMaxTailMs); } double adjustManualMs(double current, int notches, double stepMs) { - // Clamp the stepped value so both scroll directions saturate at the bounds rather - // than running away (the same [0, kMaxTailMs] guard clampManualMs enforces). return clampManualMs(current + notches * stepMs); } @@ -35,8 +31,8 @@ std::string tailToggleLabel(const TailSetting& setting) { case TailMode::None: return "Tail: Off"; case TailMode::Auto: return "Tail: Auto"; case TailMode::Manual: { - // Append the CLAMPED length in seconds to one decimal so the readout can - // never show an over-cap value even if manualMs was stored past the cap. + // Clamped so the readout can't show an over-cap value even if + // manualMs was stored past the cap. const double seconds = clampManualMs(setting.manualMs) / 1000.0; char buf[32]; std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds); @@ -46,17 +42,9 @@ std::string tailToggleLabel(const TailSetting& setting) { return "Tail: Off"; // unreachable for a valid enum; fail to the safe default } -// --------------------------------------------------------------------------- -// JSON round-trip -// --------------------------------------------------------------------------- -// -// The setting is a flat object of one enum + one double, riding the shared -// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader — -// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17 -// significant digits (%.17g) — the shortest form that round-trips every IEEE-754 -// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit. -// deserialize stays forgiving in outcome: any parse failure returns nullopt so -// the caller falls back to a default, exactly as an absent ext-state key does. +// --- JSON round-trip --------------------------------------------------------- +// manualMs round-trips exactly (json::numToStr uses the shortest %.17g-class +// form for doubles); deserialize returns nullopt on any parse failure. namespace { diff --git a/src/core/capture/tail_control.h b/src/core/capture/tail_control.h index fa4ae4b..fba7687 100644 --- a/src/core/capture/tail_control.h +++ b/src/core/capture/tail_control.h @@ -1,13 +1,7 @@ #pragma once // tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode -// toggle. The panel shell (shell/panel/) owns the SWELL window, LICE drawing, and -// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length -// clamp, and the toggle's label text — lives here so it is unit-tested outside the -// DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library -// only (plus render_settings for the pure TailMode enum). Builds and unit-tests -// without REAPER. +// toggle. The panel shell owns the SWELL window, LICE drawing, and click +// hit-testing; the cycle order, manual-length clamp, and label text live here. #include #include @@ -16,54 +10,40 @@ namespace reasampler::capture { -// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of -// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a -// project with no stored tail setting (older / never-adjusted) falls back to on load. +// The Manual-mode starting length: 2s, a musically useful default (a bar of +// reverb throw at moderate tempo), well under the 8s cap. Also the fallback +// for a project with no stored tail setting. inline constexpr double kDefaultManualTailMs = 2000.0; -// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough -// that a few notches cover the useful range, fine enough to dial a length precisely. -// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs. +// Fine-adjust step per scroll-wheel notch in Manual mode. Daniel-set. inline constexpr double kManualStepMs = 250.0; -// The panel's current tail setting: the mode plus the length used ONLY when the -// mode is Manual. Held as in-memory panel/session state (shell/panel), default -// None so a capture with no explicit choice stays exact-bounds / byte-identical to -// today. `manualMs` is a stored default a future fine-adjust UI can tune; it is -// clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest. +// The panel's current tail setting: mode + the length used only when Manual. +// Default None so a capture with no explicit choice stays exact-bounds. +// `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest. struct TailSetting { TailMode mode = TailMode::None; double manualMs = kDefaultManualTailMs; }; -// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is -// pinned by a test and the panel's click handler owns no enum arithmetic of its own. -// An out-of-range value (unreachable for a valid enum) cycles back to None. +// Cycles the tail mode: None -> Auto -> Manual -> None. TailMode cycleTailMode(TailMode current); -// The effective manual length a Manual capture uses: `manualMs` clamped to -// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies). -// Exposed so the panel can show the clamped value and main.cpp hands a pre-clamped -// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual. +// The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs]. +// Exposed so the panel can show the clamped value. Meaningful only for Manual. double clampManualMs(double manualMs); -// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to -// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust -// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler -// owns no arithmetic of its own. Meaningful only for TailMode::Manual. +// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped +// to [0, kMaxTailMs]. Meaningful only for TailMode::Manual. double adjustManualMs(double current, int notches, double stepMs); -// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto". In Manual mode the -// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" — -// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are -// test-pinned, including the boundary lengths (0.0s, 8.0s). +// The toggle's label, e.g. "Tail: Off", "Tail: Auto", or (Manual, clamped +// length to one decimal) "Tail: Manual 2.0s". std::string tailToggleLabel(const TailSetting& setting); -// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail -// setting per-project alongside the bank and view model. Kept pure/testable here — -// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a -// compact object; deserialize returns std::nullopt on malformed input so the caller -// (persist) falls back to a default setting, exactly as an absent key does. +// JSON round-trip of a TailSetting, for persist to store per-project. Pure/ +// testable here, mirroring bank_model's serialize/deserialize; deserialize +// returns nullopt on malformed input so the caller falls back to a default. std::string serializeTailSetting(const TailSetting& setting); std::optional deserializeTailSetting(const std::string& json); diff --git a/src/core/capture/wav_codec.cpp b/src/core/capture/wav_codec.cpp index 99d76fb..a08268e 100644 --- a/src/core/capture/wav_codec.cpp +++ b/src/core/capture/wav_codec.cpp @@ -1,7 +1,6 @@ -// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor. -// -// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the -// content hash both walk with it, so their view of the container cannot drift. +// wav_codec — pure implementation. See wav_codec.h. The one RIFF chunk +// traversal lives here (nextWavChunk); layout parse and content hash both +// walk with it, so their view of the container cannot drift. #include "core/capture/wav_codec.h" @@ -12,8 +11,7 @@ namespace reasampler::capture { namespace { -// Little-endian readers. Bounds are checked by the caller before each read; these -// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB. +// Little-endian readers. Caller checks bounds before each read (off + N <= size). std::uint16_t readU16LE(const std::vector& b, std::size_t off) { return static_cast(b[off] | (b[off + 1] << 8)); } @@ -28,7 +26,7 @@ bool tagEquals(const std::vector& b, std::size_t off, const char* return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; } -// WAVE format tags we accept as 32-bit float (see wav_codec.h FORMAT ASSUMPTION). +// WAVE format tags we accept as 32-bit float (see wav_codec.h). constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003; constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; @@ -37,19 +35,16 @@ constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL; constexpr std::uint64_t kFnvPrime = 1099511628211ULL; std::string fnvHex(std::uint64_t h) { - // 16-digit lowercase hex (zero-padded) for a fixed-length string. - char buf[17]; + char buf[17]; // 16 hex digits, zero-padded std::snprintf(buf, sizeof(buf), "%016llx", static_cast(h)); return std::string(buf); } -// --- The ONE RIFF chunk traversal -------------------------------------------- +// --- The one RIFF chunk traversal -------------------------------------------- // -// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at -// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`. -// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk -// whose declared size lies past the end is still REPORTED (callers decide how to -// treat it) but its body must not be read. +// One sub-chunk of a RIFF/WAVE container: header at `headerOffset` (id(4) + +// size(4)), body at `bodyOffset`/`bodySize`. `bodyInBounds` false means the +// declared body runs past the buffer — still reported, but must not be read. struct WavChunkView { std::size_t headerOffset = 0; std::size_t bodyOffset = 0; @@ -60,9 +55,8 @@ struct WavChunkView { // Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call // fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte // padding. Returns false when no further chunk header fits. If the padded advance -// would overrun the buffer, the chunk is still reported (return true) and `pos` is -// parked past the end so the NEXT call returns false — exactly the process-then- -// break shape the pre-consolidation walkers shared. +// would overrun the buffer, the chunk is still reported (return true) and `pos` +// is parked past the end so the next call returns false. bool nextWavChunk(const std::vector& bytes, std::size_t& pos, WavChunkView& out) { if (pos + 8 > bytes.size()) return false; @@ -100,8 +94,8 @@ WavLayout parseWavLayout(const std::vector& bytes) { std::uint32_t sampleRate = 0; std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible - // Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A - // malformed/truncated file is "invalid", never an OOB read. + // Walk the sub-chunks after "WAVE" (offset 12). A malformed/truncated file + // is "invalid", never an OOB read. std::size_t pos = 12; WavChunkView c; while (nextWavChunk(bytes, pos, c)) { @@ -112,11 +106,9 @@ WavLayout parseWavLayout(const std::vector& bytes) { channels = readU16LE(bytes, c.bodyOffset + 2); sampleRate = readU32LE(bytes, c.bodyOffset + 4); bitsPerSample = readU16LE(bytes, c.bodyOffset + 14); - // For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading - // 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM - // integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to - // reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in - // the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected). + // WAVE_FORMAT_EXTENSIBLE: the real format lives in the SubFormat GUID's + // leading 2-byte tag at body offset 24, not in fmtTag itself. Body must + // reach offset 24+16; otherwise leave the tag at 0 (rejected). if (fmtTag == kWaveFormatExtensible) { if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) { extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24); @@ -124,16 +116,13 @@ WavLayout parseWavLayout(const std::vector& bytes) { } haveFmt = true; } else if (tagEquals(bytes, c.headerOffset, "data")) { - // The data chunk: PCM starts at bodyOffset, declared length bodySize. - // Reject if it runs past the buffer (truncated / lying header). + // Reject if the declared body runs past the buffer (truncated/lying + // header), or if data arrived before fmt. if (!c.bodyInBounds) return out; - if (!haveFmt) return out; // data before fmt — not a WAV we parse + if (!haveFmt) return out; - // Plain IEEE-float tag (0x0003): accept as-is. - // Extensible tag (0xFFFE): accept only when the SubFormat tag read from - // the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag - // 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT - // float and must be rejected to prevent mis-decoding as float. + // Extensible tag (0xFFFE) is float only when its SubFormat sub-tag is + // also IEEE-float (0x0003) — PCM-integer-in-extensible must be rejected. const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || (fmtTag == kWaveFormatExtensible && extensibleSubFormatTag == kWaveFormatIeeeFloat); @@ -279,12 +268,6 @@ std::string hashBytes(const std::uint8_t* data, std::size_t len) { } std::string hashWavContent(const std::vector& bytes) { - // Walk the RIFF/WAVE container (the shared traversal) 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. if (isRiffWave(bytes)) { std::uint64_t h = kFnvOffsetBasis; auto feedByte = [&](std::uint8_t b) { @@ -295,23 +278,18 @@ std::string hashWavContent(const std::vector& bytes) { 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')); + feedByte(static_cast('W')); // domain-separation prefix std::size_t pos = 12; WavChunkView c; while (nextWavChunk(bytes, pos, c)) { if (tagEquals(bytes, c.headerOffset, "fmt ")) { - // Feed the entire fmt body (all fields, including format tag, channels, - // sample rate, bits-per-sample — everything that defines the audio format). if (c.bodyInBounds) { for (std::uint32_t i = 0; i < c.bodySize; ++i) feedByte(bytes[c.bodyOffset + i]); haveFmt = true; } } else if (tagEquals(bytes, c.headerOffset, "data")) { - // Feed the entire PCM payload. if (c.bodyInBounds) { for (std::uint32_t i = 0; i < c.bodySize; ++i) feedByte(bytes[c.bodyOffset + i]); diff --git a/src/core/capture/wav_codec.h b/src/core/capture/wav_codec.h index e85aa75..615f2e8 100644 --- a/src/core/capture/wav_codec.h +++ b/src/core/capture/wav_codec.h @@ -1,39 +1,9 @@ #pragma once -// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e: -// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32 -// build + size-field patch + the WAV-aware content hash, in one tested module. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. Builds and unit-tests without REAPER. -// -// Before this module, RIFF container knowledge (chunk-header arithmetic, even-byte -// padding, size fields) was minted at four sites: wav_trim's layout parse, -// capture_paths' content-hash chunk walk, ingest's hand-built float32 writer, and -// capture_realtime's in-place size patch. A drift in any one (e.g. pad-byte -// handling) would desynchronize hashing from decoding — the dedup-by-hash and -// null-test invariants both sit on this. Now every walker/builder/patcher is here, -// on ONE chunk-traversal implementation. -// -// WHY TRIM EXISTS (docs/product/capture-tail.md §The realtime path). The realtime -// backend records a generous tail window, then trims the trailing decay by -// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is -// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk -// size and the `data` sub-chunk size) must be patched to the kept byte count, or -// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking, -// format verification, and the size-field patch offsets — is exactly the fiddly, -// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER -// shell does only the file I/O: read the bytes, call the pure parse, run the decay -// scan, call the pure plan, patch + write the truncated bytes. -// -// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV -// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project -// record format, which the manual procedure sets to WAV/32-bit-float). The parser -// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt ` -// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE -// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything -// else (a different depth, a non-WAV, a compressed source) is reported invalid and -// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a -// file it does not understand. This is deliberately conservative. +// wav_codec — the pure owner of the WAV/RIFF byte format: chunk walker, layout +// parse, float32 build, size-field patch, and the WAV-aware content hash — one +// chunk traversal shared by all of them so hashing and decoding cannot desync. +// Handles 32-bit float WAV only (RIFF/WAVE, `fmt ` tag 3 or 0xFFFE-extensible +// w/ float subformat, float32 `data`); anything else parses as invalid. #include #include @@ -49,22 +19,20 @@ using audio::AudioSample; // --- Layout parse ------------------------------------------------------------ // The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the -// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field -// is meaningful only when valid. +// bytes are not a WAV we can safely trim; every other field is meaningful only +// when valid. struct WavLayout { bool valid = false; - std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride) - std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed) + std::uint16_t channelCount = 0; // from `fmt ` (interleave stride) + std::uint32_t sampleRate = 0; - // The `data` chunk: byte offset of its first PCM byte within the file, and its - // declared PCM byte length. frameCount = dataByteLength / (channelCount * 4). + // The `data` chunk: PCM byte offset + declared length. + // frameCount = dataByteLength / (channelCount * 4). std::size_t dataByteOffset = 0; std::size_t dataByteLength = 0; - // Byte offset of the two little-endian uint32 size fields the truncate patch - // rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk - // size (the 4 bytes immediately before dataByteOffset). + // Offsets of the two LE uint32 size fields the truncate patch rewrites. std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file std::size_t dataSizeFieldOffset = 0; @@ -74,19 +42,15 @@ struct WavLayout { } }; -// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything -// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk, -// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only -// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB). +// Parses a WAV byte buffer's header geometry; {valid=false} for anything not a +// canonical float32 RIFF/WAVE, or a `data` length running past the buffer. +// Does not copy PCM, only locates it. Pure + total (no throw, no UB). WavLayout parseWavLayout(const std::vector& bytes); -// Copies `frameCount` interleaved float frames starting at `startFrame` out of the -// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). -// Clamps to the frames the buffer actually holds — never reads past `data`. Returns -// empty for an invalid layout or an out-of-range start. The floats are read -// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would -// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux -// on x86/ARM-LE) is little-endian and REAPER writes LE WAV. +// Copies `frameCount` interleaved float frames starting at `startFrame` out of +// the WAV's `data` region into a flat [f0c0,f0c1,...] buffer, clamped to frames +// actually present; never reads past `data`. Reads little-endian via memcpy — +// target is x86/ARM-LE only, no big-endian byte-swap. std::vector extractFloatFrames(const std::vector& bytes, const WavLayout& layout, std::size_t startFrame, @@ -94,10 +58,8 @@ std::vector extractFloatFrames(const std::vector& byt // --- Truncate plan + size-field patch --------------------------------------- -// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte -// length and the two size-field values to patch. `valid` is false if the layout is -// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller -// clamps beforehand; this guards it too). +// The plan to truncate a parsed WAV to `keptFrames` frames. `valid` is false if +// the layout is invalid or keptFrames exceeds the file's frames (never grow). struct WavTruncatePlan { bool valid = false; @@ -109,64 +71,37 @@ struct WavTruncatePlan { // the 8-byte "RIFF"+size prefix) }; -// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV. -// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure + -// total. The shell applies it: patch the two size fields in the byte buffer -// (patchU32LE), then truncate the file to newFileByteLength. +// Computes the truncate plan to keep exactly `keptFrames` frames. The shell +// applies it: patch the two size fields (patchU32LE), then truncate to +// newFileByteLength. WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); -// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size -// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size() -// (the plan's offsets came from a valid parse of the same buffer). +// Patches a little-endian uint32 into a byte buffer at `off`. Caller guarantees +// off + 4 <= bytes.size() (the plan's offsets came from a valid parse of the same +// buffer). void patchU32LE(std::vector& bytes, std::size_t off, std::uint32_t v); // --- Float32 WAV build ------------------------------------------------------- -// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved -// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, -// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels, -// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is -// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT -// ASSUMPTION above); the reduction is intentional. The output round-trips through -// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical -// source through REAPER's PCM_source, then writes the bank copy with this. +// Builds a minimal canonical float32 RIFF/WAVE byte buffer from interleaved +// double samples (narrowed to float by cast). Round-trips through +// parseWavLayout/extractFloatFrames. std::vector buildFloat32Wav(int nch, std::uint32_t rate, std::size_t frameCount, const std::vector& interleaved); // --- Content identity (dedup hashes) ----------------------------------------- -// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data` -// and returns it as a 16-character lowercase hex string. Designed to fill -// Sample::contentHash so the confirm-on-last-reference guardrail -// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this -// file" from "another bank holds the same file." An empty buffer returns the bare -// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty -// files would share, but real WAV files are never empty). +// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase +// hex. Fills Sample::contentHash for the confirm-on-last-reference dedup guardrail. std::string hashBytes(const std::uint8_t* data, std::size_t len); -// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float -// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all -// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED). -// -// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a -// `bext` chunk containing the origination date/time) even when the format config blob -// requests no BWF metadata. Two renders of identical audio therefore differ in those -// bytes, making whole-file hashes diverge and preventing dedup collapse. -// -// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before -// the fmt/data bytes are fed in, so a content hash can never equal a whole-file -// hashBytes result for a different file of the same size. -// -// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a -// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) — -// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an -// unrecognized or malformed file still gets a non-empty hash rather than silently -// skipping dedup. -// -// Called by both capture commit paths (offline and realtime) and the ingest import -// in place of the raw hashBytes call. Walks the container with the SAME chunk -// traversal parseWavLayout uses, so hashing and decoding can never desynchronize. +// WAV-aware content hash: hashes only the `fmt ` body + `data` payload, skipping +// other chunks. WHY: REAPER's offline renderer embeds a render-varying `bext` +// timestamp chunk even with no BWF metadata requested, so two renders of +// identical audio would otherwise hash differently and never dedup. Prefixed +// with tag byte 'W' so it can't collide with a same-size hashBytes result. +// Falls back to whole-file hashBytes (no prefix) for a file that doesn't parse. std::string hashWavContent(const std::vector& bytes); } // namespace reasampler::capture diff --git a/src/core/version/app_version.cpp b/src/core/version/app_version.cpp index 568f02d..9d1db36 100644 --- a/src/core/version/app_version.cpp +++ b/src/core/version/app_version.cpp @@ -1,10 +1,9 @@ -// app_version.cpp — implementation of the pure version-identity core (Phase V, V1 + V4). -// See app_version.h for the contract. The version STRING and the channel bit both come -// from version_generated.h (produced by CMake configure_file from the one -// REASAMPLER_VERSION variable + the REASAMPLER_CHANNEL flag) — this TU re-exports them and -// owns every pure derivation: the channel-qualified identity strings (V4) and the -// parse/compare/classify logic (V1). No #ifdef forks leak beyond this file; the shells -// consume the accessors below, so channel identity is one auditable definition. +// app_version.cpp — implementation of the pure version-identity core. See +// app_version.h for the contract. The version STRING and the channel bit both +// come from version_generated.h (CMake configure_file from REASAMPLER_VERSION + +// REASAMPLER_CHANNEL) — this TU re-exports them and owns every pure derivation. +// No #ifdef forks leak beyond this file; the shells consume the accessors +// below, so channel identity is one auditable definition. #include "core/version/app_version.h" @@ -15,9 +14,8 @@ namespace reasampler::version { namespace { -// The one channel predicate every derivation below branches on — the single point the -// configure_file'd bit enters the pure module. constexpr so the branches fold at compile -// time; the accessors still return by const ref for a stable shared instance. +// The one channel predicate every derivation below branches on. constexpr so +// the branches fold at compile time. constexpr bool kIsBeta = (REASAMPLER_CHANNEL_IS_BETA != 0); } // namespace @@ -26,9 +24,6 @@ Channel channel() { return kIsBeta ? Channel::Beta : Channel::Stable; } bool isBeta() { return kIsBeta; } const std::string& appVersion() { - // The user-visible render. Stable: EXACTLY the CMake string (leading zero and all). - // Beta: the same numeric string plus a plain "-beta" suffix (V2). Function-local - // static so callers share one authoritative instance. static const std::string kVersion = kIsBeta ? std::string(REASAMPLER_VERSION_STRING) + "-beta" : std::string(REASAMPLER_VERSION_STRING); @@ -36,32 +31,22 @@ const std::string& appVersion() { } const std::string& stampVersion() { - // The ext-state stamp value — the NUMERIC TRIPLE ONLY, IDENTICAL on both channels. - // No "-beta" suffix: it must parse as Stamped on read-back (a suffixed stamp classifies - // as Unknown), and stable's stamp stays byte-identical regardless of the channel build. - // The channel is carried by extStateNamespace(), never baked into the stamp. static const std::string kStamp = REASAMPLER_VERSION_STRING; return kStamp; } const std::string& extStateNamespace() { - // Stable "reasampler" is byte-identical to the pre-V4 build; beta is isolated. - // FOREVER-STABLE per channel. static const std::string kNs = kIsBeta ? "reasampler_beta" : "reasampler"; return kNs; } const std::string& commandIdPrefix() { - // Stable prefix is byte-identical to every shipped command id; beta is a distinct - // forever-family. FOREVER-STABLE per channel. static const std::string kPrefix = kIsBeta ? "CEREBELLUM_REASAMPLER_BETA_" : "CEREBELLUM_REASAMPLER_"; return kPrefix; } const std::string& actionDisplayPrefix() { - // Actions-list legibility: two channels must be distinguishable by name. Trailing - // space so callers append the action phrase directly. static const std::string kDisp = kIsBeta ? "ReaSampler beta: " : "ReaSampler: "; return kDisp; } @@ -79,26 +64,18 @@ const std::string& dockTitle() { } const std::string& dockIdent() { - // Persisted dock-position ident — FOREVER-STABLE per channel (changing it strands the - // saved dock slot). Beta qualified so the two panels do not fight over one slot. static const std::string kIdent = kIsBeta ? "reasampler_bank_panel_beta" : "reasampler_bank_panel"; return kIdent; } const std::string& vstOutputName() { - // The .vst3 module OUTPUT_NAME base — FOREVER-STABLE per channel. Stable is - // byte-identical to pre-S18 ("reasampler_9000"); beta is isolated so both install - // side-by-side without a filename collision. static const std::string kName = kIsBeta ? "reasampler_9000_beta" : "reasampler_9000"; return kName; } const std::string& vstPluginName() { - // The factory display name / editor title / embed label. Stable is byte-identical to - // pre-S18 ("ReaSampler 9000"); beta appends " beta" so the two channels are distinct - // plugins in the FX browser. static const std::string kName = kIsBeta ? "ReaSampler 9000 beta" : "ReaSampler 9000"; return kName; diff --git a/src/core/version/app_version.h b/src/core/version/app_version.h index 8ef85bf..ed40e42 100644 --- a/src/core/version/app_version.h +++ b/src/core/version/app_version.h @@ -1,51 +1,49 @@ #pragma once -// app_version — the REAPER-free version-identity core (Phase V, V1 + V4). The single -// source of truth for the version STRING lives in CMake (a `REASAMPLER_VERSION` -// variable threaded in via configure_file -> version_generated.h); this module -// re-exports it as the canonical constant and owns every pure operation on it: the -// exact-string render, the parse/compare arithmetic a within-channel forward -// migration will lean on, and the "which version wrote this project" result that -// persist reads back from ext state (absent stamp = pre-versioning, never an error). +// app_version — the REAPER-free version-identity core. The single source of +// truth for the version STRING lives in CMake (a `REASAMPLER_VERSION` variable +// threaded in via configure_file -> version_generated.h); this module re-exports +// it as the canonical constant and owns every pure operation on it: the +// exact-string render, the parse/compare arithmetic, and the "which version +// wrote this project" result persist reads back from ext state (absent stamp = +// pre-versioning, never an error). // -// V4 (beta-in-isolation) extends this module into the SINGLE SOURCE OF TRUTH FOR -// CHANNEL IDENTITY too. A compile-time flag (`-DREASAMPLER_CHANNEL=beta`, threaded -// through the same configure_file'd version_generated.h as REASAMPLER_CHANNEL_IS_BETA) -// selects stable (the default, absent-flag build — byte-for-byte today's identity) or -// a fully isolated beta build. Every channel-qualified identity string the shells -// register with REAPER — the display suffix, the ext-state namespace, the command-id -// prefix, the Actions-list name prefix, the binary/dock idents — is DERIVED HERE from -// the one channel bit, so no scattered #ifdef forks live across the translation units; -// the shells just consume these accessors. This keeps "what makes a beta a beta" one -// auditable definition and makes the channel-derived rendering unit-testable. +// This module is also the single source of truth for CHANNEL IDENTITY. A +// compile-time flag (`-DREASAMPLER_CHANNEL=beta`, threaded through +// version_generated.h as REASAMPLER_CHANNEL_IS_BETA) selects stable (the +// default, byte-for-byte today's identity) or a fully isolated beta build. +// Every channel-qualified identity string the shells register with REAPER — the +// display suffix, the ext-state namespace, the command-id prefix, the +// Actions-list name prefix, the binary/dock idents — is derived here from the +// one channel bit, so no scattered #ifdef forks live across translation units. // // PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER (mirror of bank_model / tail_control). +// only. // -// Leading-zero fidelity (V1, Daniel-fixed): the displayed/stamped string preserves the -// configured version EXACTLY as written — padded and unpadded versions are both -// legitimate (e.g. "0.9.8" and "0.9.80" are different versions; a "0.9.01" renders its -// zero-padded patch verbatim). That exactness is why the version STRING is the -// authoritative artifact (sourced verbatim from the one CMake variable), not a -// reconstruction from numeric components — CMake's `project(VERSION)` may normalize a -// numeric patch field, so we never round-trip the string through integers to render it. -// Guarded against reconstruct-from-components regressions in app_version.cpp by -// app_version_padding_tests (the padded canary build). The canary does NOT guard -// against the CMakeLists.txt source-of-truth line being changed to a CMake variable -// derivation — that case is guarded by the comment block at the top of CMakeLists.txt. +// Leading-zero fidelity (Daniel-fixed): the displayed/stamped string preserves +// the configured version EXACTLY as written — padded and unpadded versions are +// both legitimate (e.g. "0.9.8" and "0.9.80" are different versions; "0.9.01" +// renders its zero-padded patch verbatim). That's why the version STRING is the +// authoritative artifact, not a reconstruction from numeric components — CMake's +// `project(VERSION)` may normalize a numeric patch field, so we never round-trip +// the string through integers to render it. Guarded against +// reconstruct-from-components regressions by app_version_padding_tests (the +// padded canary build); it does NOT guard against the CMakeLists.txt +// source-of-truth line itself changing — that's guarded by the comment block at +// the top of CMakeLists.txt. #include #include namespace reasampler::version { -// --- Channel identity (V4, beta-in-isolation) --------------------------------------- +// --- Channel identity (beta-in-isolation) -------------------------------------------- // -// The build channel, fixed at compile time by REASAMPLER_CHANNEL_IS_BETA (0 = stable, -// the default absent-flag build; 1 = beta, from -DREASAMPLER_CHANNEL=beta). Stable is -// today's build with byte-identical identity in EVERY string below — any divergence on -// the stable channel is a defect. Beta forks every identity so a beta binary coexists -// with stable in one REAPER (both are dlopen'd at startup) without colliding on project -// ext-state, keybindings, or any REAPER-global registration. +// The build channel, fixed at compile time by REASAMPLER_CHANNEL_IS_BETA (0 = +// stable, the default; 1 = beta, from -DREASAMPLER_CHANNEL=beta). Stable is +// byte-identical to today's build in every string below — any divergence there +// is a defect. Beta forks every identity so a beta binary coexists with stable +// in one REAPER without colliding on project ext-state, keybindings, or any +// REAPER-global registration. enum class Channel { Stable, Beta }; // The channel this build was compiled for. Constant per binary. @@ -54,87 +52,75 @@ Channel channel(); // True on the beta build only. Convenience over channel() == Channel::Beta. bool isBeta(); -// The user-visible version render. Stable: EXACTLY the configured CMake string. Beta: -// that string plus a plain "-beta" suffix — a plain suffix, NOT a git-describe -// decoration (V2, Daniel-fixed). This is what the show-version action and the -// bank-panel readout display. It is NOT the ext-state stamp value (see stampVersion). +// The user-visible version render. Stable: exactly the configured CMake string. +// Beta: that string plus a plain "-beta" suffix (not a git-describe decoration). +// What the show-version action and bank-panel readout display — NOT the +// ext-state stamp value (see stampVersion). const std::string& appVersion(); -// The ext-state STAMP value — the writing-version recorded into a saved project. This is -// the NUMERIC TRIPLE ONLY (the configured string, no channel suffix) on BOTH channels: -// it deliberately carries NO channel suffix, so (a) parseVersion classifies it as -// Stamped when its own channel reads it back (a "-beta"-suffixed stamp would classify -// as Unknown — the V4 stamp-classifiability requirement), and (b) stable's stamp value -// is byte-identical regardless of the channel build. The channel is carried by the -// ISOLATED namespace (see extStateNamespace), never baked into the stamp. Distinct from -// appVersion() precisely so the display can say "-beta" while the stamp stays -// classifiable and stable-identical. +// The ext-state STAMP value — the writing-version recorded into a saved +// project. The numeric triple only, no channel suffix, on BOTH channels: (a) so +// parseVersion classifies it as Stamped when its own channel reads it back (a +// "-beta"-suffixed stamp would classify as Unknown), and (b) so stable's stamp +// is byte-identical regardless of channel build. The channel is carried by the +// isolated namespace (see extStateNamespace), never baked into the stamp. const std::string& stampVersion(); -// The project ext-state namespace this channel reads and writes. Stable: "reasampler" -// (byte-identical to the pre-V4 build). Beta: "reasampler_beta". FOREVER-STABLE per -// channel once shipped — changing either orphans every already-saved project's state. +// The project ext-state namespace this channel reads and writes. Stable: +// "reasampler". Beta: "reasampler_beta". FOREVER-STABLE per channel once +// shipped — changing either orphans every already-saved project's state. // -// ISOLATION SEMANTICS (V4, accepted — not a bug): a channel reads/writes ONLY its own -// namespace. A project saved by stable shows empty/default ReaSampler state when opened -// in beta, and vice versa. There is NO cross-namespace read, migration, or fallback in -// this wave — that isolation is the safety property (a beta can never read or rewrite a -// stable project's bank/view/tail state). +// ISOLATION (accepted, not a bug): a channel reads/writes ONLY its own +// namespace — a project saved by stable shows empty/default state when opened +// in beta, and vice versa. No cross-namespace read, migration, or fallback: a +// beta can never read or rewrite a stable project's bank/view/tail state. const std::string& extStateNamespace(); -// The FOREVER-STABLE command-id prefix every bindable action mints its id from. Stable: -// "CEREBELLUM_REASAMPLER_" (byte-identical to the shipped ids). Beta: -// "CEREBELLUM_REASAMPLER_BETA_", a DISTINCT forever-family so beta and stable actions -// never collide in REAPER's one Actions list and their keybindings stay independent. -// Callers concatenate their per-action suffix onto this (e.g. prefix + "CAPTURE_TRACK"). -// PERMANENT once a beta ships — mark any minted id FOREVER-STABLE like stable's. +// The FOREVER-STABLE command-id prefix every bindable action mints its id from. +// Stable: "CEREBELLUM_REASAMPLER_". Beta: "CEREBELLUM_REASAMPLER_BETA_", a +// distinct forever-family so beta and stable actions never collide in REAPER's +// one Actions list. Callers concatenate their per-action suffix onto this. const std::string& commandIdPrefix(); -// The Actions-list DISPLAY-NAME prefix, so two coexisting channels are distinguishable in -// REAPER's Actions list. Stable: "ReaSampler: " (unchanged). Beta: "ReaSampler beta: ". -// Callers build a gaccel desc as actionDisplayPrefix() + "capture selected track", etc. +// The Actions-list display-name prefix, so two coexisting channels are +// distinguishable. Stable: "ReaSampler: ". Beta: "ReaSampler beta: ". Callers +// build a gaccel desc as actionDisplayPrefix() + "capture selected track", etc. const std::string& actionDisplayPrefix(); // The binary/module OUTPUT NAME base. Stable: "reaper_reasampler". Beta: -// "reaper_reasampler_beta". Mirrors the CMake OUTPUT_NAME (which is the authoritative -// artifact name); exposed here for any in-binary self-identification. REAPER dlopen's -// any reaper_* module, so both channels load side-by-side. +// "reaper_reasampler_beta". Mirrors the CMake OUTPUT_NAME. REAPER dlopen's any +// reaper_* module, so both channels load side-by-side. const std::string& binaryName(); -// The docked bank-panel identity strings, channel-qualified so the two panels are -// distinguishable and do not fight over one persisted dock slot (a REAPER-global -// collision surface — DockWindowAddEx's identstr keys the saved dock position). -// dockTitle() — the visible dock tab title. Stable: "ReaSampler Bank". -// Beta: "ReaSampler Bank beta". -// dockIdent() — the persisted dock-position ident. Stable: "reasampler_bank_panel". -// Beta: "reasampler_bank_panel_beta". FOREVER-STABLE per channel. +// The docked bank-panel identity strings, channel-qualified so the two panels +// don't fight over one persisted dock slot (DockWindowAddEx's identstr keys the +// saved dock position). +// dockTitle() — visible dock tab title. Stable: "ReaSampler Bank". +// dockIdent() — persisted dock-position ident. Stable: "reasampler_bank_panel". +// FOREVER-STABLE per channel. const std::string& dockTitle(); const std::string& dockIdent(); -// --- VST3 instrument identity (S18, beta-in-isolation) ------------------------------ +// --- VST3 instrument identity (beta-in-isolation) ------------------------------ // -// The ReaSampler 9000 VST3 instrument forks its plugin identity per channel exactly as the -// extension forks its binary/dock idents above — one channel per binary, all derived from -// the ONE channel bit here, so the VST shell carries no #ifdef fork. These are the VST's -// analogues of binaryName()/dockTitle(): the on-disk module name and the human-facing name. +// The ReaSampler 9000 VST3 instrument forks its plugin identity per channel +// exactly as the extension forks its binary/dock idents above. These are the +// VST's analogues of binaryName()/dockTitle(): the on-disk module name and the +// human-facing name. // -// vstOutputName() — the CMake OUTPUT_NAME base for the .vst3 module. Stable: -// "reasampler_9000" (byte-identical to pre-S18). Beta: -// "reasampler_9000_beta". Mirrors the CMake target's OUTPUT_NAME (the -// authoritative artifact name); exposed here so the one derivation lives -// in this module. FOREVER-STABLE per channel — the on-disk filename a -// REAPER project's saved instance path may reference. -// vstPluginName() — the factory display name (FX browser), editor title band, and S6 -// embed-strip label. Stable: "ReaSampler 9000". Beta: -// "ReaSampler 9000 beta". Sourced from here, never a literal in -// reasampler_vst.h / vst_entry.cpp / the editor / the embed strip. +// vstOutputName() — CMake OUTPUT_NAME base for the .vst3 module. Stable: +// "reasampler_9000". FOREVER-STABLE per channel — the +// on-disk filename a saved project's instance may reference. +// vstPluginName() — factory display name (FX browser), editor title band, and +// embed-strip label. Stable: "ReaSampler 9000". Sourced from +// here, never a literal in reasampler_vst.h / vst_entry.cpp / +// the editor / the embed strip. // -// NOTE: the VST3 CLASS UID is NOT here — a UID is not a string derivation but a compile-time -// FUID/INLINE_UID constant the factory needs in brace-init form; it lives in reasampler_vst.h, -// channel-selected by the same REASAMPLER_CHANNEL_IS_BETA bit. This module owns the string -// identity; reasampler_vst.h owns the binary UID identity. The version display the factory -// stamps into PClassInfo2 reuses appVersion() (it already renders "-beta" on beta) — no -// separate VST version accessor. +// The VST3 CLASS UID is NOT here — it's a compile-time FUID/INLINE_UID constant +// the factory needs in brace-init form; it lives in reasampler_vst.h, +// channel-selected by the same bit. This module owns the string identity; +// reasampler_vst.h owns the binary UID identity. The factory's PClassInfo2 +// version display reuses appVersion() — no separate VST version accessor. const std::string& vstOutputName(); const std::string& vstPluginName(); @@ -156,10 +142,10 @@ const std::string& vstPluginName(); std::string channelCommandId(const std::string& suffix); std::string channelActionName(const std::string& phrase); -// A parsed semver triple. Kept minimal — major.minor.patch as integers, for ORDERING -// only. It deliberately does NOT round-trip back to the display string (the leading -// zero is a rendering concern owned by the authoritative string, not reconstructable -// from the integer patch). parseVersion returns nullopt on malformed input. +// A parsed semver triple. Kept minimal — major.minor.patch as integers, for +// ordering only. Deliberately does NOT round-trip back to the display string +// (the leading zero is a rendering concern, not reconstructable from the +// integer patch). struct Version { int major = 0; int minor = 0;