feat(capture): bindable capture family — master/tracks/items/razor (M7)

Four wet capture actions route to OfflineRenderBackend (snapshot/restore
RENDER_*, 32-bit float), produce a Sample, add to the bank, persist. Pure
render_settings maps source mode to RENDER_SETTINGS and parses P_RAZOREDITS
(tested). No arrange insertion. True dry deferred to M10.
This commit is contained in:
2026-07-23 10:01:34 -04:00
parent 4f1231ea81
commit 7b530193b2
7 changed files with 679 additions and 60 deletions
+33 -18
View File
@@ -4,10 +4,19 @@
// 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).
//
// Scope (M3): ONE source mode — the time-selection master mix. Drives the
// RENDER_* project settings via GetSetProjectInfo / _String, 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).
// Scope (M7): the full offline source family — master mix / time selection,
// selected tracks, selected items, razor area — all wet-only with optional tail.
// 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
@@ -30,6 +39,7 @@
#include <vector>
#include "capture_paths.h"
#include "render_settings.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -61,11 +71,6 @@ constexpr int kActionRenderUsingMostRecentSettings = 42230;
// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042.
constexpr double kBoundsCustom = 0.0;
// RENDER_SETTINGS master-mix bit pattern. Per the SDK header (line ~3041):
// (&(1|2))==0 => master mix, &8=use render matrix. We want plain master mix:
// no stems (bits 1|2 clear), no render matrix. Value 0 = master mix, no matrix.
constexpr double kRenderSettingsMasterMix = 0.0;
// RENDER_TAILFLAG bit &1 = apply tail for custom time bounds. We clear it for
// the spike (exact bounds, no added silence — precision invariant).
constexpr double kTailFlagNone = 0.0;
@@ -226,13 +231,16 @@ std::string makeUniqueTag() {
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
CaptureResult result;
// M3 implements only the master-mix / time-selection case. Both mean "render
// the master mix over the requested bounds".
if (request.sourceMode != SourceMode::MasterMix &&
request.sourceMode != SourceMode::TimeSelection) {
// 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 (M3) supports only master-mix / "
"time-selection capture.";
result.message = "OfflineRenderBackend does not render this source mode "
"(realtime capture is the M8 backend).";
return result;
}
@@ -350,8 +358,11 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, true);
}
// Master mix, no stems, no render matrix.
GetSetProjectInfo(proj, "RENDER_SETTINGS", kRenderSettingsMasterMix, 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
@@ -448,6 +459,10 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// 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
@@ -465,7 +480,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
result.status = CaptureStatus::Ok;
result.sample = s;
result.message = "Captured master mix [" +
result.message = "Captured [" +
std::to_string(request.startSeconds) + "s, " +
std::to_string(request.endSeconds) + "s] -> " +
paths.relativePath;
+19 -5
View File
@@ -14,6 +14,7 @@
// without dragging the SDK into every include site.
#include <string>
#include <vector>
#include "bank_model.h"
@@ -40,10 +41,20 @@ struct CaptureRequest {
double startSeconds = 0.0;
double endSeconds = 0.0;
// 1.0 = fully wet, 0.0 = fully dry. M3 renders the wet master mix (1.0);
// dry/partial routing is M7. Carried now so the Sample records it.
// 1.0 = fully wet, 0.0 = fully dry. All M7 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. Default OFF for the spike (exact bounds, no added silence —
// precision invariant). M7 makes this bindable.
bool renderTail = false;
@@ -90,9 +101,12 @@ public:
virtual CaptureResult capture(const CaptureRequest& request) = 0;
};
// Deterministic offline-render backend. M3 implements ONLY the
// TimeSelection / MasterMix case (both map to "render the master mix over the
// requested bounds"); any other source mode returns UnsupportedMode.
// Deterministic offline-render backend. M7 implements 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 M8).
class OfflineRenderBackend : public ICaptureBackend {
public:
CaptureResult capture(const CaptureRequest& request) override;
+216 -36
View File
@@ -20,12 +20,16 @@
#include <string>
#include <vector>
#include "actions.h"
#include "bank_model.h"
#include "bank_panel.h"
#include "capture.h"
#include "insert.h"
#include "persist.h"
#include "render_settings.h"
#include "track_guid.h"
#include "view.h"
// Persistent action-id prefix for the ReaSampler action family.
@@ -39,14 +43,18 @@
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
// ---- Action registration seam (M3 spike: capture master mix) ---------------
// First live use of the action pattern the seam left templated. The full capture
// action family (selected tracks/items/razor, wet/dry, tail) is M7; this is ONE
// temporary action driving the M3 offline-render spike.
// Command id for "ReaSampler: capture master mix (spike)". FOREVER-STABLE string
// (user keybindings key off it) — see the prefix note above.
static int g_cmdCaptureMasterSpike = 0;
// ---- M7 capture action family ----------------------------------------------
// Four wet-only bindable actions from captureActionTable() (render_settings, pure):
// master mix, selected tracks, selected items, razor area — all wet (post-FX).
// Tail is OFF for every row (exact bounds); a tail-on variant is a later opt-in
// (YAGNI). The M3 "capture master mix (spike)" action is RETIRED and replaced by
// this family. Dry variants are deferred to M10 (null-test work).
//
// The minted command ids parallel the table rows 1:1 (same index). gaccel storage
// must outlive registration (REAPER holds each pointer), so both vectors are file-
// scope and sized to the table. FOREVER-STABLE id strings live in the table.
static std::vector<int> g_captureCmdIds;
static std::vector<gaccel_register_t> g_captureAccels;
// Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string.
// The docked grid window is display-only this wave (Wave A) — the action just
@@ -92,24 +100,169 @@ static void OnTimer()
reasampler::bankPanelRefresh();
}
// Runs the M3 spike: render the time-selection master mix, add the Sample, log.
static void RunCaptureMasterSpike()
// --- M7 source resolvers ----------------------------------------------------
// Each resolves a source mode to (1) the exact render range in project seconds and
// (2) the track GUIDs, when track-scoped. They ONLY READ DAW state (selection, time
// selection, razor strings) — they never mutate it (non-destructive). Returning
// false means "nothing to capture" (empty selection / no razor / empty range); the
// caller reports it and writes nothing.
// The resolved source: exact bounds + optional track GUIDs.
struct ResolvedSource
{
// Time selection -> exact render bounds (no rounding). GetSet_LoopTimeRange
// with isSet=false reads the current time selection (isLoop=false).
double start = 0.0, end = 0.0;
double startSeconds = 0.0;
double endSeconds = 0.0;
std::vector<std::string> trackGuids; // populated only for SelectedTracks
};
// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false,
// isLoop=false) reads the current time selection. Used by master mix (the range is
// the time selection) and as the time window for selected-track captures.
static bool resolveTimeSelection(double& start, double& end)
{
start = 0.0; end = 0.0;
GetSet_LoopTimeRange(false, false, &start, &end, false);
return end > start;
}
// Master mix / time selection: bounds = the time selection; no track GUIDs.
static bool resolveMaster(ResolvedSource& out)
{
return resolveTimeSelection(out.startSeconds, out.endSeconds);
}
// Selected tracks: the render time window is the time selection (RENDER_SETTINGS
// selects WHICH tracks; the custom bounds select the WHEN). We also collect the
// selected tracks' GUIDs for the Sample's provenance. Requires both a non-empty
// track selection AND a time selection (the bounds come from the latter).
static bool resolveSelectedTracks(ResolvedSource& out)
{
const int n = CountSelectedTracks(nullptr); // nullptr = active project
if (n <= 0) return false;
for (int i = 0; i < n; ++i)
{
MediaTrack* tr = GetSelectedTrack(nullptr, i);
if (!tr) continue;
std::string g = reasampler::guidString(tr);
if (!g.empty()) out.trackGuids.push_back(std::move(g));
}
return resolveTimeSelection(out.startSeconds, out.endSeconds);
}
// Selected items: bounds = the union [min position, max position+length] across
// the selected items (D_POSITION / D_LENGTH — SDK header ~1990/1991). Exact, no
// rounding. RENDER_SETTINGS selects the items; the bounds keep the render window
// tight around them.
static bool resolveSelectedItems(ResolvedSource& out)
{
const int n = CountSelectedMediaItems(nullptr);
if (n <= 0) return false;
bool any = false;
double lo = 0.0, hi = 0.0;
for (int i = 0; i < n; ++i)
{
MediaItem* it = GetSelectedMediaItem(nullptr, i);
if (!it) continue;
const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
const double end = pos + len;
if (!any) { lo = pos; hi = end; any = true; }
else { if (pos < lo) lo = pos; if (end > hi) hi = end; }
}
if (!any) return false;
out.startSeconds = lo;
out.endSeconds = hi;
return out.endSeconds > out.startSeconds;
}
// Razor area: razor edits live PER TRACK (P_RAZOREDITS — SDK header ~2899:
// space-separated triples of start, end, envGuidString). We read every track's
// razor string, parse the track-audio areas (pure parseRazorEdits), and take the
// union bound as the render window. RENDER_SETTINGS&4096 selects the razor content;
// the bounds keep the window tight. Reads only — never clears the razor selection.
static bool resolveRazorArea(ResolvedSource& out)
{
std::vector<reasampler::RazorRange> allRanges;
const int n = CountTracks(nullptr);
for (int i = 0; i < n; ++i)
{
MediaTrack* tr = GetTrack(nullptr, i);
if (!tr) continue;
// GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf, false) reads the
// razor string into buf. Big buffer: many areas can accumulate.
std::vector<char> buf(8192, '\0');
if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
continue;
std::vector<reasampler::RazorRange> ranges =
reasampler::parseRazorEdits(std::string(buf.data()));
for (auto& r : ranges) allRanges.push_back(r);
}
if (allRanges.empty()) return false;
reasampler::RazorRange u = reasampler::razorUnionBounds(allRanges);
out.startSeconds = u.startSeconds;
out.endSeconds = u.endSeconds;
return out.endSeconds > out.startSeconds;
}
// Dispatches to the right resolver for a source mode. Returns false with a reason
// in `why` when there is nothing to capture (so the action can log precisely).
static bool ResolveSource(reasampler::SourceMode mode, ResolvedSource& out,
std::string& why)
{
using reasampler::SourceMode;
switch (mode)
{
case SourceMode::MasterMix:
case SourceMode::TimeSelection:
if (resolveMaster(out)) return true;
why = "no time selection (make a time selection first)";
return false;
case SourceMode::SelectedTracks:
if (resolveSelectedTracks(out)) return true;
why = "select at least one track AND make a time selection";
return false;
case SourceMode::SelectedItems:
if (resolveSelectedItems(out)) return true;
why = "select at least one media item";
return false;
case SourceMode::RazorArea:
if (resolveRazorArea(out)) return true;
why = "no razor edit area found on any track";
return false;
case SourceMode::Realtime:
why = "realtime capture is the M8 backend, not offline render";
return false;
}
why = "unknown source mode";
return false;
}
// Runs one capture-action-table row: resolve its source, build a CaptureRequest,
// hand it to the offline backend, add the Sample to the bank, persist + mark dirty.
// The load-bearing principle holds structurally — this path writes a file + a bank
// index entry ONLY; it never calls InsertMedia or touches the arrange/timeline.
static void RunCapture(const reasampler::CaptureActionDef& def)
{
ResolvedSource src;
std::string why;
if (!ResolveSource(def.sourceMode, src, why))
{
ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
return;
}
reasampler::CaptureRequest req;
req.sourceMode = reasampler::SourceMode::TimeSelection;
req.startSeconds = start;
req.endSeconds = end;
req.wetDry = 1.0; // wet master mix
req.renderTail = false; // exact bounds, no tail
req.sampleRate = 0; // follow project rate
req.sourceMode = def.sourceMode;
req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = src.endSeconds;
req.wetDry = def.wetDry; // 1.0 wet (all M7 actions are wet-only)
req.renderTail = false; // exact bounds, no tail (M7 default)
req.tailMs = 0.0;
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither
req.baseName = "master_mix";
req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither
req.baseName = def.baseName;
req.trackGuids = src.trackGuids; // recorded on the Sample (track captures)
reasampler::OfflineRenderBackend backend;
reasampler::CaptureResult res = backend.capture(req);
@@ -122,8 +275,8 @@ static void RunCaptureMasterSpike()
reasampler::AddResult added = g_session.bank().add(res.sample);
// Persist the updated bank into the active project's ext state so the capture
// survives Save / close+reopen (M4). Non-destructive: writes only our own
// ext-state key. No-ops on an unsaved project (nothing to store into yet).
// survives Save / close+reopen (M4) and travels with the .rpp. saveToActiveProject
// also calls MarkProjectDirty. Non-destructive: writes only our own ext-state key.
g_session.saveToActiveProject();
std::string log = "ReaSampler: " + res.message + "\n";
@@ -179,7 +332,14 @@ static void RunInsertSelected(bool conform)
static bool OnHookCommand(int command, int /*flag*/)
{
if (command == 0) return false;
if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; }
// M7 capture family: command ids parallel captureActionTable() 1:1 by index.
// Claim the fired id if it is one of ours and route to its table row.
for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i)
if (command == g_captureCmdIds[i])
{
RunCapture(reasampler::captureActionTable()[i]);
return true;
}
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; }
if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; }
@@ -199,7 +359,7 @@ static int OnToggleAction(int command)
}
// gaccel storage must outlive registration — REAPER holds the pointer.
static gaccel_register_t g_accelCaptureMaster{};
// (The capture family's accels live in g_captureAccels, sized to the table.)
static gaccel_register_t g_accelToggleBankPanel{};
static gaccel_register_t g_accelInsertSelected{};
static gaccel_register_t g_accelInsertSelectedConform{};
@@ -228,9 +388,18 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL"));
g_rec->Register("-gaccel", (void*)&g_accelCaptureMaster);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_SPIKE"));
// Mirror-unregister the M7 capture family: gaccel + command_id per row,
// with '-'-prefixed strings (per the contract). The FOREVER-STABLE id
// strings come from the same table used to register them.
{
const auto& table = reasampler::captureActionTable();
for (std::size_t i = 0; i < table.size(); ++i)
{
if (i < g_captureAccels.size())
g_rec->Register("-gaccel", (void*)&g_captureAccels[i]);
g_rec->Register("-command_id", (void*)table[i].commandString);
}
}
}
// Destroy the docked window and release cached thumbnails before we drop
// the API pointers (DockWindowRemove/DestroyWindow need them live).
@@ -251,15 +420,26 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
g_hInst = hInstance;
g_rec = rec;
// Register the M3 spike action (command_id -> gaccel -> hookcommand).
g_cmdCaptureMasterSpike = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_SPIKE"));
if (g_cmdCaptureMasterSpike)
// Register the M7 capture action family (command_id -> gaccel per table row).
// The single hookcommand below routes every fired id back to its row by index.
// g_captureAccels must be sized BEFORE the loop and never reallocated after —
// REAPER holds a pointer to each element until we mirror-unregister it.
{
g_accelCaptureMaster.accel.cmd = g_cmdCaptureMasterSpike;
g_accelCaptureMaster.desc = "ReaSampler: capture master mix (spike)";
rec->Register("gaccel", (void*)&g_accelCaptureMaster);
const auto& table = reasampler::captureActionTable();
g_captureCmdIds.assign(table.size(), 0);
g_captureAccels.assign(table.size(), gaccel_register_t{});
for (std::size_t i = 0; i < table.size(); ++i)
{
const int cmd =
rec->Register("command_id", (void*)table[i].commandString);
g_captureCmdIds[i] = cmd;
if (cmd)
{
g_captureAccels[i].accel.cmd = cmd;
g_captureAccels[i].desc = table[i].description;
rec->Register("gaccel", (void*)&g_captureAccels[i]);
}
}
}
// Point the bank panel at the live session BEFORE registering its action, so
+123
View File
@@ -0,0 +1,123 @@
// render_settings.cpp — pure logic for the M7 capture action family. See header.
// NO REAPER types; unit-tested by tests/test_render_settings.cpp.
#include "render_settings.h"
#include <sstream>
namespace reasampler {
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
// All M7 actions are wet-only. `wetDry` is accepted so CaptureRequest.wetDry
// remains the seam for future M10 dry work, but it does not affect the mapping.
RenderSettingsChoice c;
switch (mode) {
case SourceMode::MasterMix:
case SourceMode::TimeSelection:
// Master IS the mix — wet-only; &(1|2)==0, no source bits.
c.settings = kRenderMasterMix;
c.supported = true;
return c;
case SourceMode::SelectedTracks:
// Selected tracks via master (&128) — wet (post-FX). Header ~3041.
c.settings = kRenderSelTracksViaMaster;
c.supported = true;
return c;
case SourceMode::SelectedItems:
// Selected media items, rendered to ONE file (single-file bit) so a
// multi-item selection yields a single bank entry, not N wavs.
c.settings = kRenderSelItems | kRenderSingleFile;
c.supported = true;
return c;
case SourceMode::RazorArea:
// Render razor edits to ONE file (same single-file rationale as items).
c.settings = kRenderRazorEdits | kRenderSingleFile;
c.supported = true;
return c;
case SourceMode::Realtime:
// Not an offline-render source — the realtime backend (M8) owns it.
c.settings = kRenderMasterMix;
c.supported = false;
return c;
}
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
c.supported = false;
return c;
}
std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
std::vector<RazorRange> ranges;
std::istringstream in(razorString);
// The string is space-separated TRIPLES: <start> <end> <envGuidString>.
// A track-audio area's third token is the literal two-char string `""`; an
// envelope-lane area's is a GUID `{…}`. We keep only track-audio triples.
std::string startTok, endTok, guidTok;
while (in >> startTok >> endTok >> guidTok) {
// Envelope-lane areas carry a real GUID; skip them (M7 = track audio).
// A track-audio area's GUID token is the empty quoted string `""`.
if (guidTok != "\"\"") continue;
// Parse the two time tokens. std::stod throws on garbage — guard so one
// malformed triple does not abort the whole parse.
double start = 0.0, end = 0.0;
try {
std::size_t sp = 0, ep = 0;
start = std::stod(startTok, &sp);
end = std::stod(endTok, &ep);
// Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse
// is a malformed area, not a valid range.
if (sp != startTok.size() || ep != endTok.size()) continue;
} catch (...) {
continue;
}
if (end > start) ranges.push_back({start, end}); // drop empty/inverted
}
return ranges;
}
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
if (ranges.empty()) return {0.0, 0.0};
RazorRange u = ranges.front();
for (const RazorRange& r : ranges) {
if (r.startSeconds < u.startSeconds) u.startSeconds = r.startSeconds;
if (r.endSeconds > u.endSeconds) u.endSeconds = r.endSeconds;
}
return u;
}
const std::vector<CaptureActionDef>& captureActionTable() {
// Built once (function-local static): four wet-only actions. Tail OFF for all
// (exact bounds). Ids are FOREVER-STABLE — never edit a shipped string.
// Dry variants deferred to M10; see kRenderPreFaderStems note in the header.
static const std::vector<CaptureActionDef> table = {
// Master mix — wet only (master IS the mix; no pre-FX concept applies).
{"CEREBELLUM_REASAMPLER_CAPTURE_MASTER",
"ReaSampler: capture master mix", "master_mix",
SourceMode::MasterMix, 1.0},
// Selected tracks — wet (via master, &128).
{"CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET",
"ReaSampler: capture selected tracks", "tracks_wet",
SourceMode::SelectedTracks, 1.0},
// Selected items — wet, single file (&32 | single-file).
{"CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET",
"ReaSampler: capture selected items", "items_wet",
SourceMode::SelectedItems, 1.0},
// Razor area — wet, single file (&4096 | single-file).
{"CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET",
"ReaSampler: capture razor area", "razor_wet",
SourceMode::RazorArea, 1.0},
};
return table;
}
} // namespace reasampler
+108
View File
@@ -0,0 +1,108 @@
#pragma once
// render_settings — the REAPER-free logic behind the M7 capture action family.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The capture shell (capture.cpp) reads
// the actual DAW state (time selection, selected tracks/items, razor strings) and
// hands the raw values here so the three genuinely-pure, easy-to-get-wrong pieces
// are unit-tested outside the DAW:
//
// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only; M7 scope).
// 2. a P_RAZOREDITS string -> the list of (start,end) ranges + their union bound.
// 3. the capture-action table (id string, description, source mode, wet/dry) —
// the taxonomy, in one place so main.cpp iterates it instead of hand-listing.
//
// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from
// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of
// which bits each source mode sets is this module's logic and is tested.
#include <string>
#include <vector>
#include "bank_model.h" // SourceMode (pure enum)
namespace reasampler {
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
//
// Only the bits M7 actually uses are named. Values are the documented bit
// weights; the DOC of each is the SDK header's, not a guess.
inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits
inline constexpr int kRenderSelItems = 32; // &32 selected media items
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
// NOTE: kRenderPreFaderStems (&8192) is NOT used in M7. REAPER offline render has
// no true pre-FX "dry" bit. Pre-fader stems are post-FX/pre-fader-volume — an
// approximation, not a dry capture. True pre-FX dry requires FX-bypass-around-
// render or the M8 realtime pre-FX path; it will be designed with the M10 null
// test. All M7 capture actions are wet-only.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
// for SourceMode::Realtime (that is the M8 backend, not offline render).
struct RenderSettingsChoice {
int settings = kRenderMasterMix;
bool supported = true; // false => not an offline-render source in M7
};
// Maps a source mode to the wet RENDER_SETTINGS value for M7.
// All M7 actions are wet-only (post-FX). `wetDry` is accepted but ignored for
// the mapping — retained in CaptureRequest as the seam for future M10 dry work.
//
// CONFIRMED (SDK header ~3041):
// MasterMix / TimeSelection -> master mix (0).
// SelectedTracks -> &128 selected tracks via master.
// SelectedItems -> &32 | single-file (one wav, not one-per-item).
// RazorArea -> &4096| single-file.
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
// A single razor-edit area: a time range on one track (envelope GUID ignored —
// M7 captures track-audio razor areas, not envelope lanes).
struct RazorRange {
double startSeconds = 0.0;
double endSeconds = 0.0;
};
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
// TRIPLES of <start> <end> <envGuidString>. The envelope GUID is "" (an empty
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a
// GUID like {…} for an envelope-lane area.
//
// Returns only the track-audio ranges (envelope-lane triples are skipped — M7
// renders track audio). Malformed/short trailing tokens are ignored, not fatal.
// A range with end <= start is dropped (no negative/empty areas leak through).
std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
// The union bound (min start, max end) of a set of razor ranges — the exact
// window the offline render must cover so every area is inside the rendered file.
// Returns {0,0} for an empty input (caller treats that as "no razor area").
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
// --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
//
// One row per bindable action. All M7 rows are wet-only (post-FX). Tail is OFF
// for every row (exact bounds); a tail-on variant is a later opt-in, YAGNI now.
// Yields a bounded, discoverable set with NO dialogs (the tool's no-clutter ethos).
//
// commandString is FOREVER-STABLE (user keybindings key off it) — never change a
// shipped value. baseName feeds the file stem (sanitized by capture_paths).
// wetDry is retained as the M10 seam; all M7 rows set it to 1.0.
struct CaptureActionDef {
const char* commandString; // CEREBELLUM_REASAMPLER_… FOREVER-STABLE id string
const char* description; // Actions-list label
const char* baseName; // file-stem base for this capture
SourceMode sourceMode;
double wetDry; // 1.0 (wet) for all M7 rows; seam for M10 dry
};
// The full M7 capture-action table. Iterated by main.cpp to register the family
// and route each fired command back to its definition. Kept here (pure) so the
// taxonomy is one testable list, not scattered registration code.
//
// Four wet-only rows: CAPTURE_MASTER, CAPTURE_TRACKS_WET, CAPTURE_ITEMS_WET,
// CAPTURE_RAZOR_WET. Dry variants are deferred to M10 (null-test work) — see the
// kRenderPreFaderStems note above for why offline dry is non-trivial.
const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler