Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
#include "core/namespaces.h"
|
||||
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes
|
||||
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU
|
||||
// that defines the API pointers; here they are extern (CLAUDE.md §contract).
|
||||
//
|
||||
// Renders a CaptureRequest's source over its requested range. The full three-scope
|
||||
// capture family (item / track / master, each over a razor-else-time range) is
|
||||
// driven here — all wet-only with optional tail. FX scope is enforced by the
|
||||
// caller (via FX-bypass-around-render / FxBypassGuard) before invoking capture;
|
||||
// this backend is source-agnostic and does not itself read the DAW selection.
|
||||
// Drives the RENDER_* project settings via GetSetProjectInfo / _String
|
||||
// (the source-selection bits come from render_settings.cpp, the pure mapping),
|
||||
// snapshots and restores every setting it changes (non-destructive), triggers a
|
||||
// render, then populates a Sample. It NEVER inserts into the arrange
|
||||
// (load-bearing principle) — RENDER_ADDTOPROJ&1 is cleared on every path.
|
||||
//
|
||||
// The backend is SOURCE-AGNOSTIC: it does NOT read the DAW selection. The action
|
||||
// layer (main.cpp) resolves each source mode to a concrete time range (+ track
|
||||
// GUIDs for track captures) and hands it in via the CaptureRequest. This keeps
|
||||
// the render-driving here and the selection-reading testable/visible up in the
|
||||
// actions layer.
|
||||
//
|
||||
// RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API):
|
||||
// Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show
|
||||
// its offline-render progress dialog (progress bar + waveform view) for the
|
||||
// duration of the render. The RENDER_SETTINGS bits documented in
|
||||
// reaper_plugin_functions.h (line ~3041) contain no "no-dialog", "headless", or
|
||||
// "suppress-progress-window" flag. No GetSetProjectInfo desc documents such a
|
||||
// flag either. There is no stock, header-verifiable mechanism to prevent REAPER
|
||||
// from showing this UI for an offline file render triggered via Main_OnCommand.
|
||||
// This is inherent to REAPER's offline render path. The dialog-free alternative
|
||||
// is the realtime-record backend (M8), which captures the master bus output to a
|
||||
// temp track during playback and never invokes the offline render pipeline.
|
||||
|
||||
#include "shell/capture/capture.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "core/util/file_bytes.h"
|
||||
#include "core/capture/render_settings.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo_String
|
||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
||||
#define REAPERAPI_WANT_Main_OnCommand
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// --- Render command / setting constants -------------------------------------
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION (open question, CONTEXT.md §Open questions): the no-dialog
|
||||
// render is triggered by the built-in action "File: Render project, using the
|
||||
// most recent render settings" — command id 42230. This is a stock REAPER main
|
||||
// action id, NOT part of reaper_plugin_functions.h, so it CANNOT be verified
|
||||
// against the SDK header; it must be confirmed in a running REAPER. It renders
|
||||
// headlessly (no dialog) using whatever RENDER_* settings are currently on the
|
||||
// project — which is exactly why we set them all explicitly first.
|
||||
constexpr int kActionRenderUsingMostRecentSettings = 42230;
|
||||
|
||||
// RENDER_BOUNDSFLAG value 0 = custom time bounds (we set STARTPOS/ENDPOS
|
||||
// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042.
|
||||
constexpr double kBoundsCustom = 0.0;
|
||||
|
||||
// RENDER_TAILFLAG / RENDER_TAILMS / RENDER_NORMALIZE / RENDER_TRIMEND for the tail
|
||||
// are driven from the pure tailRenderSettingsFor mapping (render_settings.h),
|
||||
// unit-tested outside the DAW. See the tail-driving block in capture() below.
|
||||
|
||||
// RENDER_DITHER disable-all: &16 = disable all dither/noise-shaping.
|
||||
// Verified: SDK header line ~3050: "&16=disable all".
|
||||
// Float-32 output does not need dither, but if the user's project has dither
|
||||
// enabled the render would obey it, breaking bit-identical repeats. Force off.
|
||||
constexpr double kDitherDisableAll = 16.0;
|
||||
|
||||
// --- WAV render sink configuration ------------------------------------------
|
||||
//
|
||||
// FORMAT CHOICE (CONTEXT.md open question — surfaced for Daniel to confirm):
|
||||
// 32-bit IEEE float. Rationale: float is lossless and needs NO dither, so
|
||||
// identical inputs render bit-identically (enables the M10 null test) and a dry
|
||||
// capture nulls exactly against its source. 16/24-bit int paths require dither
|
||||
// for correctness, which is nondeterministic — unacceptable for a precision tool.
|
||||
//
|
||||
// API FACT (SDK header line ~3114): GetSetProjectInfo_String("RENDER_FORMAT", ...)
|
||||
// uses the BASE64-ENCODED string form of the sink config — NOT raw binary bytes.
|
||||
// Writing raw bytes causes REAPER to silently reject the value and fall back to
|
||||
// the project's default render format (typically 16-bit/44.1 kHz). This was the
|
||||
// confirmed root cause of the M3 offline-capture regression.
|
||||
//
|
||||
// GROUND TRUTH: base64 string captured from a live REAPER configured to
|
||||
// WAV / 32-bit float. Decodes to 7 bytes: 65 76 61 77 20 00 00
|
||||
// = "evaw" (WAV fourcc, little-endian) + 0x20 (=32, the float bit-depth field)
|
||||
// + 0x00 0x00 (flags: little-endian, no BWF/loop metadata).
|
||||
constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA==";
|
||||
|
||||
// Int16 / Int24 blob strings are NOT implemented in M3 — their byte encoding
|
||||
// was not captured from a live REAPER and must not be guessed. If M7+ adds
|
||||
// them, capture the ground-truth base64 from a running REAPER first.
|
||||
//
|
||||
// Returns nullptr for unsupported depths.
|
||||
const char* wavSinkConfigBase64(WavBitDepth depth) {
|
||||
switch (depth) {
|
||||
case WavBitDepth::Float32: return kRenderFormatWavFloat32;
|
||||
case WavBitDepth::Int16: return nullptr; // M7+: capture ground-truth blob first
|
||||
case WavBitDepth::Int24: return nullptr; // M7+: capture ground-truth blob first
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// --- RENDER_* snapshot / restore --------------------------------------------
|
||||
//
|
||||
// The RENDER_* settings are project-GLOBAL: clobbering them would destroy the
|
||||
// user's render configuration. We snapshot every value we are about to change,
|
||||
// then restore all of them in the reverse order on the way out (non-destructive
|
||||
// invariant). Modeled as a small RAII guard so early returns cannot leak a
|
||||
// half-restored state.
|
||||
struct RenderSettingsSnapshot {
|
||||
ReaProject* proj = nullptr;
|
||||
|
||||
// Numeric settings (GetSetProjectInfo).
|
||||
double boundsFlag = 0.0;
|
||||
double startPos = 0.0;
|
||||
double endPos = 0.0;
|
||||
double tailFlag = 0.0;
|
||||
double tailMs = 0.0;
|
||||
double srate = 0.0;
|
||||
double channels = 0.0;
|
||||
double renderSettings = 0.0;
|
||||
double addToProj = 0.0;
|
||||
double dither = 0.0; // RENDER_DITHER — snapshotted so user's setting is restored
|
||||
double normalize = 0.0; // RENDER_NORMALIZE — snapshotted so user's setting is restored
|
||||
double trimEnd = 0.0; // RENDER_TRIMEND — snapshotted so the Auto trim threshold is restored
|
||||
|
||||
// String settings (GetSetProjectInfo_String). Big buffers: REAPER writes the
|
||||
// full value in, and RENDER_FORMAT is a base64 blob that can be long.
|
||||
std::string renderFile;
|
||||
std::string renderPattern;
|
||||
std::string renderFormat;
|
||||
|
||||
bool captured = false;
|
||||
};
|
||||
|
||||
std::string getProjString(ReaProject* proj, const char* desc) {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
GetSetProjectInfo_String(proj, desc, buf.data(), false);
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
void setProjString(ReaProject* proj, const char* desc, const std::string& value) {
|
||||
// GetSetProjectInfo_String takes a non-const char*; copy into a mutable buf.
|
||||
std::vector<char> buf(value.begin(), value.end());
|
||||
buf.push_back('\0');
|
||||
GetSetProjectInfo_String(proj, desc, buf.data(), true);
|
||||
}
|
||||
|
||||
void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) {
|
||||
s.proj = proj;
|
||||
s.boundsFlag = GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", 0.0, false);
|
||||
s.startPos = GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false);
|
||||
s.endPos = GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false);
|
||||
s.tailFlag = GetSetProjectInfo(proj, "RENDER_TAILFLAG", 0.0, false);
|
||||
s.tailMs = GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, false);
|
||||
s.srate = GetSetProjectInfo(proj, "RENDER_SRATE", 0.0, false);
|
||||
s.channels = GetSetProjectInfo(proj, "RENDER_CHANNELS", 0.0, false);
|
||||
s.renderSettings = GetSetProjectInfo(proj, "RENDER_SETTINGS", 0.0, false);
|
||||
s.addToProj = GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, false);
|
||||
s.dither = GetSetProjectInfo(proj, "RENDER_DITHER", 0.0, false);
|
||||
s.normalize = GetSetProjectInfo(proj, "RENDER_NORMALIZE", 0.0, false);
|
||||
s.trimEnd = GetSetProjectInfo(proj, "RENDER_TRIMEND", 0.0, false);
|
||||
s.renderFile = getProjString(proj, "RENDER_FILE");
|
||||
s.renderPattern = getProjString(proj, "RENDER_PATTERN");
|
||||
s.renderFormat = getProjString(proj, "RENDER_FORMAT");
|
||||
s.captured = true;
|
||||
}
|
||||
|
||||
void restoreRenderSettings(const RenderSettingsSnapshot& s) {
|
||||
if (!s.captured) return;
|
||||
// Restore strings first, then numerics — order is not load-bearing since the
|
||||
// fields are independent, but we mirror snapshot order for readability.
|
||||
setProjString(s.proj, "RENDER_FILE", s.renderFile);
|
||||
setProjString(s.proj, "RENDER_PATTERN", s.renderPattern);
|
||||
setProjString(s.proj, "RENDER_FORMAT", s.renderFormat);
|
||||
GetSetProjectInfo(s.proj, "RENDER_BOUNDSFLAG", s.boundsFlag, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_STARTPOS", s.startPos, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_ENDPOS", s.endPos, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_TAILFLAG", s.tailFlag, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_TAILMS", s.tailMs, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_SRATE", s.srate, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_CHANNELS", s.channels, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_SETTINGS", s.renderSettings, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_ADDTOPROJ", s.addToProj, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_DITHER", s.dither, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_NORMALIZE", s.normalize, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true);
|
||||
}
|
||||
|
||||
// RAII wrapper: guarantees restore on every return path from capture().
|
||||
struct ScopedRenderSettings {
|
||||
RenderSettingsSnapshot snap;
|
||||
explicit ScopedRenderSettings(ReaProject* proj) {
|
||||
snapshotRenderSettings(snap, proj);
|
||||
}
|
||||
~ScopedRenderSettings() { restoreRenderSettings(snap); }
|
||||
ScopedRenderSettings(const ScopedRenderSettings&) = delete;
|
||||
ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete;
|
||||
};
|
||||
|
||||
// A monotonic, filesystem-safe timestamp tag so repeated captures in one session
|
||||
// do not collide on the file name. NOTE: the tag varies the file NAME, not the
|
||||
// audio bytes — bit-identical-repeat is about identical *content* for identical
|
||||
// requests; two deliberate captures naturally live in two files.
|
||||
std::string makeUniqueTag() {
|
||||
std::time_t now = std::time(nullptr);
|
||||
return std::to_string(static_cast<long long>(now));
|
||||
}
|
||||
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
|
||||
// empty on any I/O failure (the caller then leaves contentHash empty — the safe,
|
||||
// confirm-eliciting direction for an unreadable file).
|
||||
|
||||
} // namespace
|
||||
|
||||
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
CaptureResult result;
|
||||
|
||||
// Resolve the RENDER_SETTINGS source/processing bits for this mode + wet/dry
|
||||
// (pure mapping, unit-tested in render_settings). An unsupported mode (only
|
||||
// SourceMode::Realtime — that is the M8 realtime backend) is refused here so
|
||||
// the offline path never silently renders the wrong thing.
|
||||
const RenderSettingsChoice choice =
|
||||
renderSettingsFor(request.sourceMode, request.wetDry);
|
||||
if (!choice.supported) {
|
||||
result.status = CaptureStatus::UnsupportedMode;
|
||||
result.message = "OfflineRenderBackend does not render this source mode "
|
||||
"(realtime capture is the M8 backend).";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Exact bounds: reject an empty/inverted range rather than render silence.
|
||||
if (!(request.endSeconds > request.startSeconds)) {
|
||||
result.status = CaptureStatus::EmptyRange;
|
||||
result.message = "Capture range is empty (end <= start).";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Current project (idx -1 == the active project tab). Verified: SDK header
|
||||
// line ~1264, EnumProjects(int idx, char*, int).
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) {
|
||||
result.status = CaptureStatus::NoProject;
|
||||
result.message = "No active project.";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Resolve the project directory from the .rpp file path.
|
||||
//
|
||||
// Unsaved-project detection: we use EnumProjects(-1, buf, bufsz) to read
|
||||
// the project's .rpp filename. Per SDK header line ~1262:
|
||||
// EnumProjects(int idx, char* projfnOutOptional, int sz)
|
||||
// "idx=-1 for current project, projfn can be NULL if not interested in filename."
|
||||
// The out-parameter is the full path to the .rpp file, and is EMPTY for a
|
||||
// project that has never been saved — making it a reliable unsaved sentinel.
|
||||
//
|
||||
// WHY NOT GetProjectPathEx: that function returns the project *recording path*
|
||||
// (SDK header line ~2548: "Get the project recording path."), NOT the .rpp
|
||||
// location. For an unsaved project it returns REAPER's default media/recording
|
||||
// directory — never empty — so it cannot detect the unsaved state. Using it
|
||||
// caused the original bug: the guard never fired, and captures landed in
|
||||
// REAPER's default media location rather than alongside the .rpp.
|
||||
//
|
||||
// WHY NOT GetProjectPathEx for the saved-project dir: even for a saved project,
|
||||
// GetProjectPathEx returns the recording path (which may be a media subfolder),
|
||||
// not the .rpp parent directory. We need the .rpp parent so reasampler_bank/
|
||||
// sits alongside the .rpp and travels with the project.
|
||||
//
|
||||
// FLOW:
|
||||
// 1. Read .rpp path via EnumProjects(-1, buf, bufsz).
|
||||
// 2. If non-empty (saved) -> derive project dir as parent of the .rpp.
|
||||
// 3. If empty (unsaved) -> Main_SaveProject(proj, true) prompts Save-As.
|
||||
// Re-read. If now non-empty -> proceed. If still empty (user cancelled) ->
|
||||
// refuse CaptureStatus::NoProject, write nothing.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save/Save-As
|
||||
// dialog and blocks until the user dismisses it. "true" = forceSaveAsIn.
|
||||
// Verified SDK header line ~4599:
|
||||
// void Main_SaveProject(ReaProject* proj, bool forceSaveAsInOptional)
|
||||
// The blocking behaviour and dialog appearance can only be confirmed in a
|
||||
// running REAPER.
|
||||
auto readRppPath = [&]() -> std::string {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
// EnumProjects(-1, ...) returns the active project and writes the .rpp
|
||||
// path into buf. We already have the ReaProject* from the earlier call
|
||||
// (nullptr-checked above), but calling EnumProjects again is the only
|
||||
// stock, header-documented way to read the .rpp filename.
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
};
|
||||
|
||||
std::string rppPath = readRppPath();
|
||||
if (rppPath.empty()) {
|
||||
// Project is unsaved. Prompt the user to choose a save location.
|
||||
Main_SaveProject(proj, true);
|
||||
// Re-read: non-empty if the user confirmed, still empty if cancelled.
|
||||
rppPath = readRppPath();
|
||||
}
|
||||
if (rppPath.empty()) {
|
||||
// User cancelled the save dialog — refuse, write nothing.
|
||||
result.status = CaptureStatus::NoProject;
|
||||
result.message = "Project must be saved before capture — nothing captured.";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Derive the project directory as the parent folder of the .rpp file.
|
||||
// std::filesystem::path handles both forward- and back-slash paths; .parent_path()
|
||||
// gives the containing directory. Convert to forward-slash string so the rest
|
||||
// of the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path.
|
||||
const std::string projectDir = [&]() -> std::string {
|
||||
namespace fs = std::filesystem;
|
||||
std::string dir = fs::path(rppPath).parent_path().string();
|
||||
// normalizeSlashes is in capture_paths (pure); replicate the transform
|
||||
// inline here to avoid a cross-module dependency for a one-liner.
|
||||
for (char& c : dir) { if (c == '\\') c = '/'; }
|
||||
// Strip a single trailing slash (defensive; parent_path usually omits it).
|
||||
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
|
||||
return dir;
|
||||
}();
|
||||
|
||||
// Compute the unique tag ONCE so the file stem and Sample.id carry the same
|
||||
// timestamp. Calling makeUniqueTag() twice could yield different values if a
|
||||
// second boundary crosses between the two calls (bug: id and filename diverge).
|
||||
const std::string uniqueTag = makeUniqueTag();
|
||||
const BankPaths paths =
|
||||
deriveBankPaths(projectDir, request.baseName, uniqueTag);
|
||||
|
||||
// Snapshot + auto-restore ALL render settings we are about to touch.
|
||||
ScopedRenderSettings guard(proj);
|
||||
|
||||
// --- Drive the render settings (exact, deterministic) -------------------
|
||||
// Custom time bounds so the rendered length equals the requested range with
|
||||
// NO rounding and NO added silence (unless a tail was explicitly requested).
|
||||
GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true);
|
||||
GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
|
||||
GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
|
||||
|
||||
// Tail: TAILFLAG / TAILMS / NORMALIZE / TRIMEND all come from the pure mapping
|
||||
// (render_settings.h, unit-tested). None -> exact bounds + disable-all normalize
|
||||
// (byte-identical to the pre-tail path); Auto -> 8 s tail + surgical trim-end
|
||||
// normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no trim.
|
||||
// RENDER_NORMALIZE is driven HERE from the mapping (not the determinism block
|
||||
// below) so the Auto surgical value is not clobbered — the snapshot guard restores
|
||||
// the user's original RENDER_NORMALIZE / RENDER_TRIMEND on every exit path.
|
||||
const TailRenderSettings tail =
|
||||
tailRenderSettingsFor(request.tailMode, request.tailMs);
|
||||
GetSetProjectInfo(proj, "RENDER_TAILFLAG",
|
||||
static_cast<double>(tail.tailFlag), true);
|
||||
GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);
|
||||
|
||||
// Source-selection bits for this mode, from the pure render_settings mapping
|
||||
// (verified against SDK header ~3041). All M7 actions are wet-only:
|
||||
// master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file.
|
||||
GetSetProjectInfo(proj, "RENDER_SETTINGS",
|
||||
static_cast<double>(choice.settings), true);
|
||||
|
||||
// Resolve the effective sample rate. When the request carries 0 ("follow
|
||||
// project"), read PROJECT_SRATE explicitly so RENDER_SRATE is set to the
|
||||
// actual value — not left as 0 for REAPER to interpret. SDK header line ~3064:
|
||||
// PROJECT_SRATE = sample rate (ignored unless PROJECT_SRATE_USE set); the
|
||||
// value is still readable via GetSetProjectInfo even when _USE is clear.
|
||||
const int effectiveSampleRate = (request.sampleRate > 0)
|
||||
? request.sampleRate
|
||||
: static_cast<int>(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false));
|
||||
|
||||
// Pin RENDER_SRATE only when the resolved rate is known (> 0). PROJECT_SRATE
|
||||
// can read 0 on a project that has never explicitly pinned a sample rate (e.g.
|
||||
// brand-new projects before the user has visited the project settings). Forcing
|
||||
// RENDER_SRATE = 0 would re-introduce the "0 as literal" trap we fixed by
|
||||
// moving away from blind passthrough. When the rate is unknown, leave
|
||||
// RENDER_SRATE unset so REAPER follows its own project-rate default — which is
|
||||
// correct behaviour for that project — rather than pinning a bogus 0.
|
||||
if (effectiveSampleRate > 0) {
|
||||
GetSetProjectInfo(proj, "RENDER_SRATE",
|
||||
static_cast<double>(effectiveSampleRate), true);
|
||||
}
|
||||
GetSetProjectInfo(proj, "RENDER_CHANNELS",
|
||||
static_cast<double>(request.channelCount), true);
|
||||
|
||||
// Load-bearing principle: do NOT add the rendered file to the project as an
|
||||
// item. Clearing RENDER_ADDTOPROJ&1 keeps capture out of the arrange.
|
||||
GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);
|
||||
|
||||
// Determinism: disable dither so identical inputs produce bit-identical files
|
||||
// and a dry capture nulls to silence. RENDER_DITHER &16 = disable all dither/
|
||||
// noise-shaping (SDK header line ~3050). Snapshotted above; restored by the guard.
|
||||
GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);
|
||||
|
||||
// RENDER_NORMALIZE + RENDER_TRIMEND come from the tail mapping (above). None /
|
||||
// Manual -> disable-all (byte-identical to the pre-tail path); Auto -> surgical
|
||||
// trim-end (only &32768) + the -72 dB TRIMEND. A fixed-threshold trailing-silence
|
||||
// trim scales/limits/fades nothing, so Auto stays deterministic and un-coloring
|
||||
// (spec §surgical normalize). TRIMEND is only consulted when the trim bit is set,
|
||||
// but we write it unconditionally (harmless when clear) so the value is explicit.
|
||||
GetSetProjectInfo(proj, "RENDER_NORMALIZE",
|
||||
static_cast<double>(tail.normalize), true);
|
||||
GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true);
|
||||
|
||||
// Output location: directory (RENDER_FILE) + file stem (RENDER_PATTERN).
|
||||
// RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the
|
||||
// format extension. Use paths.fileStem — capture_paths owns the .wav suffix
|
||||
// knowledge; re-stripping here would duplicate that coupling.
|
||||
setProjString(proj, "RENDER_FILE", paths.absoluteDir);
|
||||
setProjString(proj, "RENDER_PATTERN", paths.fileStem);
|
||||
|
||||
// Pin the WAV format using the ground-truth base64 blob for the chosen depth.
|
||||
// Int16/Int24 are not implemented (no live-captured blob) — fail explicitly
|
||||
// rather than silently mis-render at the wrong bit depth.
|
||||
const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth);
|
||||
if (!fmtBase64) {
|
||||
result.status = CaptureStatus::UnsupportedFormat;
|
||||
result.message = "Requested bit depth has no verified RENDER_FORMAT blob "
|
||||
"(M3 supports Float32 only; Int16/Int24 are M7+).";
|
||||
return result;
|
||||
// guard's dtor restores every RENDER_* setting here.
|
||||
}
|
||||
setProjString(proj, "RENDER_FORMAT", fmtBase64);
|
||||
|
||||
// --- Trigger the render -------------------------------------------------
|
||||
// DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs
|
||||
// the render synchronously on the current build. REAPER will show its
|
||||
// offline-render progress window for the duration (see file-top comment —
|
||||
// the progress UI is not suppressible via stock API).
|
||||
Main_OnCommand(kActionRenderUsingMostRecentSettings, 0);
|
||||
|
||||
// --- Verify the output file exists ---------------------------------------
|
||||
// Main_OnCommand returns void, so a failed render is silent. Stat the
|
||||
// expected output path; if the file does not exist the render failed.
|
||||
// Note: std::filesystem is used only in this REAPER-facing .cpp — the pure
|
||||
// libs (capture_paths, bank_model) remain filesystem-free.
|
||||
const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName;
|
||||
if (!std::filesystem::exists(expectedPath)) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Render produced no output file (expected: " +
|
||||
expectedPath + "). Check the REAPER console for errors.";
|
||||
return result;
|
||||
// guard's dtor restores every RENDER_* setting here.
|
||||
}
|
||||
|
||||
// --- Populate the Sample -------------------------------------------------
|
||||
// We record the request's own bounds (exact) rather than re-measuring the
|
||||
// file, so the Sample's range is precisely what was asked for.
|
||||
Sample s;
|
||||
// Use the same uniqueTag that named the file — calling makeUniqueTag() again
|
||||
// here would risk a different timestamp if a second boundary crosses between
|
||||
// the two calls, making Sample.id inconsistent with the file name.
|
||||
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
|
||||
s.displayName = request.baseName;
|
||||
s.relativePath = paths.relativePath; // project-relative (invariant)
|
||||
s.sourceMode = request.sourceMode;
|
||||
s.sourceRange.startSeconds = request.startSeconds;
|
||||
s.sourceRange.endSeconds = request.endSeconds;
|
||||
// DEFERRED (M6/M7): startPpq, endPpq, and lengthBeats are left at 0.
|
||||
// PPQ mapping via TimeMap2_timeToBeats is a musical-placement concern for the
|
||||
// insert milestone; the model refuses to re-derive one bound from the other.
|
||||
// Seconds are the authoritative source for the render. Do NOT add DAW-
|
||||
// unverifiable PPQ resolution here — it requires a live REAPER to validate.
|
||||
s.wetDry = request.wetDry;
|
||||
// Track GUIDs for track-scoped captures (empty for master/items/razor). The
|
||||
// caller resolved the selection to canonical GUID strings; we record them so a
|
||||
// "re-capture from source" (M10) knows which tracks the sample came from.
|
||||
s.trackGuids = request.trackGuids;
|
||||
s.channelCount = request.channelCount;
|
||||
// Store the resolved sample rate only when it is known (> 0). If the project
|
||||
// never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE
|
||||
// either, so the render ran at REAPER's project default — an unknown value from
|
||||
// this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather
|
||||
// than store a bogus literal; M6/M7 can fill it in by probing the rendered file.
|
||||
s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown
|
||||
s.lengthSeconds = request.endSeconds - request.startSeconds;
|
||||
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
|
||||
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
|
||||
// (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time,
|
||||
// int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at that
|
||||
// project time, so a sample captured under 3/4 keeps a 3/4 read-out even if the
|
||||
// project later switches to 4/4. proj=nullptr => the active project (matches the
|
||||
// Master_GetTempo() call above, which is also active-project). The tempoOut is
|
||||
// ignored — captureTempo already carries the master tempo. Leaves 0/0 (unstamped)
|
||||
// if the API is somehow unavailable; the formatter renders a blank musical read-out.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
TimeMap_GetTimeSigAtTime(nullptr, request.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
||||
s.captureTimeSigNum = tsNum;
|
||||
s.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
||||
// Content hash: WAV-aware FNV-1a over the rendered file's fmt+data chunks so
|
||||
// hashReferencedElsewhere can identify copies in other banks and suppress the
|
||||
// last-reference confirm when another bank still holds the same file. Using
|
||||
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
|
||||
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders of identical
|
||||
// audio collapse to the same hash. Best-effort: an unreadable file leaves
|
||||
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
|
||||
// "" as non-participating in dedup, which is the existing fallback semantics).
|
||||
{
|
||||
const std::vector<std::uint8_t> fileBytes = readFileBytes(expectedPath);
|
||||
if (!fileBytes.empty()) {
|
||||
s.contentHash = hashWavContent(fileBytes);
|
||||
}
|
||||
}
|
||||
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
// Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a
|
||||
// master mix / track / time-selection is not a single played note, so no root
|
||||
// note is derivable here — we do NOT guess one. Loop points are set later by an
|
||||
// explicit user action, not at capture. Leaving them empty is the honest default;
|
||||
// the instrument (Phase S) treats an absent root note as "not a pitched sample".
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = s;
|
||||
result.message = "Captured [" +
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] -> " +
|
||||
paths.relativePath;
|
||||
return result;
|
||||
// guard's dtor restores every RENDER_* setting here.
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,235 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split).
|
||||
//
|
||||
// This header declares the capture *seam* the later milestones fill:
|
||||
// * CaptureRequest — everything a capture needs, source-mode-agnostic.
|
||||
// * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements
|
||||
// (headless, immediate, returns a finished Sample).
|
||||
// * OfflineRenderBackend — the deterministic default; drives the offline scopes.
|
||||
// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven
|
||||
// across timer ticks; deliberately NOT an ICaptureBackend
|
||||
// (see the SEAM CHOICE note at its declaration).
|
||||
//
|
||||
// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER
|
||||
// headers — the .cpp is the REAPER-facing translation unit. Keeping this header
|
||||
// REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam
|
||||
// without dragging the SDK into every include site.
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract
|
||||
|
||||
// MediaTrack is forward-declared (like track_guid.h) so this header stays
|
||||
// REAPER-free while RealtimeRecordBackend::begin can take the resolved source
|
||||
// MediaTrack* to tap. The pointers are opaque here — never dereferenced in a
|
||||
// pure/header context; only the REAPER-facing capture_realtime.cpp touches them.
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Audio bit-depth for the rendered wav. 32-bit float is the M3 default —
|
||||
// rationale lives in capture.cpp next to the sink-config bytes.
|
||||
enum class WavBitDepth {
|
||||
Int16,
|
||||
Int24,
|
||||
Float32,
|
||||
};
|
||||
|
||||
// One capture, independent of source mode. Populated by the caller (the action
|
||||
// handler in M3; the action family in M7) and consumed by a backend.
|
||||
//
|
||||
// M3 fills only the fields the master-mix/time-selection path needs; the rest
|
||||
// are declared now so M7/M8 do not reshape the struct (they are the seam).
|
||||
struct CaptureRequest {
|
||||
SourceMode sourceMode = SourceMode::MasterMix;
|
||||
|
||||
// Sample-accurate render bounds in project seconds. For the M3 spike these
|
||||
// come straight from the time selection (GetSet_LoopTimeRange) — NO rounding.
|
||||
double startSeconds = 0.0;
|
||||
double endSeconds = 0.0;
|
||||
|
||||
// 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet).
|
||||
// The field is kept as the seam for future true-dry work (M10 null test):
|
||||
// true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires
|
||||
// FX-bypass-around-render or the M8 realtime pre-FX path, and will be
|
||||
// designed alongside the M10 null test. Also recorded on the Sample.
|
||||
double wetDry = 1.0;
|
||||
|
||||
// Track GUID(s) the capture came from, when the source mode is track-scoped
|
||||
// (SelectedTracks). Empty for master/items/razor. The action layer (M7)
|
||||
// resolves the selection to canonical GUID strings and passes them here; the
|
||||
// backend copies them onto the Sample (it does NOT itself read the selection —
|
||||
// it stays source-agnostic, driven entirely by the request).
|
||||
std::vector<std::string> trackGuids;
|
||||
|
||||
// Render tail (docs/product/capture-tail.md §The three tail states). Default
|
||||
// None: exact bounds, no added silence — the precision invariant, and the only
|
||||
// mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for
|
||||
// TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the
|
||||
// 8 s cap + -72 dB trim internally, None ignores it.
|
||||
TailMode tailMode = TailMode::None;
|
||||
double tailMs = 0.0;
|
||||
|
||||
// Output format. 0 sampleRate => follow project rate (deterministic: the
|
||||
// project rate is fixed for a given project).
|
||||
int sampleRate = 0;
|
||||
int channelCount = 2;
|
||||
WavBitDepth bitDepth = WavBitDepth::Float32;
|
||||
|
||||
// Human base name for the file stem; sanitized by capture_paths. The unique
|
||||
// tag (disambiguator) is supplied separately by the backend caller so the
|
||||
// pure naming logic stays testable.
|
||||
std::string baseName = "capture";
|
||||
std::string uniqueTag; // e.g. a timestamp/counter; may be empty
|
||||
};
|
||||
|
||||
// Outcome of a capture attempt. `Ok` carries the populated Sample; every failure
|
||||
// is an explicit code (never a thrown exception across the REAPER boundary) so
|
||||
// the action handler can log a precise reason.
|
||||
enum class CaptureStatus {
|
||||
Ok,
|
||||
NoProject, // no active project to render / resolve a bank folder
|
||||
EmptyRange, // start >= end: nothing to render
|
||||
UnsupportedMode, // backend does not implement this source mode (M3 scope)
|
||||
UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only)
|
||||
RenderFailed, // the render action ran but produced no output file
|
||||
TransportBusy, // realtime backend: transport already playing/recording — refused
|
||||
};
|
||||
|
||||
struct CaptureResult {
|
||||
CaptureStatus status = CaptureStatus::RenderFailed;
|
||||
Sample sample; // valid only when status == Ok
|
||||
std::string message; // human-readable detail for the console log
|
||||
};
|
||||
|
||||
// The capture seam. One method: run a request, return a populated Sample (or a
|
||||
// failure code). Backends are non-destructive — they must restore any global
|
||||
// state they touch before returning (OfflineRenderBackend snapshots/restores the
|
||||
// RENDER_* project settings).
|
||||
class ICaptureBackend {
|
||||
public:
|
||||
virtual ~ICaptureBackend() = default;
|
||||
virtual CaptureResult capture(const CaptureRequest& request) = 0;
|
||||
};
|
||||
|
||||
// Deterministic offline-render backend. Drives the full offline source family —
|
||||
// master mix / time selection, selected tracks, selected items, razor area — all
|
||||
// wet-only (render_settings.h) with optional tail. The source selection + range
|
||||
// are resolved by the caller (the action layer) and handed in via the
|
||||
// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection
|
||||
// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend).
|
||||
class OfflineRenderBackend : public ICaptureBackend {
|
||||
public:
|
||||
CaptureResult capture(const CaptureRequest& request) override;
|
||||
};
|
||||
|
||||
// --- Realtime-record backend: the ASYNC seam ---------------------------------
|
||||
//
|
||||
// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport
|
||||
// on REAPER's audio thread and returns immediately — it does NOT block until the
|
||||
// range completes, which takes (end - start) wall-clock seconds. Blocking the main
|
||||
// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN
|
||||
// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called
|
||||
// from the same OnTimer that runs session.poll()) advances the in-flight record and
|
||||
// reports when it is done.
|
||||
//
|
||||
// SEAM CHOICE (surfaced): RealtimeRecordBackend deliberately does NOT implement the
|
||||
// synchronous ICaptureBackend — that interface returns a finished Sample from one
|
||||
// call, which no longer fits a record that spans ticks. The two backends have
|
||||
// genuinely different lifecycles (offline is headless + immediate; realtime is
|
||||
// transport-driven + async), so forcing a shared async interface would make offline
|
||||
// fake a lifecycle it does not have (its tick() would always be Done on the first
|
||||
// call — dead code / an LSP smell). Offline stays synchronous and unchanged; the
|
||||
// realtime backend owns this small bespoke async seam, driven by exactly one caller
|
||||
// (main.cpp's OnTimer). This is the split-sync/async fork, chosen over a unified
|
||||
// async interface for that reason.
|
||||
|
||||
// One tick's verdict from the in-flight record.
|
||||
enum class RealtimeTickStatus {
|
||||
InProgress, // still recording — call tick() again next timer tick
|
||||
Done, // finished (range end reached, or the user stopped) — `result` is set
|
||||
Failed, // an error tore the capture down — `result.message` explains
|
||||
};
|
||||
|
||||
struct RealtimeTickResult {
|
||||
RealtimeTickStatus status = RealtimeTickStatus::InProgress;
|
||||
CaptureResult result; // meaningful only when status == Done or Failed
|
||||
};
|
||||
|
||||
// The opaque in-flight capture state. Owns the snapshot of everything to restore
|
||||
// (temp track + its receive sends from the source tracks, other tracks' I_RECARM,
|
||||
// transport, edit cursor, time selection) and the record's own project handle.
|
||||
// Defined in
|
||||
// capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject*
|
||||
// leaks here) by holding it behind a forward-declared type + unique_ptr.
|
||||
//
|
||||
// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope
|
||||
// RAII guard) because the record spans ticks — no single stack frame outlives it.
|
||||
// Every terminal path (normal completion, user stop, error, project switch, unload)
|
||||
// funnels through the same single restore, safe to call once from whichever fires.
|
||||
class RealtimeCaptureState;
|
||||
|
||||
// Out-of-line deleter so callers (main.cpp) can own a unique_ptr to the opaque
|
||||
// RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the delete is
|
||||
// compiled in capture_realtime.cpp where the type is complete, keeping this header
|
||||
// REAPER-free (load-bearing split).
|
||||
struct RealtimeCaptureStateDeleter {
|
||||
void operator()(RealtimeCaptureState* p) const noexcept;
|
||||
};
|
||||
using RealtimeCaptureHandle =
|
||||
std::unique_ptr<RealtimeCaptureState, RealtimeCaptureStateDeleter>;
|
||||
|
||||
// Realtime-record backend — captures by RECORDING in realtime (transport-driven)
|
||||
// into a hidden temp track, then moves the recorded file into the bank as a Sample.
|
||||
// For sources offline render cannot do (hardware, performed FX) and as the true
|
||||
// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline
|
||||
// render has none). Dialog-free: never invokes the offline-render progress window.
|
||||
//
|
||||
// Non-bit-identical by nature (it is realtime); offline stays the deterministic
|
||||
// default. Non-destructive across EVERY terminal path — the review gate — which is
|
||||
// harder here than offline because the record spans ticks: the snapshot + restore
|
||||
// live on RealtimeCaptureState, not a function-scope RAII destructor.
|
||||
//
|
||||
// SCOPE (this increment): TRACK scope only — records the selected track's OWN
|
||||
// output (item + that track's own FX + its own fader/pan, PRE-parent), matching
|
||||
// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's
|
||||
// output is naturally PRE-parent (the parent has not summed it yet), so the tap is
|
||||
// chain-independent by construction. Item realtime is deferred (UnsupportedMode).
|
||||
class RealtimeRecordBackend {
|
||||
public:
|
||||
// Starts a realtime record: validates the request (track scope, non-empty range,
|
||||
// at least one source track, active + saved project, transport idle), snapshots
|
||||
// all state to restore, creates the hidden temp track, routes a send FROM each
|
||||
// source track INTO the temp track, arms, and CSurf_OnRecord — then returns
|
||||
// IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to
|
||||
// tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free,
|
||||
// carrying only the provenance GUIDs). On success the returned unique_ptr owns the
|
||||
// in-flight state; drive it with tick(). On a validation/setup failure returns
|
||||
// nullptr and fills `outFailure` with the CaptureStatus + message (nothing was
|
||||
// left mutated — begin() restores on its own failure paths).
|
||||
RealtimeCaptureHandle begin(const CaptureRequest& request,
|
||||
const std::vector<MediaTrack*>& sourceTracks,
|
||||
CaptureResult& outFailure);
|
||||
|
||||
// Advances the in-flight record one tick. Reads the transport (bound to the
|
||||
// record's OWN project handle so a project switch cannot confuse it), and on a
|
||||
// terminal verdict stops the transport, finalizes the recorded file into the
|
||||
// bank Sample (Done) or reports the failure (Failed), then restores ALL
|
||||
// snapshotted state. Returns InProgress while the record is still running.
|
||||
// After Done/Failed the state is spent — the caller drops the unique_ptr.
|
||||
RealtimeTickResult tick(RealtimeCaptureState& state);
|
||||
|
||||
// Force-terminate an in-flight record NOW without waiting for the range end:
|
||||
// stops the transport, finalizes whatever was captured (best effort) or abandons
|
||||
// it, and restores ALL snapshotted state. For the shutdown / project-switch
|
||||
// paths (extension unload, a new project became active) where the record must
|
||||
// not leak a temp track / armed track / altered transport into the user's
|
||||
// project. Idempotent — safe even if a prior tick already tore the state down.
|
||||
RealtimeTickResult abort(RealtimeCaptureState& state);
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,858 @@
|
||||
#include "core/namespaces.h"
|
||||
// capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
//
|
||||
// Captures the requested scope over the requested range by RECORDING in realtime
|
||||
// (transport-driven) into a hidden temp track, then moves the recorded file into
|
||||
// the bank as a Sample — non-destructively. This increment implements the TRACK
|
||||
// scope only (records the selected track's own output). Item realtime is deferred
|
||||
// (UnsupportedMode) rather than silently half-built.
|
||||
//
|
||||
// ============================================================================
|
||||
// §ASYNC — timer-driven, no UI block (M8 rework — Daniel: "do it right")
|
||||
// ============================================================================
|
||||
// A realtime record takes (end - start) wall-clock seconds. The earlier spike ran a
|
||||
// bounded MAIN-THREAD wait for the transport to reach the range end — which FREEZES
|
||||
// REAPER's UI for the whole record. That is gone. The record is now driven across
|
||||
// timer ticks:
|
||||
// begin() — validate, snapshot ALL state to restore, create the temp track,
|
||||
// route the source-track tap, arm, CSurf_OnRecord, RETURN IMMEDIATELY.
|
||||
// tick() — (from OnTimer, the same tick as session.poll()) read the transport,
|
||||
// and on a terminal verdict stop + finalize/abort + RESTORE everything.
|
||||
// abort() — force-terminate now (shutdown / project switch) + RESTORE everything.
|
||||
//
|
||||
// The snapshot + restore live on RealtimeCaptureState (below), NOT a function-scope
|
||||
// RAII guard — because the record spans ticks, no single stack frame outlives it.
|
||||
// restore() is idempotent (a restored_ latch): every terminal path — normal
|
||||
// completion, user stop, error, second-capture reject, project switch, unload —
|
||||
// funnels through the SAME single restore, safe to call once from whichever fires.
|
||||
// The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the
|
||||
// completion state machine (advanceRecordPhase) all live in realtime_record.{h,cpp}
|
||||
// (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe.
|
||||
//
|
||||
// ============================================================================
|
||||
// §TAP — track-output tap (selected track's own output, PRE-parent)
|
||||
// ============================================================================
|
||||
// The recipe: the hidden temp track RECEIVES a send FROM each selected source track
|
||||
// (CreateTrackSend(source, temp)). The temp track records its OWN output
|
||||
// (I_RECMODE 3/6, latency-compensated) with B_MAINSEND=0 (it does NOT sum back into
|
||||
// the master — no feedback, no monitoring double). Multiple selected tracks each get
|
||||
// a send into the one temp track, so their outputs SUM in the temp track — matching
|
||||
// how offline track scope handles a multi-track selection.
|
||||
//
|
||||
// WHY THIS FAITHFULLY CAPTURES THE TRACK'S OUTPUT — and why NO FxBypassGuard:
|
||||
// A CreateTrackSend defaults to I_SENDMODE=0 (post-fader) with I_SRCCHAN=0
|
||||
// (channel offset 0, (srcchan>>10)==0 => full stereo — SDK ~3302/3304). Post-fader
|
||||
// taps the source track AFTER its own FX and AFTER its own fader/pan — i.e. exactly
|
||||
// the track's OWN OUTPUT — but BEFORE the parent/folder/master sums it. The send is
|
||||
// a branch off the signal at the track's output stage; the parent chain downstream
|
||||
// of that branch is not in the tapped path AT ALL. So the tap is chain-independent
|
||||
// BY CONSTRUCTION: there is nothing to neutralize, and FxBypassGuard (which mutates
|
||||
// the live chain, altering the user's monitoring) is deliberately NOT used. This is
|
||||
// the realtime analogue of offline track scope (item + the track's own FX + its own
|
||||
// fader/pan; parent/folder/master excluded), reached without touching any live FX.
|
||||
//
|
||||
// This ALSO fixes the earlier silent-file bug: that spike sent FROM the master INTO
|
||||
// a temp track, which REAPER refuses to carry (master->track is a feedback loop), so
|
||||
// the temp recorded silence. A regular track->track send has no feedback — it works.
|
||||
//
|
||||
// Non-destructive: the temp track is deleted on teardown, which removes every send we
|
||||
// created INTO it (REAPER cannot leave a send dangling to a deleted destination) — so
|
||||
// NO source track retains any routing change. We never mutate any existing track's
|
||||
// persistent state; we only add sends FROM the source tracks that vanish with the
|
||||
// temp track. The selected source tracks are UNCHANGED after capture.
|
||||
//
|
||||
// Item realtime is deferred (UnsupportedMode): item scope would need per-item take
|
||||
// isolation on top of the tap, which is a separate increment.
|
||||
|
||||
#include "shell/capture/capture.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // hashBytes, deriveBankPaths
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "core/audio/peaks.h" // lastFrameAboveThreshold, AudioSample
|
||||
#include "core/capture/realtime_record.h"
|
||||
#include "core/capture/render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
|
||||
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_InsertTrackAtIndex
|
||||
#define REAPERAPI_WANT_DeleteTrack
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_CreateTrackSend
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_GetTrackNumMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
||||
#define REAPERAPI_WANT_CSurf_OnRecord
|
||||
#define REAPERAPI_WANT_OnStopButtonEx
|
||||
#define REAPERAPI_WANT_GetPlayStateEx
|
||||
#define REAPERAPI_WANT_GetPlayPositionEx
|
||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
||||
#define REAPERAPI_WANT_GetCursorPosition
|
||||
#define REAPERAPI_WANT_SetEditCurPos
|
||||
#define REAPERAPI_WANT_ValidatePtr2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// A monotonic, filesystem-safe timestamp tag so repeated captures do not collide.
|
||||
std::string makeUniqueTag() {
|
||||
std::time_t now = std::time(nullptr);
|
||||
return "rt-" + std::to_string(static_cast<long long>(now));
|
||||
}
|
||||
|
||||
std::string normSlashes(std::string s) {
|
||||
for (char& c : s) if (c == '\\') c = '/';
|
||||
if (s.size() > 1 && s.back() == '/') s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// Reads the ACTIVE project's .rpp path (empty if unsaved). Only needed at begin()
|
||||
// time, when the record's project IS the active project.
|
||||
std::string readRppPath() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
// Discovers the file REAPER actually recorded onto the temp track: the first media
|
||||
// item's active take's source file. Empty string if nothing was recorded.
|
||||
std::string recordedFilePath(MediaTrack* temp) {
|
||||
if (!temp) return {};
|
||||
if (GetTrackNumMediaItems(temp) <= 0) return {};
|
||||
MediaItem* item = GetTrackMediaItem(temp, 0);
|
||||
if (!item) return {};
|
||||
MediaItem_Take* take = GetMediaItemTake(item, 0);
|
||||
if (!take) return {};
|
||||
PCM_source* src = GetMediaItemTake_Source(take);
|
||||
if (!src) return {};
|
||||
std::vector<char> buf(4096, '\0');
|
||||
GetMediaSourceFileName(src, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
// The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no
|
||||
// item/take/source, or the file does not exist on disk this tick). Used by the flush
|
||||
// wait to detect stability (size unchanged across a tick) BEFORE moving the file — a
|
||||
// take REAPER is still flushing on the audio thread grows tick over tick.
|
||||
std::int64_t recordedFileSize(MediaTrack* temp) {
|
||||
const std::string path = recordedFilePath(temp);
|
||||
if (path.empty()) return -1;
|
||||
std::error_code ec;
|
||||
const auto sz = std::filesystem::file_size(path, ec);
|
||||
if (ec) return -1;
|
||||
return static_cast<std::int64_t>(sz);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ============================================================================
|
||||
// RealtimeCaptureState — the in-flight snapshot + idempotent restore
|
||||
// ============================================================================
|
||||
// Holds EVERYTHING to restore across the many ticks the record spans (temp track +
|
||||
// its receive-sum sends, other tracks' I_RECARM, transport, edit cursor, time selection),
|
||||
// plus the request echo needed to finalize the Sample. restore() is idempotent
|
||||
// (restored_ latch) and is the single teardown every terminal path calls.
|
||||
class RealtimeCaptureState {
|
||||
public:
|
||||
// Bound at begin(): the record's OWN project (transport reads use *Ex(proj_) so
|
||||
// a project switch mid-record cannot read the wrong transport), the request
|
||||
// echo, and the resolved bank paths + tag for finalize.
|
||||
ReaProject* proj_ = nullptr;
|
||||
CaptureRequest request_;
|
||||
BankPaths paths_;
|
||||
std::string uniqueTag_;
|
||||
|
||||
// The RECORDED window end in project seconds (>= request_.endSeconds). For a tail
|
||||
// mode the transport runs PAST the range end (Auto: +8 s cap; Manual: +the set
|
||||
// length), so this — not request_.endSeconds — is the end the completion state
|
||||
// machine waits for. Equals request_.endSeconds for TailMode::None (exact bounds).
|
||||
double recordWindowEnd_ = 0.0;
|
||||
|
||||
// The transient sink. The sends we create (from each selected source track INTO
|
||||
// temp_) live on those source tracks pointing AT temp_, and are removed automatically
|
||||
// when temp_ is deleted — REAPER cannot leave a send dangling to a deleted
|
||||
// destination. So there is no separate send handle to track here.
|
||||
MediaTrack* temp_ = nullptr;
|
||||
|
||||
// The record phase (pure state machine drives the transition). Starts Recording.
|
||||
RecordPhase phase_ = RecordPhase::Recording;
|
||||
|
||||
// Wall-clock anchors for the pure machine's safety ceilings (a steady clock — not
|
||||
// the play cursor — so a stuck/looping transport is still caught, review §3).
|
||||
// begunAt_ is set at begin(); finalizingAt_ is set on the Recording->Finalizing
|
||||
// edge (the transport stop) so the flush wait is bounded from the stop, not begin.
|
||||
std::chrono::steady_clock::time_point begunAt_{};
|
||||
std::chrono::steady_clock::time_point finalizingAt_{};
|
||||
|
||||
// Deferred-finalize (review §2) flush tracking: the recorded file's size the
|
||||
// previous tick, so "size unchanged across a tick" signals REAPER finished
|
||||
// flushing/closing the take. -1 = not yet seen.
|
||||
std::int64_t lastFileSize_ = -1;
|
||||
|
||||
void markElapsedStart() { begunAt_ = std::chrono::steady_clock::now(); }
|
||||
double elapsedSeconds() const {
|
||||
return std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now() - begunAt_).count();
|
||||
}
|
||||
// Set the flush-wait anchor once, on the first Finalizing tick.
|
||||
void markFinalizingStartOnce() {
|
||||
if (finalizingAt_.time_since_epoch().count() == 0)
|
||||
finalizingAt_ = std::chrono::steady_clock::now();
|
||||
}
|
||||
double finalizingSeconds() const {
|
||||
if (finalizingAt_.time_since_epoch().count() == 0) return 0.0;
|
||||
return std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now() - finalizingAt_).count();
|
||||
}
|
||||
|
||||
// Snapshot of state to restore. Filled at begin(), replayed once by restore().
|
||||
double curPos_ = 0.0;
|
||||
double tsStart_ = 0.0;
|
||||
double tsEnd_ = 0.0;
|
||||
struct ArmSnap { MediaTrack* track; double recarm; };
|
||||
std::vector<ArmSnap> armSnaps_;
|
||||
|
||||
// Snapshot the transport-adjacent state (cursor + time selection) and every
|
||||
// OTHER track's arm, disarming them so only our sink records. Call ONCE, before
|
||||
// the temp track exists (so the temp track is never in the arm snapshot).
|
||||
void snapshotAndDisarmOthers() {
|
||||
curPos_ = GetCursorPosition();
|
||||
GetSet_LoopTimeRange(false, false, &tsStart_, &tsEnd_, false);
|
||||
const int n = CountTracks(proj_);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaTrack* tr = GetTrack(proj_, i);
|
||||
if (!tr) continue;
|
||||
const double armed = GetMediaTrackInfo_Value(tr, "I_RECARM");
|
||||
if (armed != 0.0) {
|
||||
armSnaps_.push_back({tr, armed});
|
||||
SetMediaTrackInfo_Value(tr, "I_RECARM", 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The single, idempotent teardown. Called on EVERY terminal path (normal
|
||||
// completion, user stop, error, project switch, unload). Safe to call more than
|
||||
// once — the restored_ latch makes every call after the first a no-op. Order:
|
||||
// 1. stop the transport if anything is still running (we own it),
|
||||
// 2. delete the temp track (drops its receive-sum sends + the recorded item),
|
||||
// 3. restore every other track's arm,
|
||||
// 4. restore the time selection + edit cursor.
|
||||
// Stop the record's OWN project transport if it is still playing/recording. Uses
|
||||
// the project-scoped OnStopButtonEx(proj_) (not the global CSurf_OnStop) so a
|
||||
// project switch mid-record — where proj_ is no longer the ACTIVE project — stops
|
||||
// OUR project's transport, never the foreign now-active one. &1=playing,
|
||||
// &4=recording. Idempotent to call (the playstate guard makes a repeat a no-op).
|
||||
void stopOwnTransport() {
|
||||
if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_);
|
||||
}
|
||||
|
||||
// Is the captured project STILL OPEN? (review §1 — CRITICAL). If the captured
|
||||
// project was CLOSED mid-record, proj_/temp_ point at freed memory;
|
||||
// touching them (stopOwnTransport, DeleteTrack, arm restore) is a use-after-free.
|
||||
// ValidatePtr2 with a null project validates the ReaProject* itself (the header:
|
||||
// "proj is ignored if pointer is itself a project"). Every teardown that
|
||||
// dereferences a captured REAPER object MUST gate on this first.
|
||||
bool captureProjectStillOpen() const {
|
||||
return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*");
|
||||
}
|
||||
|
||||
// Drop the handle WITHOUT touching any REAPER state — for the closed-project case
|
||||
// (review §1). A closed project already reclaimed its temp track, arms, and
|
||||
// transport; there is nothing to restore and the pointers are freed. Latch
|
||||
// restored_ so any later terminal path is a no-op (idempotent), but skip every
|
||||
// REAPER call restore() would make.
|
||||
void dropWithoutRestore() {
|
||||
restored_ = true;
|
||||
temp_ = nullptr;
|
||||
armSnaps_.clear();
|
||||
}
|
||||
|
||||
void restore() {
|
||||
if (restored_) return;
|
||||
restored_ = true;
|
||||
|
||||
// 1. Transport: stop OUR project's if still running (usually already stopped
|
||||
// by the terminal path's explicit stop-before-finalize — a safe no-op then).
|
||||
stopOwnTransport();
|
||||
|
||||
// 2. Temp track: deleting it drops the source-track sends (REAPER removes every
|
||||
// send whose destination is deleted — no source track is left mutated) AND the
|
||||
// recorded arrange item in one move — nothing stays behind (load-bearing).
|
||||
if (temp_) { DeleteTrack(temp_); temp_ = nullptr; }
|
||||
|
||||
// 3. Other tracks' record-arm.
|
||||
for (const ArmSnap& s : armSnaps_)
|
||||
SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm);
|
||||
armSnaps_.clear();
|
||||
|
||||
// 4. Time selection + edit cursor (no view move, no seek).
|
||||
GetSet_LoopTimeRange(true, false, &tsStart_, &tsEnd_, false);
|
||||
SetEditCurPos(curPos_, false, false);
|
||||
}
|
||||
|
||||
bool restored() const { return restored_; }
|
||||
bool finalized() const { return finalized_; }
|
||||
void markFinalized() { finalized_ = true; }
|
||||
|
||||
private:
|
||||
bool restored_ = false;
|
||||
bool finalized_ = false;
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
|
||||
// empty on any I/O failure — the caller treats an unreadable file as "skip the
|
||||
// trim" (keep the untrimmed window), never as a corruption of the recorded audio.
|
||||
|
||||
// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields).
|
||||
void writeU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
|
||||
bytes[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||
bytes[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||
bytes[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||
bytes[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime)
|
||||
// ============================================================================
|
||||
// After the recorded file is stable and moved into the bank (the file we OWN — never
|
||||
// the project), Auto mode trims the trailing decay: read the WAV, scan the tail
|
||||
// region (frames AFTER the original range end) backward for the last frame above
|
||||
// -72 dB, and truncate the file there. Rules (spec):
|
||||
// * no frame in the tail window above -72 dB -> trim back to the original range end
|
||||
// * signal never falls below -72 dB in window -> keep the full window (cap did its job)
|
||||
// * otherwise -> trim one frame past the last audible
|
||||
//
|
||||
// Returns the trimmed length in SECONDS (for the Sample), or a negative value to
|
||||
// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and
|
||||
// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window)
|
||||
// rather than risk corrupting the capture — realtime tail is a convenience path.
|
||||
//
|
||||
// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit
|
||||
// float WAV (REAPER project record format — the manual procedure sets it) and is fully
|
||||
// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees
|
||||
// that for the normal path; abort()'s best-effort finalize races it, documented).
|
||||
double trimAutoTailInPlace(const std::string& path,
|
||||
double rangeStartSeconds,
|
||||
double rangeEndSeconds) {
|
||||
constexpr double kNoTrim = -1.0;
|
||||
|
||||
std::vector<std::uint8_t> bytes = readFileBytes(path);
|
||||
if (bytes.empty()) return kNoTrim;
|
||||
|
||||
const reasampler::WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim
|
||||
|
||||
const std::size_t totalFrames = layout.frameCount();
|
||||
if (totalFrames == 0) return kNoTrim;
|
||||
|
||||
// The original range end as a frame index within the file (frame 0 == start). Use
|
||||
// the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow
|
||||
// project). Clamp to the file so a rounding overshoot cannot exceed it.
|
||||
const double rangeSeconds = rangeEndSeconds - rangeStartSeconds;
|
||||
if (rangeSeconds <= 0.0) return kNoTrim;
|
||||
std::size_t rangeEndFrame = static_cast<std::size_t>(
|
||||
rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5);
|
||||
if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames;
|
||||
|
||||
// Nothing recorded past the range end (the tail window was empty) -> nothing to
|
||||
// trim; keep as-is. (Shouldn't happen for Auto, but total by construction.)
|
||||
if (rangeEndFrame >= totalFrames) return kNoTrim;
|
||||
|
||||
// Scan ONLY the tail region (frames after the original range end). The trim never
|
||||
// eats into the range body — the scan starts at rangeEndFrame.
|
||||
const std::size_t tailFrames = totalFrames - rangeEndFrame;
|
||||
const std::vector<reasampler::AudioSample> tailPcm =
|
||||
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
|
||||
if (tailPcm.empty()) return kNoTrim;
|
||||
|
||||
const float threshold = static_cast<float>(reasampler::autoTrimEndRatio());
|
||||
const std::size_t lastAbove = reasampler::lastFrameAboveThreshold(
|
||||
tailPcm, layout.channelCount, tailFrames, threshold);
|
||||
|
||||
// keptFrames: the total frame count the trimmed file retains.
|
||||
// no audible tail frame -> trim back to the range end (rangeEndFrame frames)
|
||||
// an audible frame at idx -> keep range body + up to and including that frame
|
||||
// The "signal never falls below threshold" case falls out naturally: lastAbove is
|
||||
// the final tail frame, so keptFrames == totalFrames (the full window is kept).
|
||||
std::size_t keptFrames;
|
||||
if (lastAbove == reasampler::kNoFrameAboveThreshold) {
|
||||
keptFrames = rangeEndFrame;
|
||||
} else {
|
||||
keptFrames = rangeEndFrame + (lastAbove + 1);
|
||||
}
|
||||
if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate
|
||||
|
||||
const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames);
|
||||
if (!plan.valid) return kNoTrim;
|
||||
|
||||
// Patch the RIFF + data size fields in the in-memory buffer so they describe the
|
||||
// kept frame count, then rewrite the file as exactly the first newFileByteLength
|
||||
// bytes (header + patched sizes + retained PCM). A single truncating write is the
|
||||
// simplest correct truncate — no separate resize step, no partial-write window
|
||||
// where the on-disk sizes and length disagree. The result is a valid, playable WAV
|
||||
// of the kept frames (verified by the wav_trim re-parse test).
|
||||
writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
|
||||
writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize);
|
||||
|
||||
// NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full
|
||||
// disk, a yanked drive) would leave a short file while we return kNoTrim, so the
|
||||
// Sample length would overstate the file. Vanishingly unlikely for a just-recorded
|
||||
// local bank file, and realtime tail is a convenience path, so a temp-file+atomic-
|
||||
// rename is not warranted here; flagged rather than built.
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file
|
||||
out.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(plan.newFileByteLength));
|
||||
if (!out) return kNoTrim;
|
||||
out.close();
|
||||
|
||||
// The trimmed length in seconds for the Sample metadata.
|
||||
return static_cast<double>(keptFrames) / static_cast<double>(layout.sampleRate);
|
||||
}
|
||||
|
||||
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
||||
// move it into the bank, populate the Sample via the pure mapping. Returns Ok +
|
||||
// Sample on success, or a RenderFailed result. Does NOT restore — the caller
|
||||
// restores unconditionally afterward (finalize + restore are separate steps so a
|
||||
// finalize failure still restores).
|
||||
CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
||||
CaptureResult result;
|
||||
|
||||
const std::string recorded = normSlashes(recordedFilePath(st.temp_));
|
||||
if (recorded.empty() || !std::filesystem::exists(recorded)) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Realtime record produced no file (check transport/record "
|
||||
"settings in the DAW).";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(st.paths_.absoluteDir, ec);
|
||||
const std::string destPath = st.paths_.absoluteDir + "/" + st.paths_.fileName;
|
||||
std::filesystem::rename(recorded, destPath, ec);
|
||||
if (ec) {
|
||||
// Cross-volume rename can fail; fall back to copy+remove.
|
||||
ec.clear();
|
||||
std::filesystem::copy_file(
|
||||
recorded, destPath,
|
||||
std::filesystem::copy_options::overwrite_existing, ec);
|
||||
if (ec) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Recorded file could not be moved into the bank: " +
|
||||
ec.message();
|
||||
return result;
|
||||
}
|
||||
std::error_code rmEc;
|
||||
std::filesystem::remove(recorded, rmEc); // best-effort
|
||||
}
|
||||
|
||||
// TAIL (Auto): trim the trailing decay of the recorded window in place — on the
|
||||
// BANK file we now own (destPath), never the project. Best-effort: an unreadable /
|
||||
// unknown-format / short file skips the trim (keeps the full window) rather than
|
||||
// corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a
|
||||
// fixed window (spec §The realtime path). Returns the trimmed length in seconds,
|
||||
// or < 0 for "no trim applied".
|
||||
double trimmedLenSeconds = -1.0;
|
||||
if (st.request_.tailMode == TailMode::Auto) {
|
||||
trimmedLenSeconds = trimAutoTailInPlace(destPath,
|
||||
st.request_.startSeconds,
|
||||
st.request_.endSeconds);
|
||||
}
|
||||
|
||||
RecordedCapture cap;
|
||||
cap.relativePath = st.paths_.relativePath;
|
||||
cap.uniqueTag = st.uniqueTag_;
|
||||
cap.sourceMode = SourceMode::Realtime;
|
||||
cap.startSeconds = st.request_.startSeconds;
|
||||
cap.endSeconds = st.request_.endSeconds;
|
||||
cap.wetDry = st.request_.wetDry;
|
||||
cap.displayName = st.request_.baseName;
|
||||
cap.trackGuids = st.request_.trackGuids;
|
||||
cap.channelCount = st.request_.channelCount;
|
||||
cap.sampleRate = (st.request_.sampleRate > 0)
|
||||
? st.request_.sampleRate
|
||||
: static_cast<int>(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false));
|
||||
cap.captureTempo = Master_GetTempo();
|
||||
// Time signature at the record range's START (L7 F1). TimeMap_GetTimeSigAtTime
|
||||
// (reaper_plugin_functions.h:7130) reads the meter effective at that project time;
|
||||
// proj=st.proj_ pins the recording's own project. tempoOut ignored (captureTempo is
|
||||
// the master tempo above). Leaves 0/0 (unstamped) on any failure.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
TimeMap_GetTimeSigAtTime(st.proj_, st.request_.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
||||
cap.captureTimeSigNum = tsNum;
|
||||
cap.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
cap.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = sampleFromRecordedCapture(cap);
|
||||
|
||||
// Content hash: WAV-aware FNV-1a over the (possibly trimmed) bank file's fmt+data
|
||||
// chunks so hashReferencedElsewhere can identify copies in other banks and suppress
|
||||
// the last-reference confirm when another bank still holds the same file. Using
|
||||
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
|
||||
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two records of identical
|
||||
// audio collapse to the same hash. Best-effort: an unreadable file leaves
|
||||
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
|
||||
// "" as non-participating).
|
||||
{
|
||||
const std::vector<std::uint8_t> fileBytes = readFileBytes(destPath);
|
||||
if (!fileBytes.empty()) {
|
||||
result.sample.contentHash = hashWavContent(fileBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// The recorded file's true length differs from the request range when a tail was
|
||||
// recorded, so the Sample length must reflect the FILE, not the range:
|
||||
// Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned.
|
||||
// Auto with no trim, or Manual -> the full recorded window (end - start).
|
||||
// None -> the exact range (unchanged; recordWindowEnd_ == endSeconds).
|
||||
// sampleFromRecordedCapture already set lengthSeconds = end - start; override it
|
||||
// to the recorded/trimmed length so downstream (thumbnail, placement) matches disk.
|
||||
if (trimmedLenSeconds >= 0.0) {
|
||||
result.sample.lengthSeconds = trimmedLenSeconds;
|
||||
} else {
|
||||
result.sample.lengthSeconds =
|
||||
st.recordWindowEnd_ - st.request_.startSeconds;
|
||||
}
|
||||
|
||||
result.message = "Realtime-captured [" +
|
||||
std::to_string(st.request_.startSeconds) + "s, " +
|
||||
std::to_string(st.request_.endSeconds) + "s] (recorded " +
|
||||
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
||||
st.paths_.relativePath;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ============================================================================
|
||||
// begin — start the record, snapshot, return immediately (no UI block)
|
||||
// ============================================================================
|
||||
void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noexcept {
|
||||
delete p; // full type is visible here — keeps capture.h REAPER-free
|
||||
}
|
||||
|
||||
RealtimeCaptureHandle
|
||||
RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
const std::vector<MediaTrack*>& sourceTracks,
|
||||
CaptureResult& outFailure) {
|
||||
// Only the track scope is implemented this increment (see §TAP). Item realtime
|
||||
// is deferred — it needs per-item take isolation on top of the track-output tap.
|
||||
if (request.sourceMode != SourceMode::SelectedTracks) {
|
||||
outFailure.status = CaptureStatus::UnsupportedMode;
|
||||
outFailure.message = "RealtimeRecordBackend implements TRACK scope only this "
|
||||
"increment (item realtime is deferred).";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Track scope needs at least one source track to tap. No selection -> refuse
|
||||
// (matching offline track scope's no-op on an empty selection).
|
||||
if (sourceTracks.empty()) {
|
||||
outFailure.status = CaptureStatus::UnsupportedMode;
|
||||
outFailure.message = "No track selected — realtime track capture needs at least "
|
||||
"one selected track to tap.";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Exact bounds: refuse an empty/inverted range rather than record silence.
|
||||
if (!(request.endSeconds > request.startSeconds)) {
|
||||
outFailure.status = CaptureStatus::EmptyRange;
|
||||
outFailure.message = "Capture range is empty (end <= start).";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) {
|
||||
outFailure.status = CaptureStatus::NoProject;
|
||||
outFailure.message = "No active project.";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Refuse if the transport is already playing/recording — we own the transport for
|
||||
// the capture window and must not hijack a user's live take.
|
||||
if (GetPlayStateEx(proj) & (1 | 4)) {
|
||||
outFailure.status = CaptureStatus::TransportBusy;
|
||||
outFailure.message = "Transport is already playing/recording — realtime capture "
|
||||
"refused. Stop the transport first.";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Saved-project gate (same as offline): the bank folder resolves against the
|
||||
// .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved.
|
||||
std::string rppPath = readRppPath();
|
||||
if (rppPath.empty()) {
|
||||
Main_SaveProject(proj, true); // DAW-only: opens Save-As, blocks (verify)
|
||||
rppPath = readRppPath();
|
||||
}
|
||||
if (rppPath.empty()) {
|
||||
outFailure.status = CaptureStatus::NoProject;
|
||||
outFailure.message = "Project must be saved before capture — nothing captured.";
|
||||
return nullptr;
|
||||
}
|
||||
const std::string projectDir =
|
||||
normSlashes(std::filesystem::path(rppPath).parent_path().string());
|
||||
|
||||
// --- Build the in-flight state (owns the snapshot + teardown) ---------------
|
||||
RealtimeCaptureHandle st(new RealtimeCaptureState());
|
||||
st->proj_ = proj;
|
||||
st->request_ = request;
|
||||
st->uniqueTag_ = makeUniqueTag();
|
||||
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
|
||||
|
||||
// The recorded window end: extended past the range end for a tail mode (Auto/Manual),
|
||||
// exact for None. This — not request.endSeconds — is what the completion machine
|
||||
// waits for; the extra window past the range end is trimmed later (Auto) or kept
|
||||
// (Manual). Pure mapping (render_settings), shared caps with the offline tail.
|
||||
st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode,
|
||||
request.endSeconds,
|
||||
request.tailMs);
|
||||
|
||||
// DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT
|
||||
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
|
||||
// shells is intentional. This backend fully restores its own state across every
|
||||
// terminal path (the restore() latch); an undo point would surface an internal,
|
||||
// fully-reversed scaffold in the user's undo history for no user-meaningful action.
|
||||
// Snapshot cursor + time selection, and disarm every OTHER track BEFORE the temp
|
||||
// track exists (so it is never in the arm snapshot and keeps the arm we set).
|
||||
st->snapshotAndDisarmOthers();
|
||||
|
||||
// Hidden temp track at the end: no default FX/envelopes (clean sink), hidden from
|
||||
// both panels, B_MAINSEND=0 so it does NOT sum back into the master (monitoring
|
||||
// invariant — it would otherwise double the tapped tracks in the user's monitoring).
|
||||
const int idx = CountTracks(proj);
|
||||
InsertTrackAtIndex(idx, false);
|
||||
st->temp_ = GetTrack(proj, idx);
|
||||
if (!st->temp_) {
|
||||
outFailure.status = CaptureStatus::RenderFailed;
|
||||
outFailure.message = "Could not create the hidden temp record track.";
|
||||
st->restore(); // undo the disarm + cursor/time-sel snapshot
|
||||
return nullptr;
|
||||
}
|
||||
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINTCP", 0.0);
|
||||
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINMIXER", 0.0);
|
||||
SetMediaTrackInfo_Value(st->temp_, "B_MAINSEND", 0.0);
|
||||
|
||||
// Route the TRACK-OUTPUT tap: a send FROM each selected source track INTO the temp
|
||||
// track (CreateTrackSend(source, temp)). The temp records its OWN output, so the
|
||||
// sends' outputs SUM in it — multiple selected tracks are captured together (same as
|
||||
// offline track scope). See §TAP for why this faithfully captures each track's own
|
||||
// output and needs no FxBypassGuard.
|
||||
//
|
||||
// Sends default to post-fader (I_SENDMODE 0) and full-stereo (I_SRCCHAN default,
|
||||
// (srcchan>>10)==0 — SDK ~3302/3304): post-fader = after the source track's FX and
|
||||
// fader/pan = the track's OWN output, tapped BEFORE the parent sums it. Left at
|
||||
// defaults deliberately — that IS the track-scope tap point.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION (flag): that a post-fader track->temp send + output-record
|
||||
// reproduces the track's own output sample-for-sample (latency comp, pan law,
|
||||
// mono/stereo folding) is the crux to verify live.
|
||||
int sendsMade = 0;
|
||||
for (MediaTrack* src : sourceTracks) {
|
||||
if (!src || src == st->temp_) continue;
|
||||
if (CreateTrackSend(src, st->temp_) >= 0) ++sendsMade;
|
||||
}
|
||||
if (sendsMade == 0) {
|
||||
// Every send failed (should not happen for valid selected tracks). Refuse
|
||||
// rather than record a guaranteed-silent file.
|
||||
outFailure.status = CaptureStatus::RenderFailed;
|
||||
outFailure.message = "Could not route any selected track into the record tap — "
|
||||
"nothing to capture.";
|
||||
st->restore(); // deleting the temp track drops any partial sends too
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Record-mode values from the pure planner. The temp track records its OWN output;
|
||||
// it has no FX and unity fader, so its post-fader output equals the summed sends.
|
||||
// Track scope is fully wet -> PostFader. (The actual track-scope tap point is the
|
||||
// source sends' default post-fader mode; the temp's recmode only records the sum.)
|
||||
const OutputTap tap = outputTapForWetDry(request.wetDry);
|
||||
const RecordModePlan rec = recordModePlanFor(request.channelCount, tap);
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast<double>(rec.recMode));
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE_FLAGS",
|
||||
static_cast<double>(rec.recModeFlags));
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
|
||||
|
||||
// Record range: time selection over [start, recordWindowEnd], play cursor at start.
|
||||
// recordWindowEnd extends past the request's range end for a tail mode so the
|
||||
// transport captures the decaying tail; it equals the range end for None (exact
|
||||
// bounds). Both cursor + time selection were snapshotted and are restored by
|
||||
// restore().
|
||||
double rs = request.startSeconds, re = st->recordWindowEnd_;
|
||||
GetSet_LoopTimeRange(true, false, &rs, &re, false);
|
||||
SetEditCurPos(request.startSeconds, false, false);
|
||||
|
||||
// Start the transport and RETURN. tick() drives the rest across timer ticks.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION (flag): CSurf_OnRecord starts recording and the exact
|
||||
// range/auto-punch/stop behavior depends on the user's transport settings — not
|
||||
// header-guaranteed. tick() detects completion via the play cursor reaching the
|
||||
// range end (the pure state machine), independent of REAPER's auto-punch.
|
||||
CSurf_OnRecord();
|
||||
|
||||
// Anchor the wall-clock safety ceiling from here (steady clock — independent of the
|
||||
// play cursor, so a transport that starts but never advances is still bounded).
|
||||
st->markElapsedStart();
|
||||
|
||||
return st;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// tick — advance the in-flight record; on terminal, finalize/abort + restore
|
||||
// ============================================================================
|
||||
RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
||||
RealtimeTickResult out;
|
||||
|
||||
// If a prior terminal path already tore this down (e.g. abort() then a stray
|
||||
// tick), do nothing — the state is spent.
|
||||
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
|
||||
|
||||
const RecordPhase prevPhase = state.phase_;
|
||||
|
||||
// Read the transport bound to the record's OWN project (a project switch cannot
|
||||
// point these reads at the wrong transport). &4 = recording. Gather everything the
|
||||
// pure machine needs (transport + wall-clock ceilings + file-flush readiness).
|
||||
RecordTickInputs inputs;
|
||||
inputs.transport.recording = (GetPlayStateEx(state.proj_) & 4) != 0;
|
||||
inputs.transport.playPosition = GetPlayPositionEx(state.proj_);
|
||||
inputs.elapsedSeconds = state.elapsedSeconds();
|
||||
|
||||
// Deferred-finalize flush check (review §2), only meaningful once stopped. The
|
||||
// recorded file is READY when its size is a valid positive value AND unchanged
|
||||
// from the previous tick — REAPER finished flushing/closing the take on the audio
|
||||
// thread. Comparing across a tick avoids moving a file mid-write (truncated take).
|
||||
if (prevPhase == RecordPhase::Finalizing) {
|
||||
state.markFinalizingStartOnce();
|
||||
inputs.finalizingSeconds = state.finalizingSeconds();
|
||||
const std::int64_t sz = recordedFileSize(state.temp_);
|
||||
inputs.fileReady = (sz > 0 && sz == state.lastFileSize_);
|
||||
state.lastFileSize_ = sz;
|
||||
}
|
||||
|
||||
// Wait for the transport to reach the RECORDED window end (extended past the
|
||||
// range end for a tail mode), not the request's range end — the extra tail window
|
||||
// is part of the record. The record safety ceiling scales with it (window - start
|
||||
// + margin) inside the pure machine.
|
||||
state.phase_ = advanceRecordPhase(state.phase_, inputs,
|
||||
state.request_.startSeconds,
|
||||
state.recordWindowEnd_);
|
||||
|
||||
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
|
||||
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
|
||||
// — never the global CSurf_OnStop, which would stop whatever project is ACTIVE (a
|
||||
// foreign one during a project switch), not the record's own. The flush wait then
|
||||
// proceeds across subsequent ticks before the file is moved.
|
||||
if (prevPhase == RecordPhase::Recording &&
|
||||
isStopRequested(state.phase_)) {
|
||||
state.stopOwnTransport();
|
||||
state.markFinalizingStartOnce(); // anchor the flush ceiling from the stop
|
||||
}
|
||||
|
||||
if (!isTerminalPhase(state.phase_)) {
|
||||
out.status = RealtimeTickStatus::InProgress;
|
||||
return out; // keep the OnTimer tick fast — recording or flushing
|
||||
}
|
||||
|
||||
// Terminal (Done: file flushed + stable; Failed: flush ceiling tripped). On Done,
|
||||
// finalize moves the now-stable file into the bank + builds the Sample. On Failed
|
||||
// (the flush timeout) there is nothing usable — report RenderFailed. Then restore
|
||||
// ALL snapshotted state — the non-destructive gate, idempotent + unconditional.
|
||||
CaptureResult res;
|
||||
if (state.phase_ == RecordPhase::Done) {
|
||||
res = finalizeRecording(state);
|
||||
} else {
|
||||
res.status = CaptureStatus::RenderFailed;
|
||||
res.message = "Realtime record timed out waiting for the recorded file to "
|
||||
"flush/close (nothing captured).";
|
||||
}
|
||||
state.markFinalized();
|
||||
state.restore();
|
||||
|
||||
out.result = res;
|
||||
out.status = (res.status == CaptureStatus::Ok)
|
||||
? RealtimeTickStatus::Done
|
||||
: RealtimeTickStatus::Failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// abort — force-terminate now (shutdown / project switch) + restore
|
||||
// ============================================================================
|
||||
RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
|
||||
RealtimeTickResult out;
|
||||
|
||||
// Already torn down (idempotent): report Failed and leave it.
|
||||
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
|
||||
|
||||
// CRITICAL (review §1): if the captured project was CLOSED mid-record, proj_ /
|
||||
// temp_ point at freed memory. The closed project already reclaimed its
|
||||
// temp track, arms, and transport — so DROP the handle WITHOUT touching any REAPER
|
||||
// state (no stop, no finalize, no DeleteTrack, no arm restore). Touching those
|
||||
// freed pointers is the use-after-free bug this guard exists to prevent. This is
|
||||
// the ONE terminal path that can run against a possibly-closed project (tick() only
|
||||
// runs while proj_ is the active — hence still-open — project); guarding here covers
|
||||
// both the project-switch and unload callers.
|
||||
if (!state.captureProjectStillOpen()) {
|
||||
state.dropWithoutRestore();
|
||||
out.result.status = CaptureStatus::RenderFailed;
|
||||
out.result.message = "Realtime capture dropped — the captured project was closed "
|
||||
"mid-record (nothing to restore; no capture persisted).";
|
||||
out.status = RealtimeTickStatus::Failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
// The project is still open (a tab-switch, or a clean unload with the project
|
||||
// present): stop the transport, then TRY to finalize whatever was captured so a
|
||||
// near-complete record still keeps the audio; if nothing was recorded (or the file
|
||||
// has not flushed yet), finalize returns RenderFailed and we abort clean.
|
||||
// Project-scoped stop (OnStopButtonEx(proj_)) — on a project switch proj_ is no
|
||||
// longer active, so the global CSurf_OnStop would stop the wrong (foreign) project.
|
||||
//
|
||||
// NOTE (residual timing — DAW-verify): abort is the force-terminate path (unload /
|
||||
// switch); it cannot span ticks to wait for the flush the way tick() does, so its
|
||||
// finalize still races REAPER's audio-thread take close. That is inherent to a
|
||||
// best-effort terminal grab and is acceptable — the normal completion path (tick)
|
||||
// is the one that must be flush-safe.
|
||||
state.stopOwnTransport();
|
||||
|
||||
CaptureResult res = finalizeRecording(state);
|
||||
state.markFinalized();
|
||||
state.restore(); // the non-destructive gate — always runs
|
||||
|
||||
out.result = res;
|
||||
out.status = (res.status == CaptureStatus::Ok)
|
||||
? RealtimeTickStatus::Done
|
||||
: RealtimeTickStatus::Failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,186 @@
|
||||
#include "core/namespaces.h"
|
||||
// insert.cpp — REAPER-facing placement shell (M6). See insert.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are
|
||||
// extern (CLAUDE.md §contract).
|
||||
//
|
||||
// THIS IS THE INTENDED PLACEMENT PATH. Unlike capture / bank_panel (which never
|
||||
// touch the arrange), insert deliberately adds items to the arrange — that is its
|
||||
// whole job (CONTEXT.md §load-bearing principle). It runs ONLY from its own action.
|
||||
//
|
||||
// FLAGGED RUNTIME ASSUMPTIONS (semantics the header does not fully specify — must
|
||||
// be DAW-verified by Daniel post-merge; see the handoff):
|
||||
// A. InsertMedia base mode 0 ("add to current track") targets the track that is
|
||||
// currently the ONLY selected track. The header names the base target but does
|
||||
// not spell out how "current track" resolves at runtime. We force exactly one
|
||||
// selected track via SetOnlyTrackSelected before each InsertMedia call, which
|
||||
// is the most defensible interpretation; if REAPER uses a different notion of
|
||||
// "current" (e.g. last-focused, not last-selected), DAW-verify and adjust.
|
||||
// B. InsertMedia mode 0 inserts AT THE EDIT CURSOR. Placement at the edit cursor
|
||||
// is REAPER's documented convention for base modes 0/1 (the header does not
|
||||
// spell out an explicit "at edit cursor" bit). Flagged for DAW-verification.
|
||||
// C. InsertMedia ADVANCES the edit cursor to the end of the inserted media. We
|
||||
// reset the cursor to the snapshot position before EACH track's insert, so
|
||||
// assumption C's truth or falsity is irrelevant: we own the cursor reset.
|
||||
// D. SetEditCurPos(time, false, false) moves the cursor without scrolling the view
|
||||
// and without seeking the transport. The header lists the args as
|
||||
// (time, moveview, seekplay) — moveview=false and seekplay=false are the
|
||||
// non-disruptive choice; flagged in case the DAW shows otherwise.
|
||||
// E. SetOnlyTrackSelected deselects all tracks and selects exactly one. The header
|
||||
// doc-comment says "Set exactly one track selected, deselect all others" —
|
||||
// this is the strongest confirmation we have; flagged for DAW-verification.
|
||||
|
||||
#include "shell/capture/insert.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_model.h"
|
||||
#include "bank_panel.h"
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "persist.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetCursorPosition
|
||||
#define REAPERAPI_WANT_GetSelectedTrack
|
||||
#define REAPERAPI_WANT_InsertMedia
|
||||
#define REAPERAPI_WANT_SetEditCurPos
|
||||
#define REAPERAPI_WANT_SetOnlyTrackSelected
|
||||
#define REAPERAPI_WANT_SetTrackSelected
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// The current project's directory (mirrors bank_panel/capture/persist). The bank
|
||||
// index stores relative paths; resolving a bank file needs the current .rpp dir.
|
||||
// FOLLOW-UP (already noted in bank_panel.cpp): a shared "current project dir"
|
||||
// REAPER helper is a clean small refactor now that a fourth consumer exists — out
|
||||
// of scope for M6.
|
||||
std::string currentProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
std::string rpp(buf.data());
|
||||
if (rpp.empty()) return {}; // unsaved project: no resolvable bank
|
||||
return normalizeSlashes(fs::path(rpp).parent_path().string());
|
||||
}
|
||||
|
||||
// Snapshot the user's currently-selected track set (ignores master, matches
|
||||
// CountSelectedTracks / GetSelectedTrack which both skip master). Returns the
|
||||
// tracks in selection order so we can restore the original state afterward.
|
||||
std::vector<MediaTrack*> snapshotSelectedTracks() {
|
||||
const int n = CountSelectedTracks(nullptr); // nullptr = active project
|
||||
std::vector<MediaTrack*> tracks;
|
||||
tracks.reserve(static_cast<size_t>(n));
|
||||
for (int i = 0; i < n; ++i)
|
||||
tracks.push_back(GetSelectedTrack(nullptr, i));
|
||||
return tracks;
|
||||
}
|
||||
|
||||
// Restore a previously-snapshotted track selection: deselect all (by setting the
|
||||
// first track alone) then re-select the full set. If the snapshot is empty we
|
||||
// leave all tracks deselected; no-op guard handles a completely empty project.
|
||||
void restoreSelectedTracks(const std::vector<MediaTrack*>& tracks) {
|
||||
if (tracks.empty()) return;
|
||||
// Deselect all via the first track, then re-add the rest.
|
||||
SetOnlyTrackSelected(tracks[0]);
|
||||
for (size_t i = 1; i < tracks.size(); ++i)
|
||||
SetTrackSelected(tracks[i], true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) {
|
||||
InsertResult result;
|
||||
if (!session) { result.status = InsertStatus::NoSelection; return result; }
|
||||
|
||||
// WHO to target: the user's currently-selected track set. No-op (with a clear
|
||||
// console message) when nothing is selected — inserting without a target track
|
||||
// would create an unintended new track or behave unpredictably.
|
||||
const std::vector<MediaTrack*> selectedTracks = snapshotSelectedTracks();
|
||||
if (selectedTracks.empty()) {
|
||||
ShowConsoleMsg("ReaSampler insert: select a track first.\n");
|
||||
result.status = InsertStatus::NoSelection;
|
||||
return result;
|
||||
}
|
||||
|
||||
// WHAT to place: the single focused sample from the panel. Multi-select is
|
||||
// deprioritized; take the first (or only) selected id. An empty panel selection
|
||||
// is a no-op — nothing to place.
|
||||
const std::vector<std::string> ids = bankPanelSelectedSampleIds();
|
||||
if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; }
|
||||
const std::string& id = ids.front(); // focused / first selected — single sample
|
||||
|
||||
// WHERE the bank lives on disk. An unsaved project has no resolvable bank dir;
|
||||
// insert is a no-op rather than resolving against CWD (CLAUDE.md invariant).
|
||||
const std::string projectDir = currentProjectDir();
|
||||
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
|
||||
|
||||
// Resolve the id against the bank the SELECTION came from — under B4's vertical
|
||||
// split the selection may live in the pool or a shown named bank, which is NOT
|
||||
// necessarily the active/capture-target bank. Fall back to the active bank when
|
||||
// the source id names no bank (defensive).
|
||||
const std::string srcBankId = bankPanelSelectedSourceBankId();
|
||||
const BankModel* srcIndex = session->book().index(srcBankId);
|
||||
const BankModel& bank = srcIndex ? *srcIndex : session->bank();
|
||||
const Sample* sample = bank.query(id);
|
||||
if (!sample) { result.status = InsertStatus::NothingResolved; return result; }
|
||||
|
||||
const std::string abs = resolveBankFile(projectDir, sample->relativePath);
|
||||
if (abs.empty() || !fs::exists(fs::path(abs))) {
|
||||
result.status = InsertStatus::NothingResolved;
|
||||
return result;
|
||||
}
|
||||
|
||||
const int mode = computeInsertMode(request.options);
|
||||
|
||||
// Snapshot the edit cursor position up front so we can restore it to the same
|
||||
// position for each track insert (and after the whole operation).
|
||||
const double cursorPos = GetCursorPosition();
|
||||
|
||||
// Wrap the whole placement (all tracks + selection/cursor save-restore) in ONE
|
||||
// undo block so a single undo removes every item and restores the state before
|
||||
// the action. Opened before the first InsertMedia, closed after the restore,
|
||||
// unconditionally — the block is always balanced.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
|
||||
// Insert onto EACH selected track at the SAME edit-cursor position (assumption B).
|
||||
// For each track: isolate it as the only selection so InsertMedia mode 0 targets
|
||||
// it unambiguously (assumption A + E), reset the cursor to the snapshot position
|
||||
// (assumption C cursor advance is irrelevant — we own the reset), then insert.
|
||||
for (MediaTrack* track : selectedTracks) {
|
||||
SetOnlyTrackSelected(track); // assumption A + E
|
||||
SetEditCurPos(cursorPos, false, false); // assumption D
|
||||
InsertMedia(abs.c_str(), mode);
|
||||
++result.inserted;
|
||||
}
|
||||
|
||||
// Restore the user's original track selection and cursor position so the action
|
||||
// is non-destructive to their DAW state (non-negotiable per the brief).
|
||||
restoreSelectedTracks(selectedTracks);
|
||||
SetEditCurPos(cursorPos, false, false);
|
||||
|
||||
// Label reflects the count and the conform choice so the undo history reads
|
||||
// clearly ("ReaSampler: insert on 2 tracks" etc.). extraflags -1 = UNDO_STATE_ALL
|
||||
// (superset: tracks, items, envelope points, project state).
|
||||
const std::string label =
|
||||
"ReaSampler: insert on " + std::to_string(result.inserted) +
|
||||
(result.inserted == 1 ? " track" : " tracks") +
|
||||
(request.options.conform == TempoConform::None ? "" : " (conform)");
|
||||
Undo_EndBlock2(nullptr, label.c_str(), -1);
|
||||
|
||||
result.status = InsertStatus::Ok;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,63 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// insert — placement of bank samples into the arrange (M6). REAPER-facing shell:
|
||||
// it reads the bank_panel's current selection, resolves each selected sample's
|
||||
// file, and drops it into the arrange at the edit cursor via InsertMedia, wrapped
|
||||
// in an undo block.
|
||||
//
|
||||
// THE INTENDED PLACEMENT PATH (CONTEXT.md §load-bearing principle): capture NEVER
|
||||
// auto-inserts; `insert` is the deliberate, user-invoked placement act, so it IS
|
||||
// allowed and expected to add items to the arrange. It must only ever run from its
|
||||
// own action — never from a capture path.
|
||||
//
|
||||
// Non-destructive to the bank: insert references the bank file (adds an arrange
|
||||
// item pointing at it); it never modifies the bank, the bank files, or ext state.
|
||||
// No SILENT time-stretch: conform-to-tempo is an explicit opt-in on the request,
|
||||
// defaulting OFF (native length). See insert_plan for the mode-bit computation.
|
||||
//
|
||||
// The header is SDK-free: all REAPER API use lives in insert.cpp. The pure
|
||||
// mode-bit arithmetic lives in insert_plan (unit-tested outside the DAW).
|
||||
|
||||
#include "core/capture/insert_plan.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// What one insert action does. Carries the InsertMedia options (target track +
|
||||
// tempo-conform choice) so the two action variants (native-length vs
|
||||
// conform-to-tempo) differ only by this struct — no divergent code paths.
|
||||
struct InsertRequest {
|
||||
InsertOptions options; // defaults: current track, no conform, native length
|
||||
};
|
||||
|
||||
// The outcome of an insert action, for the caller to log to the console.
|
||||
enum class InsertStatus {
|
||||
Ok, // one or more samples inserted
|
||||
NoSelection, // the panel had no selection — a no-op (not an error)
|
||||
NoProject, // no saved project, so no resolvable bank dir — no-op
|
||||
NothingResolved, // a selection existed but no sample resolved to a file
|
||||
};
|
||||
|
||||
struct InsertResult {
|
||||
InsertStatus status = InsertStatus::NoSelection;
|
||||
int inserted = 0; // how many samples were actually placed
|
||||
int skipped = 0; // selected-but-unresolvable/unreadable samples skipped
|
||||
};
|
||||
|
||||
// Runs the insert: reads the bank panel's single focused sample and the user's
|
||||
// currently-selected track set, then inserts the sample onto EACH selected track
|
||||
// at the SAME edit-cursor position. Snapshot/restore ensures the user's track
|
||||
// selection and cursor position are unchanged after the action. The whole operation
|
||||
// is wrapped in a single Undo_BeginBlock2 / Undo_EndBlock2.
|
||||
//
|
||||
// No-op cases (with console messages):
|
||||
// - No track selected: prints "select a track first."
|
||||
// - No sample selected in the panel: NoSelection status.
|
||||
// - Unsaved project (no resolvable bank dir): NoProject status.
|
||||
// - Sample id not in bank / file missing: NothingResolved status.
|
||||
//
|
||||
// `session` supplies the live bank the selected id resolves against.
|
||||
InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "core/namespaces.h"
|
||||
// item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See
|
||||
// item_read.h. Compiled into the reaper_reasampler MODULE; includes
|
||||
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU that
|
||||
// defines the API pointers — CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/item_read.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetSetMediaItemInfo_String
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string itemGuid(MediaItem* it) {
|
||||
char buf[64] = {0};
|
||||
if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {};
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
std::string itemLaneName(MediaTrack* tr, MediaItem* it) {
|
||||
const int laneIdx = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
|
||||
char parm[32];
|
||||
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
|
||||
char buf[512] = {0};
|
||||
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,35 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// item_read — the ONE place a MediaItem* is read for its canonical GUID string and for
|
||||
// the durable P_LANENAME of the fixed lane it sits on. Before this seam, view.cpp and
|
||||
// bank_panel.cpp each carried a near-identical private itemGuid / itemLaneName pair
|
||||
// (both files' comments acknowledged the deliberate copy); the D2 Wave-3-B item actions
|
||||
// need the same two reads, so the duplication is extracted here — the item-read analog
|
||||
// of track_guid's single MediaTrack* -> GUID-key formatter.
|
||||
//
|
||||
// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
|
||||
// CLAUDE.md §contract). MediaItem / MediaTrack are forward-declared so this header
|
||||
// stays SDK-lite. These are shell reads (REAPER string/value getters); the managed/
|
||||
// manual DECISION that consumes the lane name stays pure in lane_keys (isOnManualLane).
|
||||
|
||||
#include <string>
|
||||
|
||||
class MediaItem;
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on a
|
||||
// read failure (an empty GUID must never be tagged — every caller skips empties).
|
||||
std::string itemGuid(MediaItem* it);
|
||||
|
||||
// The durable P_LANENAME of the fixed lane item `it` currently sits on (read via the
|
||||
// item's I_FIXEDLANE ordinal, then P_LANENAME:n on `tr`). Empty if the lane is unnamed
|
||||
// or the param is unavailable. Callers must already know `tr` is a fixed-lane track
|
||||
// (I_FREEMODE==2) before calling — I_FIXEDLANE is meaningless otherwise; the pure
|
||||
// isOnManualLane predicate handles the non-fixed-lane case via its own argument, so
|
||||
// callers should not call this at all for a normal track.
|
||||
std::string itemLaneName(MediaTrack* tr, MediaItem* it);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,183 @@
|
||||
#include "core/namespaces.h"
|
||||
// provenance_shell.cpp — the REAPER reads behind Milestone 10. See provenance_shell.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers
|
||||
// (CLAUDE.md §contract). Every REAPER symbol used here is verified against
|
||||
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||
// * TrackFX_GetCount(MediaTrack*) (~7283)
|
||||
// * TrackFX_GetFXName(MediaTrack*, int, char*, int) -> bool (~7356)
|
||||
// * TrackFX_GetFXGUID(MediaTrack*, int) -> GUID* (~7348)
|
||||
// * TrackFX_GetEnabled(MediaTrack*, int) -> bool (~7291)
|
||||
// * TakeFX_GetCount(MediaItem_Take*) (~6710)
|
||||
// * TakeFX_GetFXName(MediaItem_Take*, int, char*, int) -> bool (~6758)
|
||||
// * TakeFX_GetFXGUID(MediaItem_Take*, int) -> GUID* (~6750)
|
||||
// * TakeFX_GetEnabled(MediaItem_Take*, int) -> bool (~6718)
|
||||
// * CountSelectedMediaItems / GetSelectedMediaItem (selection reads)
|
||||
// * GetActiveTake(MediaItem*) -> MediaItem_Take* (active take)
|
||||
// * GetMediaItemTake_Source(MediaItem_Take*) -> PCM_source* (~2053)
|
||||
// * GetMediaSourceFileName(PCM_source*, char*, int) (~2141)
|
||||
// * CountTracks / GetTrack (track scan)
|
||||
// * guidToString (via track_guid)
|
||||
|
||||
#include "shell/capture/provenance_shell.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_book.h" // BankBook, Bank, BankModel::all
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile, normalizeSlashes
|
||||
#include "shell/capture/track_guid.h" // guidString — the ONE canonical GUID key formatter
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||
#define REAPERAPI_WANT_TrackFX_GetFXName
|
||||
#define REAPERAPI_WANT_TrackFX_GetFXGUID
|
||||
#define REAPERAPI_WANT_TrackFX_GetEnabled
|
||||
#define REAPERAPI_WANT_TakeFX_GetCount
|
||||
#define REAPERAPI_WANT_TakeFX_GetFXName
|
||||
#define REAPERAPI_WANT_TakeFX_GetFXGUID
|
||||
#define REAPERAPI_WANT_TakeFX_GetEnabled
|
||||
#define REAPERAPI_WANT_CountSelectedMediaItems
|
||||
#define REAPERAPI_WANT_GetSelectedMediaItem
|
||||
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
#define REAPERAPI_WANT_GetActiveTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string fxChainIdentityForTrack(MediaTrack* tr) {
|
||||
if (!tr) return fxChainIdentity({});
|
||||
std::vector<FxIdentityEntry> rows;
|
||||
const int n = TrackFX_GetCount(tr);
|
||||
rows.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
FxIdentityEntry e;
|
||||
char nameBuf[512] = {0};
|
||||
if (TrackFX_GetFXName(tr, i, nameBuf, static_cast<int>(sizeof(nameBuf))))
|
||||
e.name = nameBuf;
|
||||
// Per-instance GUID: the stable identity of THIS FX in the chain, so swapping
|
||||
// one FX for another of the same name registers as drift. guidToString needs a
|
||||
// >=64-char destination (SDK contract).
|
||||
if (GUID* g = TrackFX_GetFXGUID(tr, i)) {
|
||||
char gb[64] = {0};
|
||||
guidToString(g, gb);
|
||||
e.guid = gb;
|
||||
}
|
||||
e.enabled = TrackFX_GetEnabled(tr, i);
|
||||
rows.push_back(std::move(e));
|
||||
}
|
||||
return fxChainIdentity(rows);
|
||||
}
|
||||
|
||||
std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items) {
|
||||
// For Item scope the in-scope chain is each item's active take's FX chain, NOT
|
||||
// the owning track's FX chain (the track chain is out-of-scope and is bypassed
|
||||
// during render). TakeFX_* is the correct family here.
|
||||
std::vector<std::string> perItem;
|
||||
perItem.reserve(items.size());
|
||||
for (MediaItem* it : items) {
|
||||
if (!it) { perItem.push_back(fxChainIdentity({})); continue; }
|
||||
MediaItem_Take* take = GetActiveTake(it);
|
||||
if (!take) { perItem.push_back(fxChainIdentity({})); continue; }
|
||||
std::vector<FxIdentityEntry> rows;
|
||||
const int n = TakeFX_GetCount(take);
|
||||
rows.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
FxIdentityEntry e;
|
||||
char nameBuf[512] = {0};
|
||||
if (TakeFX_GetFXName(take, i, nameBuf, static_cast<int>(sizeof(nameBuf))))
|
||||
e.name = nameBuf;
|
||||
if (GUID* g = TakeFX_GetFXGUID(take, i)) {
|
||||
char gb[64] = {0};
|
||||
guidToString(g, gb);
|
||||
e.guid = gb;
|
||||
}
|
||||
e.enabled = TakeFX_GetEnabled(take, i);
|
||||
rows.push_back(std::move(e));
|
||||
}
|
||||
perItem.push_back(fxChainIdentity(rows));
|
||||
}
|
||||
return combineChainIdentities(perItem);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The active take source file of one item, normalized. Empty if unresolvable.
|
||||
std::string itemSourceFile(MediaItem* it) {
|
||||
if (!it) return {};
|
||||
MediaItem_Take* take = GetActiveTake(it);
|
||||
if (!take) return {}; // empty (MIDI-less?) / no active take -> unresolvable
|
||||
PCM_source* src = GetMediaItemTake_Source(take);
|
||||
if (!src) return {};
|
||||
char buf[4096] = {0};
|
||||
GetMediaSourceFileName(src, buf, static_cast<int>(sizeof(buf)));
|
||||
return normalizeSlashes(std::string(buf));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> selectedItemSourceFiles() {
|
||||
std::vector<std::string> files;
|
||||
const int n = CountSelectedMediaItems(nullptr); // nullptr = active project
|
||||
for (int i = 0; i < n; ++i) {
|
||||
std::string f = itemSourceFile(GetSelectedMediaItem(nullptr, i));
|
||||
if (!f.empty()) files.push_back(std::move(f)); // omit unresolvable (never empty)
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
std::vector<std::string> trackItemSourceFiles(const std::vector<MediaTrack*>& tracks,
|
||||
double startSeconds, double endSeconds) {
|
||||
std::vector<std::string> files;
|
||||
for (MediaTrack* tr : tracks) {
|
||||
if (!tr) continue;
|
||||
const int n = CountTrackMediaItems(tr);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaItem* it = GetTrackMediaItem(tr, i);
|
||||
if (!it) continue;
|
||||
const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
|
||||
const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
|
||||
// Positive overlap with the capture range (a zero-length touch is not an
|
||||
// overlap): item [pos, pos+len) intersects [startSeconds, endSeconds).
|
||||
if (pos < endSeconds && (pos + len) > startSeconds) {
|
||||
std::string f = itemSourceFile(it);
|
||||
if (!f.empty()) files.push_back(std::move(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
std::vector<BankFileRef> bankFileRefs(const BankBook& book, const std::string& projectDir) {
|
||||
std::vector<BankFileRef> refs;
|
||||
for (const Bank& b : book.banks()) {
|
||||
for (const Sample& s : b.index.all()) {
|
||||
BankFileRef ref;
|
||||
ref.sampleId = s.id;
|
||||
// Resolve to the same normalized absolute form selectedItemSourceFiles
|
||||
// produces, so detectParent compares like-for-like. Empty projectDir /
|
||||
// relativePath -> empty absolutePath (never a false match).
|
||||
ref.absolutePath = normalizeSlashes(resolveBankFile(projectDir, s.relativePath));
|
||||
refs.push_back(std::move(ref));
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
MediaTrack* trackByGuid(const std::string& guid) {
|
||||
if (guid.empty()) return nullptr;
|
||||
const int n = CountTracks(nullptr); // nullptr = active project; excludes master
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaTrack* tr = GetTrack(nullptr, i);
|
||||
if (!tr) continue;
|
||||
if (guidString(tr) == guid) return tr;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,78 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// provenance_shell — the REAPER-facing reads Milestone 10 needs, in one place.
|
||||
//
|
||||
// The PURE provenance module (provenance.h) owns the fingerprint encoding, the
|
||||
// recipe model, the FX-identity fold, and the parent-detection DECISION — all over
|
||||
// plain strings/values. This shell gathers those strings/values FROM REAPER:
|
||||
// * the in-scope FX-chain identity of a source track (name/GUID/enabled rows),
|
||||
// * the media-file paths of a resolved capture's source items,
|
||||
// * the active book's bank samples resolved to absolute file paths,
|
||||
// * a canonical track-GUID string back to a live MediaTrack*.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
|
||||
// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays
|
||||
// SDK-lite. It depends on the pure provenance module (FxIdentityEntry / recipe /
|
||||
// BankFileRef) and bank_book (to enumerate the active book's samples).
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/provenance.h"
|
||||
|
||||
class MediaTrack;
|
||||
class MediaItem;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class BankBook;
|
||||
|
||||
// The in-scope FX-chain identity of a source track (Track scope), folded to the
|
||||
// pure provenance string. Reads the track's own FX chain via TrackFX_GetCount /
|
||||
// TrackFX_GetFXName / TrackFX_GetFXGUID / TrackFX_GetEnabled in chain order.
|
||||
std::string fxChainIdentityForTrack(MediaTrack* tr);
|
||||
|
||||
// The in-scope FX-chain identity for Item scope: enumerates each item's active
|
||||
// take FX chain via TakeFX_GetCount / TakeFX_GetFXName / TakeFX_GetFXGUID /
|
||||
// TakeFX_GetEnabled, in item order then FX order, combined with
|
||||
// combineChainIdentities so distinct per-item partitions never collide. Returns
|
||||
// the combined identity string (empty combined identity for a no-FX or no-item
|
||||
// set). The items vector is the same source-item set the shell collected for the
|
||||
// item-scope capture (selected items whose owning tracks were also collected).
|
||||
std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items);
|
||||
|
||||
// Reads the media-file path of every SELECTED media item's active take source
|
||||
// (GetMediaItemTake_Source -> GetMediaSourceFileName), normalized to forward-slash.
|
||||
// Unresolvable items (no take / no source / empty name) are omitted — never an
|
||||
// empty string in the result, so detectParent's "not in bank" branch is honest.
|
||||
// The active-project selection is read directly (mirrors main.cpp's collectors).
|
||||
// This is the ITEM-scope source set (the user selected the items being resampled).
|
||||
std::vector<std::string> selectedItemSourceFiles();
|
||||
|
||||
// The TRACK-scope source set: the media-file paths of the items ON `tracks` that
|
||||
// OVERLAP the capture range [startSeconds, endSeconds). For a track capture the user
|
||||
// selects the track, not the item, so the "what audio is being captured" set is the
|
||||
// range-overlapping items on the source tracks. Same normalize + omit-unresolvable
|
||||
// contract as selectedItemSourceFiles. An item overlaps iff its [pos, pos+len)
|
||||
// intersects the range with positive overlap (a zero-length touch does not count).
|
||||
std::vector<std::string> trackItemSourceFiles(const std::vector<MediaTrack*>& tracks,
|
||||
double startSeconds, double endSeconds);
|
||||
|
||||
// Enumerates the ACTIVE book's samples across every bank (pool + named) as pure
|
||||
// BankFileRefs — each sample id paired with its file resolved to a normalized
|
||||
// ABSOLUTE path against `projectDir` (resolveBankFile + normalizeSlashes). A sample
|
||||
// whose path cannot be resolved (empty projectDir / empty relativePath) is emitted
|
||||
// with an empty absolutePath, which detectParent never matches. `projectDir` is the
|
||||
// current .rpp parent (the shell resolves it; empty -> all refs unresolved).
|
||||
std::vector<BankFileRef> bankFileRefs(const BankBook& book, const std::string& projectDir);
|
||||
|
||||
// Resolves a canonical track-GUID string (guidString form) to a live MediaTrack*
|
||||
// in the active project by scanning tracks and comparing guidString(tr). Returns
|
||||
// nullptr when no live track carries that GUID (the source track was deleted since
|
||||
// capture — a re-capture failure mode the caller reports). The master track is not
|
||||
// scanned (it has no membership GUID and is never a capture source).
|
||||
MediaTrack* trackByGuid(const std::string& guid);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,25 @@
|
||||
#include "core/namespaces.h"
|
||||
// track_guid.cpp — the single MediaTrack* -> canonical GUID key formatter. See
|
||||
// track_guid.h. Compiled into the reaper_reasampler MODULE; includes
|
||||
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU
|
||||
// that defines the API pointers — CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/track_guid.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetTrackGUID
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string guidString(MediaTrack* tr) {
|
||||
if (!tr) return {};
|
||||
GUID* g = GetTrackGUID(tr);
|
||||
if (!g) return {};
|
||||
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
|
||||
guidToString(g, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,25 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID
|
||||
// string used as a membership-index key. Both the Design View shell (view.cpp) and
|
||||
// the actions layer (actions.cpp) key membership on this exact string, so the key
|
||||
// contract lives in a single helper rather than being re-derived (and drifting) at
|
||||
// two call sites (the cross-module key contract flagged in D2 review).
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
|
||||
// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays SDK-lite.
|
||||
|
||||
#include <string>
|
||||
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// REAPER's canonical "{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}" form of a track's
|
||||
// GUID (GetTrackGUID -> guidToString). Empty string if `tr` has no GUID. This IS
|
||||
// the membership-index key format — it must match guidToString's braces exactly so
|
||||
// the view tree keys and the model/actions keys align.
|
||||
std::string guidString(MediaTrack* tr);
|
||||
|
||||
} // namespace reasampler
|
||||
Reference in New Issue
Block a user