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