Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green

This commit is contained in:
2026-07-28 20:48:56 -04:00
parent 67a41728f3
commit 847936f813
222 changed files with 2247 additions and 2079 deletions
+76
View File
@@ -0,0 +1,76 @@
// batch_capture.cpp — pure logic for M11 batch capture. See header.
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp.
#include "core/capture/batch_capture.h"
#include <algorithm>
namespace reasampler::capture {
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges) {
std::vector<CaptureUnit> units;
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.
if (!(r.endSeconds > r.startSeconds)) continue;
++ordinal;
units.push_back({ordinal, r.startSeconds, r.endSeconds});
}
return units;
}
void BatchOutcome::record(int ordinal, bool ok, std::string detail) {
results_.push_back({ordinal, ok, std::move(detail)});
}
std::size_t BatchOutcome::succeeded() const {
return static_cast<std::size_t>(
std::count_if(results_.begin(), results_.end(),
[](const BatchUnitResult& r) { return r.ok; }));
}
std::size_t BatchOutcome::failed() const {
return results_.size() - succeeded();
}
std::vector<BatchUnitResult> BatchOutcome::failures() const {
std::vector<BatchUnitResult> out;
for (const BatchUnitResult& r : results_)
if (!r.ok) out.push_back(r);
return out;
}
std::string BatchOutcome::summaryLine(const std::string& noun) const {
const std::size_t n = total();
const std::size_t ok = succeeded();
if (n == 0)
return "ReaSampler batch capture: nothing to capture.";
const std::string plural = (n == 1) ? noun : noun + "s";
if (ok == n)
return "ReaSampler batch capture: " + std::to_string(ok) + " " +
plural + " captured.";
// Mixed / all-failed: report the ratio and enumerate the failed ordinals so the
// user knows exactly which units to retry. No partial corruption is implied —
// each captured unit is a complete, independent bank sample.
std::string line = "ReaSampler batch capture: " + std::to_string(ok) + " of " +
std::to_string(n) + " " + plural + " captured (" +
std::to_string(n - ok) + " failed: ";
bool first = true;
for (const BatchUnitResult& f : results_) {
if (f.ok) continue;
if (!first) line += ", ";
line += "#" + std::to_string(f.ordinal);
first = false;
}
line += ").";
return line;
}
} // namespace reasampler::capture
+100
View File
@@ -0,0 +1,100 @@
#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).
//
// 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:
//
// 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.
//
// 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.
#include <cstddef>
#include <string>
#include <vector>
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.
struct CaptureUnit {
int ordinal = 0; // 1-based, counts kept units only
double startSeconds = 0.0; // exact — no rounding
double endSeconds = 0.0;
};
// 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.
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).
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
// The per-unit verdict the shell records after each render attempt, in unit order.
struct BatchUnitResult {
int ordinal = 0; // the CaptureUnit's ordinal this result is for
bool ok = false; // true iff the render + bank-add succeeded
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).
class BatchOutcome {
public:
// Records one unit's verdict. Order of calls IS the reported order.
void record(int ordinal, bool ok, std::string detail = {});
std::size_t total() const { return results_.size(); }
std::size_t succeeded() const;
std::size_t failed() const;
const std::vector<BatchUnitResult>& results() const { return results_; }
// The ordered subset of results that failed (ok == false). For the summary line.
std::vector<BatchUnitResult> failures() const;
// A single human summary line for the console (explicit-action response — allowed
// by the console policy; a batch-completion summary with failure counts qualifies,
// per-unit success spam does not). `noun` is the unit word ("item" / "razor area").
// Examples:
// all-success, 3 items : "ReaSampler batch capture: 3 items captured."
// partial, 3 of 5 : "ReaSampler batch capture: 3 of 5 items captured "
// "(2 failed: #2, #4)."
// empty plan : "ReaSampler batch capture: nothing to capture."
std::string summaryLine(const std::string& noun) const;
private:
std::vector<BatchUnitResult> results_;
};
} // namespace reasampler::capture
+287
View File
@@ -0,0 +1,287 @@
#include "core/capture/capture_paths.h"
#include <cassert>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstring> // std::memcmp
#include <filesystem>
#include <vector>
namespace reasampler::capture {
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
// Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/).
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kPrime = 1099511628211ULL;
std::uint64_t h = kOffsetBasis;
for (std::size_t i = 0; i < len; ++i) {
h ^= static_cast<std::uint64_t>(data[i]);
h *= kPrime;
}
// Format as 16-digit lowercase hex (zero-padded) for a fixed-length string.
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx",
static_cast<unsigned long long>(h));
return std::string(buf);
}
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
// Walk the RIFF/WAVE container and feed only the `fmt ` body and `data` body
// through FNV-1a, prefixed with the domain-separation tag byte 'W' (0x57).
// Any render-varying metadata chunks (bext, iXML, LIST, SMED, etc.) are skipped.
// If the file does not parse as RIFF/WAVE with both fmt and data chunks, fall back
// to whole-file hashBytes (no prefix) so an unrecognized file still gets a hash.
//
// The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates
// FNV state instead of recording geometry — no second parser, same logic.
// FNV-1a 64-bit constants (same as hashBytes).
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kPrime = 1099511628211ULL;
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
auto tagEq = [&](std::size_t off, const char* tag) -> bool {
return off + 4 <= bytes.size() &&
std::memcmp(bytes.data() + off, tag, 4) == 0;
};
auto readU32LE = [&](std::size_t off) -> std::uint32_t {
return static_cast<std::uint32_t>(bytes[off]) |
(static_cast<std::uint32_t>(bytes[off + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[off + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[off + 3]) << 24);
};
bool isWav = bytes.size() >= 12 &&
tagEq(0, "RIFF") &&
tagEq(8, "WAVE");
if (isWav) {
// Accumulate FNV-1a starting with the domain-separation tag byte 'W'.
std::uint64_t h = kOffsetBasis;
auto feedByte = [&](std::uint8_t b) {
h ^= static_cast<std::uint64_t>(b);
h *= kPrime;
};
bool haveFmt = false;
bool haveData = false;
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
// whole-file hash of different bytes that happen to be the same length.
feedByte(static_cast<std::uint8_t>('W'));
std::size_t pos = 12;
while (pos + 8 <= bytes.size()) {
const std::size_t bodyOffset = pos + 8;
const std::uint32_t bodySize = readU32LE(pos + 4);
if (tagEq(pos, "fmt ")) {
// Feed the entire fmt body (all fields, including format tag, channels,
// sample rate, bits-per-sample — everything that defines the audio format).
if (bodyOffset + bodySize <= bytes.size()) {
for (std::uint32_t i = 0; i < bodySize; ++i)
feedByte(bytes[bodyOffset + i]);
haveFmt = true;
}
} else if (tagEq(pos, "data")) {
// Feed the entire PCM payload.
if (bodyOffset + bodySize <= bytes.size()) {
for (std::uint32_t i = 0; i < bodySize; ++i)
feedByte(bytes[bodyOffset + i]);
haveData = true;
}
}
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
// Advance past this chunk's body, honoring RIFF even-byte padding.
std::size_t advance = bodySize;
if (advance & 1u) ++advance; // RIFF pad byte
if (advance > bytes.size() - bodyOffset) break; // overrun guard
pos = bodyOffset + advance;
}
if (haveFmt && haveData) {
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx",
static_cast<unsigned long long>(h));
return std::string(buf);
}
// Falls through to whole-file fallback if chunks were missing/malformed.
}
// Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old
// per-call hashBytes). No prefix tag: identical to hashBytes(data, size).
return hashBytes(bytes.data(), bytes.size());
}
std::string normalizeSlashes(const std::string& path) {
std::string out = path;
for (char& c : out) {
if (c == '\\') c = '/';
}
// Strip a single trailing slash so joins do not double up. Preserve a lone
// "/" (root) — stripping it would turn root into empty.
if (out.size() > 1 && out.back() == '/') {
out.pop_back();
}
#ifdef _WIN32
// Windows paths are case-insensitive. Fold to lowercase so that two paths
// that differ only in drive-letter or component casing compare equal (e.g.
// "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is
// preserved (the filesystem is case-sensitive; folding would be wrong).
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
#endif
return out;
}
std::string sanitizeStem(const std::string& baseName) {
std::string out;
out.reserve(baseName.size());
for (unsigned char c : baseName) {
const bool keep = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' ||
c == '-';
out.push_back(keep ? static_cast<char>(c) : '_');
}
// Collapse to a stable default if nothing usable survived (e.g. all spaces).
// A stem of only separators ('.', '_', '-') is also unhelpful as a name.
bool hasAlnum = false;
for (unsigned char c : out) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9')) {
hasAlnum = true;
break;
}
}
if (out.empty() || !hasAlnum) {
return "capture";
}
return out;
}
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag) {
const std::string dir = normalizeSlashes(projectDir);
std::string stem = sanitizeStem(baseName);
if (!uniqueTag.empty()) {
stem += "_" + sanitizeStem(uniqueTag);
}
const std::string fileName = stem + ".wav";
// Precondition: the capture shell must resolve a non-empty project directory
// before calling this function. An empty projectDir would produce a bare
// relative "reasampler_bank" path — the silent default-location fallback this
// tool explicitly forbids. Assert in debug; leave absoluteDir empty in release
// so any caller that ignores the precondition fails loudly at the render/stat
// step rather than silently writing to CWD.
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
BankPaths p;
p.fileStem = stem; // stem only — REAPER appends extension
p.fileName = fileName;
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
// absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
// directory itself; RENDER_PATTERN supplies the file name separately).
// Empty when precondition is violated (dir empty) — caller must not proceed.
p.absoluteDir = dir.empty() ? std::string{}
: dir + "/" + kBankSubfolder;
return p;
}
std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {};
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so
// the two spellings can never drift (Phase R spelling-consistency invariant).
return std::string(kBankSubfolder) + "/" + fileName;
}
std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath) {
// No default-location fallback (CLAUDE.md invariant): an empty project dir or
// relative path yields empty, not a bare relative path resolved against CWD.
if (projectDir.empty() || relativePath.empty()) {
return {};
}
const std::string dir = normalizeSlashes(projectDir);
const std::string rel = normalizeSlashes(relativePath);
if (dir.empty() || rel.empty()) {
return {};
}
return dir + "/" + rel;
}
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 persist.cpp's prior
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
if (rppPath.empty()) return {};
std::string dir = std::filesystem::path(rppPath).parent_path().string();
return normalizeSlashes(dir);
}
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir) {
BankRelocation r;
if (oldProjectDir.empty() || newProjectDir.empty()) {
return r; // needed=false, empty dirs — nothing to relocate
}
const std::string oldDir = normalizeSlashes(oldProjectDir);
const std::string newDir = normalizeSlashes(newProjectDir);
r.oldBankDir = oldDir + "/" + kBankSubfolder;
r.newBankDir = newDir + "/" + kBankSubfolder;
// A Save (in place) leaves the project dir unchanged — nothing to relocate.
// Only a Save-As to a different directory needs the bank moved.
r.needed = (oldDir != newDir);
return r;
}
ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastGuid,
const std::string& lastPath,
const std::string& currentGuid,
const std::string& currentPath) {
// 1. The GUID is the identity of record and is checked FIRST. A different
// stored GUID means a genuinely different project is active — Load ITS index.
// This catches the regression that pointer-primary classification missed:
// REAPER RECYCLES ReaProject* addresses across close/open, so a reopened /
// new project can reuse the previous project's address (sameProjectObject ==
// true) while carrying a different stored GUID. Deciding on the pointer alone
// then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is
// immune to address recycling, so it leads. Also covers new/unsaved<->saved
// transitions (one GUID empty, the other not) and switching between two
// distinct saved projects.
if (currentGuid != lastGuid) {
return ProjectTransition::Load;
}
// From here currentGuid == lastGuid (they are equal; both may be empty for
// unsaved projects). The pointer now disambiguates the same-GUID case.
// 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our
// GUID onto a distinct project object. Load its (own) index; never relocate.
// Two unsaved projects (both GUIDs empty, distinct objects) also land here —
// Load, so switching between them installs the right in-memory state.
if (!sameProjectObject) {
return ProjectTransition::Load;
}
// 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object
// identity is proven and the record identity is unchanged — only the .rpp
// moved). Also the first save of an unsaved project (both GUIDs empty, old
// path empty): SaveAsRelocate is safe there because deriveRelocationPlan
// no-ops on the empty old dir (empty-GUID safety preserved) while poll()
// mints a GUID.
if (currentPath != lastPath) {
return ProjectTransition::SaveAsRelocate;
}
// 4. Same object, same GUID, same path — Save in place / idle tick.
return ProjectTransition::NoOp;
}
} // namespace reasampler::capture
+215
View File
@@ -0,0 +1,215 @@
#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).
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
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).
inline constexpr const char* kBankSubfolder = "reasampler_bank";
// A resolved pair of paths for one capture: where REAPER must be told to write
// (absolute, because RENDER_FILE wants a directory REAPER can create/open) and
// what we store in the BankModel (project-relative, because the index is
// relative-paths-only — CLAUDE.md precision invariant).
struct BankPaths {
std::string absoluteDir; // <projectDir>/reasampler_bank (forward slash)
std::string relativePath; // reasampler_bank/<fileName> (index value)
std::string fileName; // <stem>.wav (full file name)
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
};
// 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).
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) in place of the raw
// hashBytes call.
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
// 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).
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).
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 "<stem>[_<tag>].wav". The relativePath is always project-relative and
// forward-slashed so it satisfies BankModel::add's relative-only invariant.
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.
std::string bankRelativeForName(const std::string& fileName);
// --- Persist-side path arithmetic (M4) --------------------------------------
//
// 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 "<projectDir>/<relativePath>" 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.
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.
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.
struct BankRelocation {
std::string oldBankDir; // <oldProjectDir>/reasampler_bank
std::string newBankDir; // <newProjectDir>/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.
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir);
// --- Project-identity transition (W12 combined identity fix) -----------------
//
// 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.
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.
ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastGuid,
const std::string& lastPath,
const std::string& currentGuid,
const std::string& currentPath);
} // namespace reasampler::capture
+49
View File
@@ -0,0 +1,49 @@
// insert_plan.cpp — see insert_plan.h. Pure InsertMedia mode-bit arithmetic.
#include "core/capture/insert_plan.h"
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
// 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
} // namespace
int computeInsertMode(const InsertOptions& opts) {
int mode = opts.target == InsertTarget::NewTrack ? kBaseNewTrack
: kBaseCurrentTrack;
switch (opts.conform) {
case TempoConform::None:
// No tempo bits: native length, no stretch. (Also never &4.)
return mode;
case TempoConform::Ratio1x:
mode |= kMatchTempo1x;
break;
case TempoConform::RatioHalf:
mode |= kMatchTempoHalf;
break;
case TempoConform::RatioDouble:
mode |= kMatchTempoDbl;
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.
if (!opts.preservePitch)
mode |= kDontPreservePitch;
return mode;
}
} // namespace reasampler::capture
+75
View File
@@ -0,0 +1,75 @@
#pragma once
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): 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.
//
// The 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).
#include <cstdint>
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).
enum class InsertTarget {
NewTrack, // mode base 1: add a new track for the item
CurrentTrack, // mode base 0: add to the current/selected track
};
// Tempo-conform choice. Default is None: insert at the file's native length with
// NO stretching (the precision-preserving default). The three ratios are the
// explicit opt-in "try to match project tempo" paths — never applied silently.
// Ratio1x is the ordinary "conform to tempo"; Half/Double are the octave-shifted
// variants REAPER exposes for half/double-time material.
enum class TempoConform {
None, // no tempo bits set: native length, no stretch (default)
Ratio1x, // &8: try to match tempo 1x
RatioHalf,// &16: try to match tempo 0.5x
RatioDouble,// &32: try to match tempo 2x
};
// Options that shape one InsertMedia call. Defaults encode the intended path:
// current track (user's selection), no conform, pitch preserved.
struct InsertOptions {
InsertTarget target = InsertTarget::CurrentTrack;
TempoConform conform = TempoConform::None;
// Only meaningful when conform != None. When false, adds &64 ("don't preserve
// pitch when matching tempo") so a tempo match also shifts pitch (classic
// varispeed). Default true = preserve pitch across the tempo match. Ignored
// when conform == None (no tempo bits set, so pitch is moot).
bool preservePitch = true;
};
// Computes the InsertMedia `mode` integer for the given options.
//
// Guarantees enforced here (and asserted in tests):
// * The &4 stretch-to-time-selection bit is NEVER set (no silent stretch).
// * When conform == None, NONE of the tempo bits (&8/&16/&32/&64) are set — the
// item lands at native length.
// * Exactly one base target bit pattern is used (0 or 1), never 3.
int computeInsertMode(const InsertOptions& opts);
// The forbidden stretch bit, exposed so a test can assert it is never present in
// any computed mode (the "no silent time-stretch" invariant, made checkable).
inline constexpr int kStretchToTimeSelBit = 4;
} // namespace reasampler::capture
+127
View File
@@ -0,0 +1,127 @@
// realtime_record.cpp — pure logic for the realtime-record backend (M8). See header.
// NO REAPER types; unit-tested by tests/test_realtime_record.cpp.
#include "core/capture/realtime_record.h"
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.)
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
: kRecModeStereoOutLatComp;
switch (tap) {
case OutputTap::PostFader: p.recModeFlags = kRecOutPostFader; break;
case OutputTap::PreFx: p.recModeFlags = kRecOutPreFx; break;
case OutputTap::PostFxPreFader: p.recModeFlags = kRecOutPostFxPreFader; break;
}
return p;
}
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-<tag>-<fileName>" 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.
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
s.displayName = cap.displayName;
s.relativePath = cap.relativePath; // project-relative (invariant)
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.
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.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.createdTimestamp = cap.createdTimestamp;
return s;
}
RecordPhase advanceRecordPhase(RecordPhase current,
const RecordTickInputs& inputs,
double rangeStartSeconds,
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.
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.
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.
const double ceiling =
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
return RecordPhase::Recording;
}
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.
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:
return current;
}
}
bool isStopRequested(RecordPhase phase) {
return phase != RecordPhase::Recording;
}
bool isTerminalPhase(RecordPhase phase) {
return phase == RecordPhase::Done || phase == RecordPhase::Failed;
}
} // namespace reasampler::capture
+238
View File
@@ -0,0 +1,238 @@
#pragma once
// realtime_record — the REAPER-free logic behind the realtime-record backend (M8).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The realtime backend (capture.cpp)
// 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.
#include <cstdint>
#include <string>
#include <vector>
#include "core/model/bank_model.h" // Sample, SourceMode (pure)
namespace reasampler::capture {
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.
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.
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
// The tap point on the source track's output the temp track records from.
// Orthogonal to the record mode (stereo/mono); this only sets the &3 flags bits.
enum class OutputTap {
PostFader, // fully wet, after this track's fader (kRecOutPostFader)
PreFx, // true dry, before this track's FX (kRecOutPreFx)
PostFxPreFader, // wet FX, before the fader (kRecOutPostFxPreFader)
};
// 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).
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).
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).
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).
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).
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).
std::string uniqueTag;
// Echoed from the request (exact bounds — no re-measuring the file).
SourceMode sourceMode = SourceMode::Realtime;
double startSeconds = 0.0;
double endSeconds = 0.0;
double wetDry = 1.0;
std::string displayName;
std::vector<std::string> trackGuids;
int channelCount = 0;
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;
int captureTimeSigDenom = 0;
std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock)
};
// 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).
Sample sampleFromRecordedCapture(const RecordedCapture& cap);
// --- Async record-phase state machine (M8 rework) ----------------------------
//
// 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).
//
// 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.
// 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).
enum class RecordPhase {
Recording,
Finalizing,
Done,
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).
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).
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;
};
// --- 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.
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.
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
// shell's single-restore relies on).
RecordPhase advanceRecordPhase(RecordPhase current,
const RecordTickInputs& inputs,
double rangeStartSeconds,
double rangeEndSeconds);
// True once the shell must STOP the transport and begin the flush wait — i.e. the
// phase has left Recording (Finalizing/Done/Failed). Used by the shell to fire the
// (idempotent) transport stop exactly on the Recording -> Finalizing edge.
bool isStopRequested(RecordPhase phase);
// True for the phases the shell must ACT on to conclude (finalize-or-fail + restore).
// Only Done and Failed are terminal; Recording and Finalizing are live.
bool isTerminalPhase(RecordPhase phase);
} // namespace reasampler::capture
+224
View File
@@ -0,0 +1,224 @@
// render_settings.cpp — pure logic for the three-scope capture action family. See header.
// NO REAPER types; unit-tested by tests/test_render_settings.cpp.
#include "core/capture/render_settings.h"
#include <algorithm>
#include <cmath>
#include <sstream>
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.
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
}
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.
t.tailFlag = kTailFlagNone;
t.tailMs = 0.0;
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
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).
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd;
t.trimEnd = autoTrimEndRatio();
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.
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
return t;
}
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
return t;
}
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;
case TailMode::Auto:
// The 8 s runaway cap past the range end; the decay-trim shortens it later.
return rangeEndSeconds + kMaxTailSeconds;
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).
return 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.
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.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.
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;
return c;
}
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
c.supported = false;
return c;
}
SourceMode sourceModeForScope(CaptureScope scope) {
switch (scope) {
case CaptureScope::Item: return SourceMode::SelectedItems;
case CaptureScope::Track: return SourceMode::SelectedTracks;
}
return SourceMode::SelectedItems; // unreachable for a valid enum; fail closed
}
RangeSource inferRangeSource(bool hasRazorArea) {
// Razor wins when present; otherwise the time selection. Orthogonal to scope.
return hasRazorArea ? RangeSource::Razor : RangeSource::TimeSelection;
}
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.)
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.
p.bypassSelfFx = false;
p.bypassAncestorFx = true;
p.bypassMaster = true;
return p;
}
return p; // unreachable; bypass nothing (fail to full-chain, never over-bypass)
}
std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
std::vector<RazorRange> ranges;
std::istringstream in(razorString);
// The string is space-separated TRIPLES: <start> <end> <envGuidString>.
// 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 `""`.
if (guidTok != "\"\"") continue;
// Parse the two time tokens. std::stod throws on garbage — guard so one
// malformed triple does not 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.
if (sp != startTok.size() || ep != endTok.size()) continue;
} catch (...) {
continue;
}
if (end > start) ranges.push_back({start, end}); // drop empty/inverted
}
return ranges;
}
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
if (ranges.empty()) return {0.0, 0.0};
RazorRange u = ranges.front();
for (const RazorRange& r : ranges) {
if (r.startSeconds < u.startSeconds) u.startSeconds = r.startSeconds;
if (r.endSeconds > u.endSeconds) u.endSeconds = r.endSeconds;
}
return u;
}
const std::vector<CaptureActionDef>& 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.
static const std::vector<CaptureActionDef> 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},
};
return table;
}
} // namespace reasampler::capture
+265
View File
@@ -0,0 +1,265 @@
#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.
#include <string>
#include <vector>
#include "core/model/bank_model.h" // SourceMode (pure enum)
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.
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)
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
// 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.
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.
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).
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.
enum class TailMode {
None,
Auto,
Manual,
};
// 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.
struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
double tailMs = 0.0; // RENDER_TAILMS
int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE
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.
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).
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).
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.
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
// --- Capture scope: the FX-scope invariant (Daniel, critical) ----------------
//
// 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.
enum class CaptureScope {
Item,
Track,
};
// The render source mode each scope drives. Item captures selected items, Track
// captures selected tracks (via master).
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).
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.
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.
struct FxBypassPlan {
bool bypassSelfFx = false; // the captured track's own FX
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX
bool bypassMaster = false; // the master track's FX
};
FxBypassPlan fxBypassPlanFor(CaptureScope scope);
// A single razor-edit area: a time range on one track (envelope GUID ignored —
// razor captures target track-audio areas, not envelope lanes).
struct RazorRange {
double startSeconds = 0.0;
double endSeconds = 0.0;
};
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
// TRIPLES of <start> <end> <envGuidString>. 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).
std::vector<RazorRange> 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").
RazorRange razorUnionBounds(const std::vector<RazorRange>& 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).
//
// 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).
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
const char* baseName; // file-stem base for this capture
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.
//
// 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.
const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler::capture
+131
View File
@@ -0,0 +1,131 @@
// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor.
#include "core/capture/tail_control.h"
#include <algorithm>
#include <cstdio>
#include "core/json/json.h"
namespace reasampler::capture {
TailMode cycleTailMode(TailMode current) {
switch (current) {
case TailMode::None: return TailMode::Auto;
case TailMode::Auto: return TailMode::Manual;
case TailMode::Manual: return TailMode::None;
}
return TailMode::None; // unreachable for a valid enum; fail to the safe default
}
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);
}
std::string tailToggleLabel(const TailSetting& setting) {
switch (setting.mode) {
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.
const double seconds = clampManualMs(setting.manualMs) / 1000.0;
char buf[32];
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds);
return std::string(buf);
}
}
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.
namespace {
// The persisted integer for a mode. Stable forever (stored in the .rpp): never
// renumber these values or an already-saved project reads back the wrong mode.
int modeToInt(TailMode m) {
switch (m) {
case TailMode::None: return 0;
case TailMode::Auto: return 1;
case TailMode::Manual: return 2;
}
return 0;
}
std::optional<TailMode> modeFromInt(int v) {
switch (v) {
case 0: return TailMode::None;
case 1: return TailMode::Auto;
case 2: return TailMode::Manual;
default: return std::nullopt; // unknown enumerant -> malformed -> default
}
}
} // namespace
std::string serializeTailSetting(const TailSetting& setting) {
// Byte-identical to the former snprintf writer: {"mode":%d,"manualMs":%.17g}.
std::string out;
{
json::Writer w(out);
w.keyRaw("mode", json::numToStr(modeToInt(setting.mode)));
w.keyRaw("manualMs", json::numToStr(setting.manualMs));
} // Writer closes the object here (see bank_model's NRVO note)
return out;
}
std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
json::Reader r(blob);
if (!r.consume('{')) return std::nullopt;
int modeInt = 0;
double ms = 0.0;
bool haveMode = false, haveMs = false;
r.skipWs();
if (!r.consume('}')) {
do {
std::string key;
if (!r.parseKey(key)) return std::nullopt;
if (key == "mode") {
if (!r.parseInt(modeInt)) return std::nullopt;
haveMode = true;
} else if (key == "manualMs") {
if (!r.parseDouble(ms)) return std::nullopt;
haveMs = true;
} else {
if (!r.skipValue()) return std::nullopt; // forward-compat
}
} while (r.consume(','));
if (!r.consume('}')) return std::nullopt;
}
if (!haveMode || !haveMs) return std::nullopt; // absent key -> malformed -> default
const std::optional<TailMode> mode = modeFromInt(modeInt);
if (!mode) return std::nullopt;
TailSetting out;
out.mode = *mode;
out.manualMs = ms;
return out;
}
} // namespace reasampler::capture
+70
View File
@@ -0,0 +1,70 @@
#pragma once
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
// toggle. The panel shell (bank_panel.cpp) 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.
#include <optional>
#include <string>
#include "core/capture/render_settings.h" // TailMode (pure enum) — the three-state tail contract
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.
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.
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 (bank_panel.cpp), 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.
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.
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.
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.
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).
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.
std::string serializeTailSetting(const TailSetting& setting);
std::optional<TailSetting> deserializeTailSetting(const std::string& json);
} // namespace reasampler::capture
+160
View File
@@ -0,0 +1,160 @@
// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor.
#include "core/capture/wav_trim.h"
#include <cstring> // std::memcpy, std::memcmp
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.
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
}
std::uint32_t readU32LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint32_t>(b[off]) |
(static_cast<std::uint32_t>(b[off + 1]) << 8) |
(static_cast<std::uint32_t>(b[off + 2]) << 16) |
(static_cast<std::uint32_t>(b[off + 3]) << 24);
}
bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char* tag) {
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
}
// WAVE format tags we accept as 32-bit float (see wav_trim.h FORMAT ASSUMPTION).
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
} // namespace
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
WavLayout out;
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
if (bytes.size() < 12) return out;
if (!tagEquals(bytes, 0, "RIFF")) return out;
if (!tagEquals(bytes, 8, "WAVE")) return out;
bool haveFmt = false;
std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0;
std::uint32_t sampleRate = 0;
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
// Walk the sub-chunks after "WAVE" (offset 12). Each is: id(4) size(4) body(size),
// body padded to an even byte count (RIFF word alignment). Stop cleanly if a
// header would run past the buffer — a malformed/truncated file is "invalid",
// never an OOB read.
std::size_t pos = 12;
while (pos + 8 <= bytes.size()) {
const std::size_t bodyOffset = pos + 8;
const std::uint32_t bodySize = readU32LE(bytes, pos + 4);
if (tagEquals(bytes, pos, "fmt ")) {
// fmt body: at least 16 bytes (PCM/float common fields).
if (bodyOffset + 16 > bytes.size() || bodySize < 16) return out;
fmtTag = readU16LE(bytes, bodyOffset + 0);
channels = readU16LE(bytes, bodyOffset + 2);
sampleRate = readU32LE(bytes, bodyOffset + 4);
bitsPerSample = readU16LE(bytes, 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).
if (fmtTag == kWaveFormatExtensible) {
if (bodySize >= 40 && bodyOffset + 40 <= bytes.size()) {
extensibleSubFormatTag = readU16LE(bytes, bodyOffset + 24);
}
}
haveFmt = true;
} else if (tagEquals(bytes, pos, "data")) {
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
// Reject if it runs past the buffer (truncated / lying header).
if (bodyOffset + bodySize > bytes.size()) return out;
if (!haveFmt) return out; // data before fmt — not a WAV we parse
// 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.
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
(fmtTag == kWaveFormatExtensible &&
extensibleSubFormatTag == kWaveFormatIeeeFloat);
if (!floatTag || bitsPerSample != 32 || channels == 0) return out;
out.valid = true;
out.channelCount = channels;
out.sampleRate = sampleRate;
out.dataByteOffset = bodyOffset;
out.dataByteLength = bodySize;
out.riffSizeFieldOffset = 4;
out.dataSizeFieldOffset = pos + 4; // the `data` size field (LE uint32)
return out;
}
// Advance past this chunk's body, honoring RIFF even-byte padding. Guard the
// additions against size_t overflow (a hostile bodySize near SIZE_MAX).
std::size_t advance = bodySize;
if (advance & 1u) ++advance; // pad byte
if (advance > bytes.size() - bodyOffset) break; // would overrun -> stop
pos = bodyOffset + advance;
}
return out; // no data chunk found -> invalid
}
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
std::size_t frameCount) {
std::vector<AudioSample> out;
if (!layout.valid) return out;
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t totalFrames = layout.frameCount();
if (startFrame >= totalFrames) return out;
// Clamp the requested span to the frames that actually exist.
const std::size_t avail = totalFrames - startFrame;
const std::size_t frames = (frameCount < avail) ? frameCount : avail;
if (frames == 0) return out;
const std::size_t firstByte =
layout.dataByteOffset + startFrame * bytesPerFrame;
out.resize(frames * layout.channelCount);
// memcpy each float (LE on target hosts — see header's byte-order note).
for (std::size_t i = 0; i < out.size(); ++i) {
float f = 0.0f;
std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u);
out[i] = f;
}
return out;
}
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) {
WavTruncatePlan plan;
if (!layout.valid) return plan;
const std::size_t totalFrames = layout.frameCount();
if (keptFrames > totalFrames) return plan; // never grow
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t keptDataBytes = keptFrames * bytesPerFrame;
plan.valid = true;
plan.newFileByteLength = layout.dataByteOffset + keptDataBytes;
plan.dataSizeFieldOffset = layout.dataSizeFieldOffset;
plan.newDataSize = static_cast<std::uint32_t>(keptDataBytes);
plan.riffSizeFieldOffset = layout.riffSizeFieldOffset;
// RIFF size counts everything after the 8-byte "RIFF"+size prefix.
plan.newRiffSize = static_cast<std::uint32_t>(plan.newFileByteLength - 8);
return plan;
}
} // namespace reasampler::capture
+103
View File
@@ -0,0 +1,103 @@
#pragma once
// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
//
// WHY THIS 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 (capture_realtime.cpp) does only the file I/O: read the bytes, call the
// pure parse, run the decay scan, call the pure plan, 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). This 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.
#include <cstddef>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::capture {
using audio::AudioSample;
// 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.
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)
// The `data` chunk: byte offset of its first PCM byte within the file, and its
// declared PCM byte 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).
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
std::size_t dataSizeFieldOffset = 0;
std::size_t frameCount() const {
const std::size_t bytesPerFrame = static_cast<std::size_t>(channelCount) * 4u;
return bytesPerFrame ? dataByteLength / bytesPerFrame : 0;
}
};
// 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).
WavLayout parseWavLayout(const std::vector<std::uint8_t>& 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.
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
std::size_t frameCount);
// 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).
struct WavTruncatePlan {
bool valid = false;
std::size_t newFileByteLength = 0; // truncate the file to exactly this length
std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32)
std::uint32_t newDataSize = 0; // kept PCM byte length
std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32)
std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes
// 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, then
// truncate the file to newFileByteLength.
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
} // namespace reasampler::capture