diff --git a/CMakeLists.txt b/CMakeLists.txt index be892eb..ea6285a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,18 @@ target_link_libraries(view_tree PUBLIC view_mode_model) add_library(insert_plan STATIC src/insert_plan.cpp) 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). # --------------------------------------------------------------------------- @@ -106,6 +118,10 @@ add_executable(insert_plan_tests tests/test_insert_plan.cpp) target_link_libraries(insert_plan_tests PRIVATE insert_plan) 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). # --------------------------------------------------------------------------- @@ -133,8 +149,9 @@ add_library(reaper_reasampler MODULE src/view.cpp src/track_guid.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}) set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") diff --git a/src/capture.cpp b/src/capture.cpp index 6d729f3..ddcd539 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -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 #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(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; diff --git a/src/capture.h b/src/capture.h index 91d92e3..3e2753e 100644 --- a/src/capture.h +++ b/src/capture.h @@ -14,6 +14,7 @@ // without dragging the SDK into every include site. #include +#include #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 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; diff --git a/src/main.cpp b/src/main.cpp index 046bded..67ed03c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,12 +20,16 @@ #include +#include + #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 g_captureCmdIds; +static std::vector 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 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 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 buf(8192, '\0'); + if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) + continue; + std::vector 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 diff --git a/src/render_settings.cpp b/src/render_settings.cpp new file mode 100644 index 0000000..a66997d --- /dev/null +++ b/src/render_settings.cpp @@ -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 + +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 parseRazorEdits(const std::string& razorString) { + std::vector ranges; + std::istringstream in(razorString); + + // The string is space-separated TRIPLES: . + // 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& 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& 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 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 diff --git a/src/render_settings.h b/src/render_settings.h new file mode 100644 index 0000000..4a1f6fb --- /dev/null +++ b/src/render_settings.h @@ -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 +#include + +#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 . 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 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& 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& captureActionTable(); + +} // namespace reasampler diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp new file mode 100644 index 0000000..14d4800 --- /dev/null +++ b/tests/test_render_settings.cpp @@ -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 +#include +#include + +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 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 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; +}