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
+18 -1
View File
@@ -74,6 +74,18 @@ target_link_libraries(view_tree PUBLIC view_mode_model)
add_library(insert_plan STATIC src/insert_plan.cpp) add_library(insert_plan STATIC src/insert_plan.cpp)
target_include_directories(insert_plan PUBLIC src) target_include_directories(insert_plan PUBLIC src)
# ---------------------------------------------------------------------------
# 2f) Pure render_settings library — NO REAPER, NO SWELL. The M7 capture-family
# logic: source-mode + wet/dry -> RENDER_SETTINGS bit value, P_RAZOREDITS
# string -> time ranges + union bound, and the capture-action taxonomy table.
# Split out so the fiddly bit-mapping / razor-parsing is unit-tested outside
# the DAW; the render-driving + selection reads stay in capture.cpp / main.cpp.
# Depends on bank_model for the pure SourceMode enum.
# ---------------------------------------------------------------------------
add_library(render_settings STATIC src/render_settings.cpp)
target_include_directories(render_settings PUBLIC src)
target_link_libraries(render_settings PUBLIC bank_model)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3) Standalone tests for the pure modules (run without launching REAPER). # 3) Standalone tests for the pure modules (run without launching REAPER).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -106,6 +118,10 @@ add_executable(insert_plan_tests tests/test_insert_plan.cpp)
target_link_libraries(insert_plan_tests PRIVATE insert_plan) target_link_libraries(insert_plan_tests PRIVATE insert_plan)
add_test(NAME insert_plan_tests COMMAND insert_plan_tests) add_test(NAME insert_plan_tests COMMAND insert_plan_tests)
add_executable(render_settings_tests tests/test_render_settings.cpp)
target_link_libraries(render_settings_tests PRIVATE render_settings)
add_test(NAME render_settings_tests COMMAND render_settings_tests)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -133,8 +149,9 @@ add_library(reaper_reasampler MODULE
src/view.cpp src/view.cpp
src/track_guid.cpp src/track_guid.cpp
src/actions.cpp src/actions.cpp
src/render_settings.cpp
) )
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model insert_plan) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model insert_plan render_settings)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
+33 -18
View File
@@ -4,10 +4,19 @@
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU // 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). // that defines the API pointers; here they are extern (CLAUDE.md §contract).
// //
// Scope (M3): ONE source mode — the time-selection master mix. Drives the // Scope (M7): the full offline source family — master mix / time selection,
// RENDER_* project settings via GetSetProjectInfo / _String, snapshots and // selected tracks, selected items, razor area — all wet-only with optional tail.
// restores every setting it changes (non-destructive), triggers a render, then // Drives the RENDER_* project settings via GetSetProjectInfo / _String
// populates a Sample. It NEVER inserts into the arrange (load-bearing principle). // (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): // RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API):
// Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show // Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show
@@ -30,6 +39,7 @@
#include <vector> #include <vector>
#include "capture_paths.h" #include "capture_paths.h"
#include "render_settings.h"
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_EnumProjects
@@ -61,11 +71,6 @@ constexpr int kActionRenderUsingMostRecentSettings = 42230;
// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042. // ourselves for exact, unrounded bounds). Verified: SDK header line ~3042.
constexpr double kBoundsCustom = 0.0; 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 // RENDER_TAILFLAG bit &1 = apply tail for custom time bounds. We clear it for
// the spike (exact bounds, no added silence — precision invariant). // the spike (exact bounds, no added silence — precision invariant).
constexpr double kTailFlagNone = 0.0; constexpr double kTailFlagNone = 0.0;
@@ -226,13 +231,16 @@ std::string makeUniqueTag() {
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
CaptureResult result; CaptureResult result;
// M3 implements only the master-mix / time-selection case. Both mean "render // Resolve the RENDER_SETTINGS source/processing bits for this mode + wet/dry
// the master mix over the requested bounds". // (pure mapping, unit-tested in render_settings). An unsupported mode (only
if (request.sourceMode != SourceMode::MasterMix && // SourceMode::Realtime — that is the M8 realtime backend) is refused here so
request.sourceMode != SourceMode::TimeSelection) { // 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.status = CaptureStatus::UnsupportedMode;
result.message = "OfflineRenderBackend (M3) supports only master-mix / " result.message = "OfflineRenderBackend does not render this source mode "
"time-selection capture."; "(realtime capture is the M8 backend).";
return result; return result;
} }
@@ -350,8 +358,11 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, true); GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, true);
} }
// Master mix, no stems, no render matrix. // Source-selection bits for this mode, from the pure render_settings mapping
GetSetProjectInfo(proj, "RENDER_SETTINGS", kRenderSettingsMasterMix, true); // (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 // Resolve the effective sample rate. When the request carries 0 ("follow
// project"), read PROJECT_SRATE explicitly so RENDER_SRATE is set to the // 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- // Seconds are the authoritative source for the render. Do NOT add DAW-
// unverifiable PPQ resolution here — it requires a live REAPER to validate. // unverifiable PPQ resolution here — it requires a live REAPER to validate.
s.wetDry = request.wetDry; 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; s.channelCount = request.channelCount;
// Store the resolved sample rate only when it is known (> 0). If the project // 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 // 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.status = CaptureStatus::Ok;
result.sample = s; result.sample = s;
result.message = "Captured master mix [" + result.message = "Captured [" +
std::to_string(request.startSeconds) + "s, " + std::to_string(request.startSeconds) + "s, " +
std::to_string(request.endSeconds) + "s] -> " + std::to_string(request.endSeconds) + "s] -> " +
paths.relativePath; paths.relativePath;
+19 -5
View File
@@ -14,6 +14,7 @@
// without dragging the SDK into every include site. // without dragging the SDK into every include site.
#include <string> #include <string>
#include <vector>
#include "bank_model.h" #include "bank_model.h"
@@ -40,10 +41,20 @@ struct CaptureRequest {
double startSeconds = 0.0; double startSeconds = 0.0;
double endSeconds = 0.0; double endSeconds = 0.0;
// 1.0 = fully wet, 0.0 = fully dry. M3 renders the wet master mix (1.0); // 1.0 = fully wet, 0.0 = fully dry. All M7 actions set this to 1.0 (wet).
// dry/partial routing is M7. Carried now so the Sample records it. // 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; 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 — // Render tail. Default OFF for the spike (exact bounds, no added silence —
// precision invariant). M7 makes this bindable. // precision invariant). M7 makes this bindable.
bool renderTail = false; bool renderTail = false;
@@ -90,9 +101,12 @@ public:
virtual CaptureResult capture(const CaptureRequest& request) = 0; virtual CaptureResult capture(const CaptureRequest& request) = 0;
}; };
// Deterministic offline-render backend. M3 implements ONLY the // Deterministic offline-render backend. M7 implements the full offline source
// TimeSelection / MasterMix case (both map to "render the master mix over the // family — master mix / time selection, selected tracks, selected items, razor
// requested bounds"); any other source mode returns UnsupportedMode. // 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 { class OfflineRenderBackend : public ICaptureBackend {
public: public:
CaptureResult capture(const CaptureRequest& request) override; CaptureResult capture(const CaptureRequest& request) override;
+216 -36
View File
@@ -20,12 +20,16 @@
#include <string> #include <string>
#include <vector>
#include "actions.h" #include "actions.h"
#include "bank_model.h" #include "bank_model.h"
#include "bank_panel.h" #include "bank_panel.h"
#include "capture.h" #include "capture.h"
#include "insert.h" #include "insert.h"
#include "persist.h" #include "persist.h"
#include "render_settings.h"
#include "track_guid.h"
#include "view.h" #include "view.h"
// Persistent action-id prefix for the ReaSampler action family. // 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_HINSTANCE g_hInst = nullptr; // this module's instance handle
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
// ---- Action registration seam (M3 spike: capture master mix) --------------- // ---- M7 capture action family ----------------------------------------------
// First live use of the action pattern the seam left templated. The full capture // Four wet-only bindable actions from captureActionTable() (render_settings, pure):
// action family (selected tracks/items/razor, wet/dry, tail) is M7; this is ONE // master mix, selected tracks, selected items, razor area — all wet (post-FX).
// temporary action driving the M3 offline-render spike. // 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
// Command id for "ReaSampler: capture master mix (spike)". FOREVER-STABLE string // this family. Dry variants are deferred to M10 (null-test work).
// (user keybindings key off it) — see the prefix note above. //
static int g_cmdCaptureMasterSpike = 0; // 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. // 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 // The docked grid window is display-only this wave (Wave A) — the action just
@@ -92,24 +100,169 @@ static void OnTimer()
reasampler::bankPanelRefresh(); reasampler::bankPanelRefresh();
} }
// Runs the M3 spike: render the time-selection master mix, add the Sample, log. // --- M7 source resolvers ----------------------------------------------------
static void RunCaptureMasterSpike() // 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 double startSeconds = 0.0;
// with isSet=false reads the current time selection (isLoop=false). double endSeconds = 0.0;
double start = 0.0, end = 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); 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; reasampler::CaptureRequest req;
req.sourceMode = reasampler::SourceMode::TimeSelection; req.sourceMode = def.sourceMode;
req.startSeconds = start; req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = end; req.endSeconds = src.endSeconds;
req.wetDry = 1.0; // wet master mix req.wetDry = def.wetDry; // 1.0 wet (all M7 actions are wet-only)
req.renderTail = false; // exact bounds, no tail req.renderTail = false; // exact bounds, no tail (M7 default)
req.sampleRate = 0; // follow project rate req.tailMs = 0.0;
req.sampleRate = 0; // follow project rate
req.channelCount = 2; req.channelCount = 2;
req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither
req.baseName = "master_mix"; req.baseName = def.baseName;
req.trackGuids = src.trackGuids; // recorded on the Sample (track captures)
reasampler::OfflineRenderBackend backend; reasampler::OfflineRenderBackend backend;
reasampler::CaptureResult res = backend.capture(req); reasampler::CaptureResult res = backend.capture(req);
@@ -122,8 +275,8 @@ static void RunCaptureMasterSpike()
reasampler::AddResult added = g_session.bank().add(res.sample); reasampler::AddResult added = g_session.bank().add(res.sample);
// Persist the updated bank into the active project's ext state so the capture // 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 // survives Save / close+reopen (M4) and travels with the .rpp. saveToActiveProject
// ext-state key. No-ops on an unsaved project (nothing to store into yet). // also calls MarkProjectDirty. Non-destructive: writes only our own ext-state key.
g_session.saveToActiveProject(); g_session.saveToActiveProject();
std::string log = "ReaSampler: " + res.message + "\n"; std::string log = "ReaSampler: " + res.message + "\n";
@@ -179,7 +332,14 @@ static void RunInsertSelected(bool conform)
static bool OnHookCommand(int command, int /*flag*/) static bool OnHookCommand(int command, int /*flag*/)
{ {
if (command == 0) return false; 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_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; } if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; }
if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); 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. // 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_accelToggleBankPanel{};
static gaccel_register_t g_accelInsertSelected{}; static gaccel_register_t g_accelInsertSelected{};
static gaccel_register_t g_accelInsertSelectedConform{}; 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("-gaccel", (void*)&g_accelToggleBankPanel);
g_rec->Register("-command_id", g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL")); (void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL"));
g_rec->Register("-gaccel", (void*)&g_accelCaptureMaster); // Mirror-unregister the M7 capture family: gaccel + command_id per row,
g_rec->Register("-command_id", // with '-'-prefixed strings (per the contract). The FOREVER-STABLE id
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_SPIKE")); // 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 // Destroy the docked window and release cached thumbnails before we drop
// the API pointers (DockWindowRemove/DestroyWindow need them live). // 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_hInst = hInstance;
g_rec = rec; g_rec = rec;
// Register the M3 spike action (command_id -> gaccel -> hookcommand). // Register the M7 capture action family (command_id -> gaccel per table row).
g_cmdCaptureMasterSpike = rec->Register( // The single hookcommand below routes every fired id back to its row by index.
"command_id", // g_captureAccels must be sized BEFORE the loop and never reallocated after —
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_SPIKE")); // REAPER holds a pointer to each element until we mirror-unregister it.
if (g_cmdCaptureMasterSpike)
{ {
g_accelCaptureMaster.accel.cmd = g_cmdCaptureMasterSpike; const auto& table = reasampler::captureActionTable();
g_accelCaptureMaster.desc = "ReaSampler: capture master mix (spike)"; g_captureCmdIds.assign(table.size(), 0);
rec->Register("gaccel", (void*)&g_accelCaptureMaster); 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 // 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
+162
View File
@@ -0,0 +1,162 @@
// Standalone tests for reasampler::render_settings — no REAPER, no framework.
// Covers the three pure pieces behind the M7 capture family: the source-mode ->
// RENDER_SETTINGS bit mapping (wet-only), P_RAZOREDITS parsing -> ranges + union,
// and the capture-action taxonomy table (stable ids, coverage of every mode).
#include "../src/render_settings.h"
#include <cstdio>
#include <set>
#include <string>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- renderSettingsFor: wet-only bit mapping ---------------------------------
static void testMasterMixWet() {
// Master mix -> value 0 (no source bits), supported.
RenderSettingsChoice c = renderSettingsFor(SourceMode::MasterMix, 1.0);
CHECK(c.settings == kRenderMasterMix);
CHECK(c.supported);
// TimeSelection aliases master mix — same result.
CHECK(renderSettingsFor(SourceMode::TimeSelection, 1.0).settings == kRenderMasterMix);
// wetDry argument is irrelevant for M7 (all actions are wet); passing 0.0
// must still yield the same wet master-mix bits.
CHECK(renderSettingsFor(SourceMode::MasterMix, 0.0).settings == kRenderMasterMix);
}
static void testSelectedTracksWet() {
// Selected tracks -> via master (&128), header-confirmed.
RenderSettingsChoice c = renderSettingsFor(SourceMode::SelectedTracks, 1.0);
CHECK(c.settings == kRenderSelTracksViaMaster);
CHECK(c.supported);
}
static void testSelectedItemsSingleFile() {
// Items render to ONE file (single-file bit set) so N items -> 1 bank entry.
RenderSettingsChoice c = renderSettingsFor(SourceMode::SelectedItems, 1.0);
CHECK((c.settings & kRenderSelItems) != 0);
CHECK((c.settings & kRenderSingleFile) != 0);
CHECK(c.supported);
}
static void testRazorSingleFile() {
// Razor edits render to ONE file (same single-file rationale as items).
RenderSettingsChoice c = renderSettingsFor(SourceMode::RazorArea, 1.0);
CHECK((c.settings & kRenderRazorEdits) != 0);
CHECK((c.settings & kRenderSingleFile) != 0);
CHECK(c.supported);
}
static void testRealtimeIsUnsupportedOffline() {
// The realtime mode is not an offline-render source — must report unsupported
// so the offline backend refuses it rather than rendering the master mix.
CHECK(!renderSettingsFor(SourceMode::Realtime, 1.0).supported);
}
// --- parseRazorEdits: P_RAZOREDITS string -> ranges --------------------------
static void testParseSingleTrackAudioArea() {
// One track-audio triple: start end "" (empty quoted GUID = track audio).
auto r = parseRazorEdits("1.5 3.25 \"\"");
CHECK(r.size() == 1);
CHECK(r[0].startSeconds == 1.5);
CHECK(r[0].endSeconds == 3.25);
}
static void testParseMultipleAreas() {
auto r = parseRazorEdits("0.0 1.0 \"\" 2.0 4.0 \"\"");
CHECK(r.size() == 2);
CHECK(r[0].startSeconds == 0.0 && r[0].endSeconds == 1.0);
CHECK(r[1].startSeconds == 2.0 && r[1].endSeconds == 4.0);
}
static void testParseSkipsEnvelopeLaneAreas() {
// A triple whose GUID is a real {…} is an ENVELOPE-lane area — skipped, since
// M7 renders track audio. Only the track-audio triple survives.
auto r = parseRazorEdits(
"1.0 2.0 \"\" 3.0 4.0 {AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}");
CHECK(r.size() == 1);
CHECK(r[0].startSeconds == 1.0 && r[0].endSeconds == 2.0);
}
static void testParseEmptyAndMalformed() {
CHECK(parseRazorEdits("").empty());
// Empty/inverted range dropped (end <= start).
CHECK(parseRazorEdits("5.0 5.0 \"\"").empty());
CHECK(parseRazorEdits("5.0 1.0 \"\"").empty());
// Trailing garbage token in a time field -> that triple dropped, not a crash.
CHECK(parseRazorEdits("1.0x 2.0 \"\"").empty());
// A dangling partial triple (missing GUID token) is ignored.
CHECK(parseRazorEdits("1.0 2.0").empty());
}
static void testRazorUnionBounds() {
// Union = min start .. max end across all areas (the exact render window).
std::vector<RazorRange> ranges = {{2.0, 3.0}, {0.5, 1.0}, {4.0, 6.5}};
RazorRange u = razorUnionBounds(ranges);
CHECK(u.startSeconds == 0.5);
CHECK(u.endSeconds == 6.5);
// Empty -> {0,0} sentinel (caller treats as "no razor area").
RazorRange empty = razorUnionBounds({});
CHECK(empty.startSeconds == 0.0 && empty.endSeconds == 0.0);
}
// --- captureActionTable: the taxonomy ----------------------------------------
static void testTableHasFourWetOnlyRows() {
const auto& table = captureActionTable();
// Exactly 4 wet-only rows: MASTER, TRACKS_WET, ITEMS_WET, RAZOR_WET.
CHECK(table.size() == 4);
std::set<std::string> ids;
bool sawMaster = false, sawTracks = false, sawItems = false, sawRazor = false;
for (const auto& def : table) {
// Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE
// (duplicate ids would collide on registration).
std::string id = def.commandString;
CHECK(id.rfind("CEREBELLUM_REASAMPLER_", 0) == 0);
CHECK(ids.insert(id).second); // false if duplicate
// Every row is wet (>=0.5) and a real offline source.
CHECK(def.wetDry >= 0.5);
CHECK(renderSettingsFor(def.sourceMode, def.wetDry).supported);
if (def.sourceMode == SourceMode::MasterMix) sawMaster = true;
if (def.sourceMode == SourceMode::SelectedTracks) sawTracks = true;
if (def.sourceMode == SourceMode::SelectedItems) sawItems = true;
if (def.sourceMode == SourceMode::RazorArea) sawRazor = true;
}
CHECK(sawMaster);
CHECK(sawTracks);
CHECK(sawItems);
CHECK(sawRazor);
}
static void testNoDryRowsInTable() {
// M7 ships wet-only. No table row must have wetDry < 0.5.
for (const auto& def : captureActionTable())
CHECK(def.wetDry >= 0.5);
}
int main() {
testMasterMixWet();
testSelectedTracksWet();
testSelectedItemsSingleFile();
testRazorSingleFile();
testRealtimeIsUnsupportedOffline();
testParseSingleTrackAudioArea();
testParseMultipleAreas();
testParseSkipsEnvelopeLaneAreas();
testParseEmptyAndMalformed();
testRazorUnionBounds();
testTableHasFourWetOnlyRows();
testNoDryRowsInTable();
if (g_fail == 0) std::printf("render_settings: all tests passed\n");
else std::printf("render_settings: %d CHECK(s) FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}