// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers. // // This file is the entire contract between REAPER and the extension: // * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and // dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry // (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro). // * REAPER calls it, handing over `rec` — a small dispatch struct. // - rec->GetFunc(name) resolves any REAPER API function to a pointer // - rec->Register(what,ptr) plugs OUR callbacks into REAPER // * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and // fills in every global function pointer (ShowConsoleMsg, InsertMedia...). // // Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates // storage for those global pointers. Every other .cpp includes // reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. #define REAPERAPI_IMPLEMENT #include "reaper_plugin.h" #include "reaper_plugin_functions.h" #include #include #include #include #include #include #include "actions.h" #include "app_version.h" #include "bank_model.h" #include "bank_panel.h" #include "batch_capture.h" #include "capture.h" #include "ingest.h" #include "insert.h" #include "persist.h" #include "provenance.h" #include "provenance_shell.h" #include "render_settings.h" #include "track_guid.h" #include "view.h" #include // project-dir derivation for provenance parent resolution // Persistent action-id family (Phase V, V4 — channel-qualified). Every bindable action // mints its command id from commandIdPrefix() + a per-action SUFFIX, and its Actions-list // name from actionDisplayPrefix() + a phrase, both derived from the ONE channel bit in the // pure app_version module (channelCommandId / channelActionName). Stable rebuilds the exact // shipped id ("CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); beta yields the isolated forever- // family id ("CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK"). FOREVER-STABLE per channel: a // shipped suffix is as permanent as the prefix; user keybindings key off the composed id. // // The composed id strings are held here for the module's lifetime (idStore) so both the // register call and the mirroring '-command_id' unregister pass the SAME stable pointer. // A std::deque (NOT vector) is used deliberately: it never invalidates references to // existing elements on push_back, so a c_str() handed out early stays valid after later // interning — the unload path re-presents these same pointers. static std::deque g_idStore; // Interns a composed command-id string for the module lifetime and returns its C string. // Appended-to only during startup registration and read on unload; never cleared until // process exit, and deque guarantees the returned pointer stays valid. static const char* internCmdId(const std::string& suffix) { g_idStore.push_back(reasampler::channelCommandId(suffix)); return g_idStore.back().c_str(); } // Globals other files reference via `extern`. REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct // ---- Capture action family (two FX scopes) --------------------------------- // Two bindable SCOPE actions from captureActionTable() (render_settings, pure): // capture item / track. Each infers its range (razor-else-time) and enforces the // FX-scope invariant via FX-bypass-around-render (FxBypassGuard): // Item -> take/item FX only (bypass the item's track + ancestors + master). // Track -> item FX + track's own FX (bypass ancestors + master). // There is NO master scope — to capture the master you render a track. (The master // track's FX/gain/pan are STILL neutralized for both scopes as the out-of-scope // chain — master is a bypass target, not a capture scope.) The retired M7 // CAPTURE_TRACKS_WET / CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids AND the removed // CAPTURE_MASTER / CAPTURE_MASTER_REALTIME ids are mirror-unregistered on unload so // old keybindings clear cleanly. // // 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; // Channel-qualified capture-action labels, one per table row. REAPER holds each gaccel's // `desc` pointer, so the composed strings live here for the module lifetime (parallel to // g_captureAccels; never resized after the registration loop sets it). static std::vector g_captureDescs; // Retired capture-action command-id SUFFIXES. Kept ONLY to mirror-unregister them on // unload so a user's stale keybindings are cleaned up. Never re-register these. Composed // through the channel prefix at unload (channelCommandId) so a beta unload clears beta- // qualified retired ids and a stable unload clears stable's — each channel cleans up only // its own family. // * The M7 four-mode ids (tracks/items/razor WET). // * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the // master realtime action are REMOVED (capture is now item + track only; realtime // taps the selected track). Their shipped ids are retired so old keybindings clear. // * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants // are REMOVED; tail is now a panel-setting toggle, not a paired action. Retired so // old keybindings clear. static const char* const kRetiredCaptureCmdSuffixes[] = { "CAPTURE_TRACKS_WET", "CAPTURE_ITEMS_WET", "CAPTURE_RAZOR_WET", "CAPTURE_MASTER", "CAPTURE_MASTER_REALTIME", "CAPTURE_ITEM_TAIL", "CAPTURE_TRACK_TAIL", }; // 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 // shows/hides it; it never captures, inserts, or mutates the bank. static int g_cmdToggleBankPanel = 0; // Command ids for the M6 insert actions. FOREVER-STABLE strings. Two variants that // differ ONLY in the InsertOptions they build: the default inserts at native length // (no stretch, no conform); the "conform" variant is the EXPLICIT opt-in to REAPER's // try-to-match-project-tempo path (CONTEXT.md §insert: conform is opt-in, never // silent). Both read the bank panel's current selection and place at the edit cursor. static int g_cmdInsertSelected = 0; static int g_cmdInsertSelectedConform = 0; // Command ids for the M11 batch-capture actions. NEW FOREVER-STABLE strings. One action // fires N captures: CAPTURE_BATCH_ITEMS -> one bank sample per selected item (item scope); // CAPTURE_BATCH_RAZOR -> one bank sample per razor area (track scope, each area's range). // Each unit honors every precision invariant; the original selection is restored on every // exit path. Bank-only, never places on the timeline (load-bearing principle). static int g_cmdCaptureBatchItems = 0; static int g_cmdCaptureBatchRazor = 0; // Command id for the "capture selected track (realtime)" action. NEW FOREVER-STABLE // string. Records the selected track's OWN output in realtime (transport-driven) into // a hidden temp track via RealtimeRecordBackend, then moves the recorded file into the // bank. The realtime SIBLING of the offline CAPTURE_TRACK scope action: same range // logic (razor-else-time), same track selection, same bank/persist path, different // backend. Dialog-free. (Replaces the removed CAPTURE_MASTER_REALTIME action.) static int g_cmdCaptureTrackRealtime = 0; // Command id for the M10 "re-capture from source" action. NEW FOREVER-STABLE string // (suffix RECAPTURE_FROM_SOURCE). Regenerates the bank panel's selected PROVENANCED // sample from its recorded source's current state and updates the Sample in place — // BANK-ONLY, never places on the timeline (load-bearing principle). Reports the no- // provenance / vanished-source / drift cases to the console (a direct response to an // explicit action, allowed by the console policy). static int g_cmdRecaptureFromSource = 0; // Command id for the S8 "capture selected item / time-selection into bank + assign" // action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline // Item-scope capture path (RunCapture) verbatim — same razor-else-time range, same // FX-scope neutralize, same bank/persist landing — then writes an S8 assignment request // so the active sampler instance plays the just-captured sample on its next reload. NEVER // inserts a timeline item (the capture/placement separation holds; assign is a bank-index // + instance-selection act). Lives in the capture family (not the ingest family) because // it leans on main.cpp's capture render machinery, which is not exposed cross-module. static int g_cmdCaptureItemAssign = 0; // Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string. // Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user // who started a long capture can bail without waiting for the range end or hunting for // the transport-stop. No-op (with a note) when nothing is in flight. static int g_cmdCancelRealtime = 0; // Command id for the Phase V "show version" action. FOREVER-STABLE string. On demand // ONLY — prints the CMake-sourced version string to the console when fired. This is the // SOLE new console output the versioning wave adds; there is no unconditional startup // version print (routine console chatter was deliberately removed — it pops the console // window). The user copies this line into a bug report. static int g_cmdShowVersion = 0; // The persistence session (M4): owns the in-memory BankIndex and bridges it to // project ext state. A timer tick drives g_session.poll() to detect project // load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to // the ACTIVE bank's index inside the session's BankBook; after a capture we serialize // the book back into the active project's ext state (the `banks` key) so it travels // with the .rpp. Replaces the M3 session-only g_bank. static reasampler::ReaSamplerSession g_session; // --- M8 in-flight realtime capture (async, timer-driven) -------------------- // A realtime record spans many timer ticks (it takes end-start wall-clock seconds // and must NOT block REAPER's UI). The action STARTS it (g_rtBackend.begin), which // returns immediately with the in-flight state owned here; OnTimer drives it // (g_rtBackend.tick) each tick until a terminal verdict; then this pointer is // cleared. Non-null == a capture is in progress (used to reject a second one, and to // abort on project switch / unload). static reasampler::RealtimeRecordBackend g_rtBackend; static reasampler::RealtimeCaptureHandle g_rtCapture; // The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets // OnTimer detect a project switch mid-capture and abort+restore rather than leak the // temp track/arm/transport into or across projects. Only meaningful when // g_rtCapture != nullptr. static ReaProject* g_rtCaptureProject = nullptr; // Commit a finished realtime capture (a Done tick/abort with an Ok result): add the // Sample to the ACTIVE bank (g_session.bank() resolves to book.activeIndex() — B2), // persist + MarkProjectDirty. Shared by the tick-completion path and the abort // paths. On a non-Ok result, logs the failure only. static void CommitRealtimeResult(const reasampler::CaptureResult& res) { if (res.status != reasampler::CaptureStatus::Ok) { ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str()); return; } g_session.bank().add(res.sample); // B-cap: record the file the capture created in the owned-file manifest, at the same // point the Sample is added and before the same persist. Recorded regardless of the // index AddResult — even a hash-collapse still WROTE a file the tool owns, and the // manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index). g_session.owned().add(res.sample.relativePath); g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp) } // Advance any in-flight realtime capture one tick. Cheap when none is running (a // null check) and fast even mid-record (tick() only reads the transport until the // terminal tick). Detects a project switch mid-capture and aborts+restores so the // capture never leaks across projects. Called from OnTimer BEFORE session.poll() so // poll's project-switch handling sees a cleaned-up project. static void DriveRealtimeCapture() { if (!g_rtCapture) return; // Project switch guard: if the active project is no longer the one the capture // belongs to, a new/other project became active mid-record — abort + restore // (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize // into the new project. ReaProject* active = EnumProjects(-1, nullptr, 0); if (active != g_rtCaptureProject) { reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); // Only commit if the ORIGINAL project is still open and active would be it — // on a switch we restored into the original but must not persist into the // now-active foreign project. Log the outcome without persisting. On a Failed // abort surface abort()'s own message — it distinguishes a clean tab-switch // abort from the closed-project DROP (the captured project was closed mid-record, // review §1: nothing restored because the pointers were already freed). if (r.status == reasampler::RealtimeTickStatus::Done) ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- " "captured audio restored into the original project; not " "persisted to avoid crossing projects.\n"); else ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record -- " + r.result.message + "\n").c_str()); g_rtCapture.reset(); g_rtCaptureProject = nullptr; return; } reasampler::RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture); if (r.status == reasampler::RealtimeTickStatus::InProgress) return; // Terminal (Done or Failed): commit/log and drop the in-flight state. CommitRealtimeResult(r.result); g_rtCapture.reset(); g_rtCaptureProject = nullptr; } // The timer callback REAPER runs periodically (registered via "timer"). It only // forwards to the session poll — cheap per tick (reads the active project id and // its .rpp path, acts only on a change). static void OnTimer() { // Advance any in-flight realtime capture FIRST, so a project switch is caught and // the capture torn down/restored before session.poll() reacts to that switch. DriveRealtimeCapture(); g_session.poll(); // D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view // model but deliberately does NOT apply visibility — that would couple persist // to the view shell). Instead poll() raises a one-shot load signal; here — the // integration layer that already drives both persist and the view shell — we // drain it and reapply the SAVED active mode's visibility/processing so opening a // project saved in Design mode parks the Arrange tracks automatically, no manual // toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks // skip it. proj = nullptr -> REAPER's active project (the one poll just loaded). // // The SAME signal re-arms the bank panel's new-content detector: a load must // re-baseline the detector against the just-loaded project's content so its // pre-existing tracks are never mis-detected as "new" and mass-tagged into the // active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's // re-arm and the model restore ride the one authoritative load event. if (g_session.consumeLoadSignal()) { reasampler::bankPanelNotifyProjectLoaded(); // Reconcile the restored lane-ownership index against the live project's lanes // FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth), // so a saved lane-split project's managed/manual classification is correct // before the active mode's lane visibility is reapplied. Never re-mints, never // mass-tags — it only records managed ownership recovered from lane names. reasampler::reconcileManagedLanes(g_session.view(), nullptr); reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); } // Reflect a live bank change (capture / project load) in the docked grid. // Cheap when the bank is unchanged (a fingerprint compare); repaints only on // an actual change. No-op when the panel is closed. reasampler::bankPanelRefresh(); } // --- projectconfig hook: reload the session on undo/redo (R-B) --------------- // A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on // disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's // identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view // would stay stale until close+reopen. REAPER's projectconfig extension fires // BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore // (isUndo == true for both). We hook it to drive a session reload. // // TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as // firing BEFORE any state restore. Reading GetProjExtState synchronously here would // return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request // (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which // point REAPER has finished restoring the block and GetProjExtState returns // the POST-undo value. Deterministic, event-driven — NOT ext-state content polling. // // GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false); // we ignore that here so a normal open flows solely through the timer's identity-transition // Load path (no double load). Only undo/redo (isUndo=true) requests the reload. static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) { if (isUndo) g_session.requestReload(); } // ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores // its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"), // which REAPER persists in its own RPP block — NOT via this extension's own // project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo // notification. Returning false from ProcessExtensionLine means "not our line" so REAPER // keeps dispatching (we claim none). SaveExtensionConfig writes nothing. static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/, bool /*isUndo*/, project_config_extension_t* /*reg*/) { return false; // we own no project lines — ext state carries our data } static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/, project_config_extension_t* /*reg*/) { // Nothing to write: our data rides in ext state, not project lines. } // Storage must outlive registration — REAPER holds this pointer until we unregister it. static project_config_extension_t g_projectConfig{ &OnProcessExtensionLine, &OnSaveExtensionConfig, &OnBeginLoadProjectState, nullptr, // userData }; // --- Scope-action source resolution ----------------------------------------- // The three scope actions (item / track / master) each resolve to (1) an exact // render range in project seconds — razor-else-time, inferred here — and (2) the // set of source TRACKS whose ancestor chains drive the FX-bypass plan. All reads // are non-destructive: selection, razor, and time selection are read, never // mutated. Returning false means "nothing to capture" (empty selection / no // range); the caller reports it and writes nothing. // The resolved source: exact bounds + the source tracks (for FX-bypass + Sample // provenance GUIDs). `sourceTracks` holds the item-owning tracks (Item scope) or the // selected tracks (Track scope). struct ResolvedSource { double startSeconds = 0.0; double endSeconds = 0.0; std::vector sourceTracks; // item-owning tracks / selected tracks std::vector trackGuids; // canonical GUIDs of sourceTracks }; // Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false, // isLoop=false) reads the current time selection. static bool resolveTimeSelection(double& start, double& end) { start = 0.0; end = 0.0; GetSet_LoopTimeRange(false, false, &start, &end, false); return end > start; } // Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of // start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits), // and returns the union bound. Reads only — never clears the razor selection. // Returns false when no track-audio razor area exists on any track. static bool resolveRazorRange(double& start, double& end) { std::vector allRanges; const int n = CountTracks(nullptr); for (int i = 0; i < n; ++i) { MediaTrack* tr = GetTrack(nullptr, i); if (!tr) continue; 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); start = u.startSeconds; end = u.endSeconds; return end > start; } // Infers the render RANGE for any scope: razor union when a razor area is present, // else the time selection (pure inferRangeSource decides which). Orthogonal to // scope. Returns false (with a reason) when neither yields a non-empty range. static bool resolveRange(double& start, double& end, std::string& why) { double rzStart = 0.0, rzEnd = 0.0; const bool hasRazor = resolveRazorRange(rzStart, rzEnd); if (reasampler::inferRangeSource(hasRazor) == reasampler::RangeSource::Razor) { start = rzStart; end = rzEnd; return true; // resolveRazorRange already verified end > start } if (resolveTimeSelection(start, end)) return true; why = "make a razor area or a time selection first"; return false; } // Current project's directory (parent of its .rpp), forward-slashed, no trailing // slash — the same derivation capture.cpp does internally, needed here so M10 can // resolve the bank's relative paths to absolute for parent detection. Empty for an // unsaved project (EnumProjects writes an empty .rpp path), which makes every bank // file resolve empty -> no false parentage. Read-only; mutates nothing. static std::string currentProjectDir() { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); const std::string rpp(buf.data()); if (rpp.empty()) return {}; namespace fs = std::filesystem; std::string dir = fs::path(rpp).parent_path().string(); for (char& c : dir) if (c == '\\') c = '/'; if (dir.size() > 1 && dir.back() == '/') dir.pop_back(); return dir; } // Maps a capture FX scope onto the pure provenance scope (kept decoupled so the // pure provenance module does not depend on render_settings). static reasampler::ProvenanceScope provenanceScopeFor(reasampler::CaptureScope scope) { return scope == reasampler::CaptureScope::Item ? reasampler::ProvenanceScope::Item : reasampler::ProvenanceScope::Track; } // Builds the M10 provenance for a capture IF it genuinely resamples from a bank // sample, else returns nullopt (the common, non-resample case). Detection rule // (stated honestly): the capture's source item media file(s) must all resolve, by // exact normalized absolute path, to ONE bank sample's file (detectParent). On a // match, records that sample's id as the parent plus a THIN capture-recipe // fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels + // source track GUIDs + the in-scope source FX-chain identity — so "re-capture from // source" can replay the request and report drift. NEVER a serialized chain to // restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per // selected item, combined in item order; Track scope reads the track FX chain. static std::optional buildCaptureProvenance( const reasampler::CaptureRequest& req, reasampler::CaptureScope scope, const ResolvedSource& src) { const std::string projectDir = currentProjectDir(); const std::vector bankFiles = reasampler::bankFileRefs(g_session.book(), projectDir); // The "what audio is being captured" source set depends on scope: item scope uses // the SELECTED items (the user picked them); track scope uses the range-overlapping // items ON the source tracks (the user picked the track, not the item). const std::vector sourceFiles = scope == reasampler::CaptureScope::Item ? reasampler::selectedItemSourceFiles() : reasampler::trackItemSourceFiles(src.sourceTracks, req.startSeconds, req.endSeconds); const std::optional parentId = reasampler::detectParent(sourceFiles, bankFiles); if (!parentId) return std::nullopt; // not a resample-from-sample — no provenance reasampler::CaptureRecipe recipe; recipe.scope = provenanceScopeFor(scope); recipe.sourceMode = static_cast(req.sourceMode); recipe.startSeconds = req.startSeconds; recipe.endSeconds = req.endSeconds; recipe.tailMode = static_cast(req.tailMode); recipe.tailMs = req.tailMs; recipe.sampleRate = req.sampleRate; recipe.channelCount = req.channelCount; recipe.trackGuids = req.trackGuids; // The in-scope FX-chain identity: // Track scope — per-track chains combined in track order (TrackFX_*). // Item scope — per-item active-take chains combined in item order (TakeFX_*); // the owning track's FX chain is OUT OF SCOPE for an item capture and must // not be fingerprinted here (it is bypassed during render, not heard). if (scope == reasampler::CaptureScope::Item) { const int n = CountSelectedMediaItems(nullptr); std::vector items; items.reserve(static_cast(n < 0 ? 0 : n)); for (int i = 0; i < n; ++i) { MediaItem* it = GetSelectedMediaItem(nullptr, i); if (it) items.push_back(it); } recipe.fxChainIdentity = reasampler::fxChainIdentityForItems(items); } else { std::vector perTrack; perTrack.reserve(src.sourceTracks.size()); for (MediaTrack* tr : src.sourceTracks) perTrack.push_back(reasampler::fxChainIdentityForTrack(tr)); recipe.fxChainIdentity = reasampler::combineChainIdentities(perTrack); } reasampler::Provenance prov; prov.parentSampleId = *parentId; prov.fxChainSnapshot = reasampler::buildFingerprint(recipe); return prov; } // Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. static bool collectSelectedTracks(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; out.sourceTracks.push_back(tr); std::string g = reasampler::guidString(tr); if (!g.empty()) out.trackGuids.push_back(std::move(g)); } return !out.sourceTracks.empty(); } // Collects the tracks that own the selected items (Item scope) into // out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an // item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning // track (SDK header, verify). GUIDs recorded for provenance. static bool collectSelectedItemTracks(ResolvedSource& out) { const int n = CountSelectedMediaItems(nullptr); if (n <= 0) return false; for (int i = 0; i < n; ++i) { MediaItem* it = GetSelectedMediaItem(nullptr, i); if (!it) continue; MediaTrack* tr = GetMediaItem_Track(it); if (!tr) continue; // Dedup: several selected items can share a track. bool seen = false; for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; } if (seen) continue; out.sourceTracks.push_back(tr); std::string g = reasampler::guidString(tr); if (!g.empty()) out.trackGuids.push_back(std::move(g)); } return !out.sourceTracks.empty(); } // Resolves the source for a scope: the selection tracks (item/track), plus the // inferred range. Returns false with a reason on nothing to do. static bool ResolveScopeSource(reasampler::CaptureScope scope, ResolvedSource& out, std::string& why) { using reasampler::CaptureScope; switch (scope) { case CaptureScope::Item: if (!collectSelectedItemTracks(out)) { why = "select at least one media item"; return false; } break; case CaptureScope::Track: if (!collectSelectedTracks(out)) { why = "select at least one track"; return false; } break; } return resolveRange(out.startSeconds, out.endSeconds, why); } // --- FX-bypass + full parent-chain neutralize around render (RAII, non-destr.) -- // For every track a scope must NOT hear the FX of, this ALSO neutralizes that // track's fader gain AND its full pan chain (pan/width/law/mode) for the render — // because a Track/Item capture renders via master and would otherwise sum through // the parent/folder/master FADERS and PAN/WIDTH/LAW, printing their gain and pan // coloring into the file (Daniel: the capture is likely re-routed through that // same chain later, so parent/master level and pan must not be baked in). The // neutralize set is IDENTICAL to the FX-bypass set: // Item -> own track + all ancestors + master (take vol/pan kept: item content). // Track -> all ancestors + master (selected track's OWN vol/pan kept). // (Master is a bypass TARGET for both scopes — never a scope of its own.) // // Per track in that set we snapshot & set the full parent-chain-independence set, // so a Track/Item capture is uncolored by the parent/folder/master it renders // through — no FX, no fader, and no pan/width/law/mode coloring: // I_FXEN -> 0 (FX bypassed; SDK ~2194) // D_VOL -> 1.0 (unity trim volume; SDK ~2226 "1=+0dB") // D_PAN -> 0.0 (center; SDK ~2227 "trim pan of track, -1..1") // D_WIDTH -> 1.0 (full/neutral stereo width; SDK ~2228 "width, -1..1", // 1.0 = full width = no narrowing/collapse) // D_PANLAW -> 1.0 (no coloring; SDK ~2232 "1=+0dB" — pan-law applies no gain) // I_PANMODE -> 5 (stereo pan; SDK ~2231 "0=classic,3=balance,5=stereo,6=dual") // All are restored to their ORIGINAL values on EVERY exit path (RAII). // // Why also force I_PANMODE (pan mode). D_PAN's effect is mode-dependent. In modes // 0/3/5, D_PAN=0 + D_WIDTH=1 is a provable pass-through. But in mode 6 (dual pan) // D_PAN/D_WIDTH are ignored — routing is governed instead by D_DUALPANL/D_DUALPANR // (SDK ~2229-2230, live only when I_PANMODE==6), whose neutral pass-through the // header does not state as such. Rather than snapshot two more mode-conditional // params and infer their neutral values, we force I_PANMODE=5 (stereo pan) for the // render, where D_PAN=0 + D_WIDTH=1 is unambiguously uncolored, then restore the // original mode. This fully neutralizes pan for every original mode with no // residual — the "handle it fully" the brief requires. (See Snap dual-pan note.) // // Structurally non-destructive: no takes, no items, no project restructuring — // only transient FX-enable + trim-volume toggles, always restored. class FxBypassGuard { public: // scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose // ancestor chains (walked via GetParentTrack) + the master are bypassed per the // plan. proj is the active project (for GetMasterTrack). FxBypassGuard(reasampler::CaptureScope scope, const std::vector& sourceTracks, ReaProject* proj) { const reasampler::FxBypassPlan plan = reasampler::fxBypassPlanFor(scope); for (MediaTrack* tr : sourceTracks) { if (!tr) continue; if (plan.bypassSelfFx) bypass(tr); if (plan.bypassAncestorFx) { // Walk parents to the top: GetParentTrack returns the immediate // parent (folder) track, nullptr at the outermost level (SDK // header ~2407). The master is NOT returned here — handled below. for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p)) bypass(p); } } if (plan.bypassMaster) { // GetMasterTrack(proj) -> the master track (SDK header ~1925). bypass() // neutralizes its FX (I_FXEN), gain (D_VOL) AND pan/width/law/mode on it // just like any other in-scope track; only the master's summing/routing // topology (the mix bus itself) remains — that is not a per-track param. if (MediaTrack* master = GetMasterTrack(proj)) bypass(master); } } ~FxBypassGuard() { // Restore in reverse for symmetry (order is not load-bearing — each track // appears once, snapshots are independent). EVERY snapshotted param is // restored to its ORIGINAL value on this (every) exit path. Restore // I_PANMODE before the pan values so any mode-conditional params (e.g. dual // pan) settle under the original mode. for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it) { SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen); SetMediaTrackInfo_Value(it->track, "D_VOL", it->vol); SetMediaTrackInfo_Value(it->track, "I_PANMODE", it->panmode); SetMediaTrackInfo_Value(it->track, "D_PAN", it->pan); SetMediaTrackInfo_Value(it->track, "D_WIDTH", it->width); SetMediaTrackInfo_Value(it->track, "D_PANLAW", it->panlaw); } } FxBypassGuard(const FxBypassGuard&) = delete; FxBypassGuard& operator=(const FxBypassGuard&) = delete; private: // One snapshot per bypassed track: all params we neutralize, at their originals. // panmode captures I_PANMODE so we can force stereo-pan for the render and put // the original mode back — which also makes D_DUALPANL/D_DUALPANR (live only when // I_PANMODE==6, SDK ~2229-2230) irrelevant during the render without us having to // touch or guess neutral values for them. struct Snap { MediaTrack* track; double fxen; double vol; double pan; double width; double panlaw; double panmode; }; std::vector snapshots_; // Snapshot every neutralized param once per track (dedup: an ancestor shared by // two selected tracks must be restored to its ORIGINAL values, not to a // re-snapshot of the already-neutralized state), then read ALL originals, push // one Snap, and set all to neutral — bypass FX, unity gain, uncolored pan chain. void bypass(MediaTrack* tr) { for (const Snap& s : snapshots_) if (s.track == tr) return; // already done // Read ALL originals first (atomic snapshot), then push, then neutralize. const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN"); const double vol = GetMediaTrackInfo_Value(tr, "D_VOL"); const double pan = GetMediaTrackInfo_Value(tr, "D_PAN"); const double width = GetMediaTrackInfo_Value(tr, "D_WIDTH"); const double panlaw = GetMediaTrackInfo_Value(tr, "D_PANLAW"); const double panmode = GetMediaTrackInfo_Value(tr, "I_PANMODE"); snapshots_.push_back({tr, fxen, vol, pan, width, panlaw, panmode}); SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194) SetMediaTrackInfo_Value(tr, "D_VOL", 1.0); // 1.0 = unity gain (SDK ~2226) SetMediaTrackInfo_Value(tr, "I_PANMODE", 5.0); // 5 = stereo pan (SDK ~2231) SetMediaTrackInfo_Value(tr, "D_PAN", 0.0); // 0.0 = center (SDK ~2227) SetMediaTrackInfo_Value(tr, "D_WIDTH", 1.0); // 1.0 = full width (SDK ~2228) SetMediaTrackInfo_Value(tr, "D_PANLAW", 1.0); // 1.0 = +0dB, no law (SDK ~2232) } }; // Renders one CaptureRequest through the offline backend under the scope's // FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and // RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE // place: the out-of-scope FX / fader / pan chain is snapshotted, neutralized for the // render, and fully restored on every path (RAII). Non-destructive; touches no // timeline item (load-bearing principle) — it writes a file only. static reasampler::CaptureResult renderOffline( reasampler::CaptureScope scope, const std::vector& sourceTracks, const reasampler::CaptureRequest& req) { ReaProject* proj = EnumProjects(-1, nullptr, 0); FxBypassGuard fxGuard(scope, sourceTracks, proj); reasampler::OfflineRenderBackend backend; return backend.capture(req); } // Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, // and adds the resulting Sample to the ACTIVE bank + records the created file in the // owned-file manifest — WITHOUT persisting. The caller persists once (single-capture: // right after; batch: once at the end) so a batch does not write ext state N times. // // Provenance is read from the LIVE selection here, so a batch that transiently // selects exactly one item per unit gets per-unit-correct provenance. `src` supplies // the source tracks (FX bypass + Sample GUIDs); `scope` drives the bypass plan and // provenance scope. Returns the backend's CaptureResult (status + message) so the // caller can report success/failure. Load-bearing principle holds: writes a file + // a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the // out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard), // and the backend restores every RENDER_* setting. // // On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id // on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8 // capture+assign path can target the sample actually in the bank. Batch callers ignore // it; the plain capture actions are unaffected. static reasampler::CaptureResult captureAndIndexOne( reasampler::CaptureScope scope, const ResolvedSource& src, const std::string& baseName, double startSeconds, double endSeconds) { // The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action // variant: the capture actions apply whatever the panel is set to. Default is None // (exact bounds / byte-identical to today) until the user opts in via the toggle. const reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); reasampler::CaptureRequest req; req.sourceMode = reasampler::sourceModeForScope(scope); req.startSeconds = startSeconds; // exact bounds — no rounding req.endSeconds = endSeconds; req.wetDry = 1.0; // wet post the FX left enabled by the scope req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto req.sampleRate = 0; // follow project rate req.channelCount = 2; req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither req.baseName = baseName; req.trackGuids = src.trackGuids; // recorded on the Sample (provenance) // M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain — // the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and // the source selection is still live here. Returns nullopt unless this capture // genuinely resamples from a bank sample (detectParent). Read-only. const std::optional prov = buildCaptureProvenance(req, scope, src); // Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain // neutralized for the render, fully restored on every path). Writes a file only. reasampler::CaptureResult res = renderOffline(scope, src.sourceTracks, req); if (res.status != reasampler::CaptureStatus::Ok) return res; // Stamp provenance onto the captured Sample (only set when this was a genuine // resample-from-sample; otherwise the optional stays empty, per M1's contract). res.sample.provenance = prov; // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). The // AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can // target the sample actually in the bank (the existing entry on a collapse). const reasampler::AddResult addResult = g_session.bank().add(res.sample); // B-cap: record the created file in the owned-file manifest, at the same point the // Sample is added. Recorded regardless of the index AddResult — even a hash-collapse // still WROTE a file the tool owns, and the manifest dedups a repeat path itself // (Phase R prune reconciles manifest vs index later). g_session.owned().add(res.sample.relativePath); // Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new // id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a // Collapsed (the file we just rendered deduped onto an already-present sample — assign // THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged. if (addResult == reasampler::AddResult::Collapsed && !res.sample.contentHash.empty()) { if (const reasampler::Sample* existing = g_session.bank().findByHash(res.sample.contentHash)) res.sample.id = existing->id; } return res; } // Runs one capture-action-table row: resolve its scope source + range, render + add + // record via captureAndIndexOne, then 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. // Returns the bank-index id of the sample the capture landed on: the newly-added id on a // fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign // targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange // capture+assign path reads this to write an assignment request; the plain capture actions // ignore it (their behaviour is unchanged — capture still writes a file + index entry only). static std::string RunCapture(const reasampler::CaptureActionDef& def) { ResolvedSource src; std::string why; if (!ResolveScopeSource(def.scope, src, why)) { ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str()); return {}; } reasampler::CaptureResult res = captureAndIndexOne(def.scope, src, def.baseName, src.startSeconds, src.endSeconds); if (res.status != reasampler::CaptureStatus::Ok) { ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str()); return {}; } // captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE // bank, and recorded the created file in the owned-file manifest (WITHOUT persisting). // Persist the updated book AND manifest into the active project's ext state (the // `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and // travels with the .rpp. saveToActiveProject also clears the retired legacy key and // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys. g_session.saveToActiveProject(); // Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne // resolved res.sample.id to the fresh id on a new add or the existing entry's id on a // hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above). return res.sample.id; } // S8 arrange ingest: capture the selected item / time-selection into the active bank // (reusing the Item-scope capture path verbatim) and, on success, write an assignment // request so the active sampler instance plays the new sample on its next reload. The // capture itself is unchanged — RunCapture writes a file + an index entry and NEVER // inserts a timeline item (load-bearing principle); the only addition here is the // bank-index-id -> assignment-request write after the sample lands. If the capture // failed / no-op'd (empty id), no assignment is written (nothing to assign). // // UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the // assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are // wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically. // An undo that removes the captured sample also clears the assign_request that named it, // preventing a stale request from pointing at a removed sample. The block uses the house // pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero // flag) matching the bank-op family in actions.cpp. static void RunCaptureItemAssign() { // Reuse the Item-scope def from the capture table (index 0) — same range logic, same // FX-scope neutralize, same bank/persist landing as the plain "capture item" action. Undo_BeginBlock2(nullptr); const std::string sampleId = RunCapture(reasampler::captureActionTable()[0]); if (sampleId.empty()) { // Capture failed or no-op'd — RunCapture already reported. Discard the empty point. Undo_EndBlock2(nullptr, "", 0); return; } // Assign inside the same block so undo clears both keys together. reasampler::ingestAssignActiveInstance(g_session.book().activeBankId(), sampleId); Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance", UNDO_STATE_MISCCFG); reasampler::bankPanelRefresh(); ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " "instance.\n"); } // --- M11: batch capture (per selected item / per razor area) ---------------- // // One action fires N captures — one bank sample per selected item (item scope) or per // razor area (track scope, each area's own range). Each individual capture honors every // precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan // neutralize, relative paths, channel preservation) and M10 provenance stamping applies // per capture where its detection rule matches. The load-bearing principle holds: each // unit writes a file + a bank index entry ONLY; nothing lands in the arrange. // // Per-unit FILE NAMING: the offline backend's unique tag is 1-second-granular. The // per-item render already takes real wall-clock time (REAPER's offline-render dialog per // unit), so consecutive units naturally land in distinct seconds; belt-and-braces, each // unit's baseName also carries its ordinal ("item-1", "item-2", ...) so two units are // never asked to write the same stem within one batch. (Residual, DAW-verify: two BATCHES // fired within the same wall-clock second with identical ordinals could still collide — // unreachable in practice given the per-unit render latency, noted for completeness.) // RAII snapshot/restore of the project's media-item selection. Batch item capture must // transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is // selected); the user's ORIGINAL selection must be restored on EVERY exit path — including // a mid-batch failure or early return — because selection restoration is part of the // non-destructive invariant. Snapshot on construct (the currently-selected item set), // restore on destruct (deselect everything, then re-select exactly the snapshot). class ItemSelectionGuard { public: ItemSelectionGuard() { const int n = CountSelectedMediaItems(nullptr); for (int i = 0; i < n; ++i) if (MediaItem* it = GetSelectedMediaItem(nullptr, i)) selected_.push_back(it); } ~ItemSelectionGuard() { // Deselect every item in the project, then re-select the snapshot — restoring the // exact original set regardless of what the batch selected in between. Iterate ALL // items (not just the currently-selected) so any transient selection is cleared. const int total = CountMediaItems(nullptr); for (int i = 0; i < total; ++i) if (MediaItem* it = GetMediaItem(nullptr, i)) SetMediaItemSelected(it, false); for (MediaItem* it : selected_) SetMediaItemSelected(it, true); UpdateArrange(); // reflect the restored selection in the arrange view } ItemSelectionGuard(const ItemSelectionGuard&) = delete; ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete; private: std::vector selected_; }; // Selects exactly `item` (deselect-all then select-one) so the offline render's // selected-items bit (&32) captures a single item. Used inside the batch loop under the // ItemSelectionGuard, which restores the user's original selection afterward. static void selectOnlyItem(MediaItem* item) { const int total = CountMediaItems(nullptr); for (int i = 0; i < total; ++i) if (MediaItem* it = GetMediaItem(nullptr, i)) SetMediaItemSelected(it, it == item); } // Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the // selection (RAII restore on every path), then for each selected item transiently selects // only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the // Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for // the whole batch). Reports a mixed-result summary (explicit-action response — allowed). static void RunBatchCaptureItems() { // Read the selected items up front (pointers stay valid — batch mutates only selection // flags, never adds/removes items). Also capture each item's exact bounds and owning // track NOW, while the full selection is live, before any transient re-selection. struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; }; std::vector itemUnits; { const int n = CountSelectedMediaItems(nullptr); for (int i = 0; i < n; ++i) { MediaItem* it = GetSelectedMediaItem(nullptr, i); if (!it) continue; MediaTrack* tr = GetMediaItem_Track(it); if (!tr) continue; const double pos = GetMediaItemInfo_Value(it, "D_POSITION"); const double len = GetMediaItemInfo_Value(it, "D_LENGTH"); itemUnits.push_back({it, tr, pos, pos + len}); } } if (itemUnits.empty()) { ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n"); return; } // Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/ // inverted item ranges (a zero-length item) are dropped here so no stray render runs. std::vector ranges; ranges.reserve(itemUnits.size()); for (const ItemUnit& u : itemUnits) ranges.push_back({u.start, u.end}); const std::vector plan = reasampler::planCaptureUnits(ranges); reasampler::BatchOutcome outcome; bool anyAdded = false; { // Restore the user's ORIGINAL item selection on every exit path (incl. early // return / mid-batch failure) — non-destructive invariant. ItemSelectionGuard selGuard; // The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only // for those whose range survived planning (same drop rule), matching by ordinal. std::size_t planIdx = 0; for (const ItemUnit& u : itemUnits) { if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep const reasampler::CaptureUnit& unit = plan[planIdx++]; // Transiently select ONLY this item so the item-scope render captures exactly it. selectOnlyItem(u.item); ResolvedSource src; src.startSeconds = unit.startSeconds; src.endSeconds = unit.endSeconds; src.sourceTracks.push_back(u.track); if (std::string g = reasampler::guidString(u.track); !g.empty()) src.trackGuids.push_back(std::move(g)); const std::string baseName = "item-" + std::to_string(unit.ordinal); reasampler::CaptureResult res = captureAndIndexOne( reasampler::CaptureScope::Item, src, baseName, unit.startSeconds, unit.endSeconds); const bool ok = (res.status == reasampler::CaptureStatus::Ok); outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); if (ok) anyAdded = true; } } // selGuard restores the original selection here, on every path // Persist ONCE for the whole batch (one ext-state write) — only if something landed. if (anyAdded) g_session.saveToActiveProject(); ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); } // Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving // track order then area order — the batch analog of resolveRazorRange, which unions them. // Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser. static std::vector> collectRazorAreas() { std::vector> areas; const int n = CountTracks(nullptr); for (int i = 0; i < n; ++i) { MediaTrack* tr = GetTrack(nullptr, i); if (!tr) continue; std::vector buf(8192, '\0'); if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) continue; for (const reasampler::RazorRange& r : reasampler::parseRazorEdits(std::string(buf.data()))) areas.push_back({tr, r}); } return areas; } // RAII snapshot/restore of the project's TRACK selection. Batch razor capture must // transiently select exactly the area's owning track per render (track scope's &128 bit // renders whatever TRACKS are selected); the user's original track selection is restored // on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard. class TrackSelectionGuard { public: TrackSelectionGuard() { const int n = CountSelectedTracks(nullptr); for (int i = 0; i < n; ++i) if (MediaTrack* tr = GetSelectedTrack(nullptr, i)) selected_.push_back(tr); } ~TrackSelectionGuard() { // Deselect every track, then re-select the snapshot — the exact original set. const int total = CountTracks(nullptr); for (int i = 0; i < total; ++i) if (MediaTrack* tr = GetTrack(nullptr, i)) SetTrackSelected(tr, false); for (MediaTrack* tr : selected_) SetTrackSelected(tr, true); } TrackSelectionGuard(const TrackSelectionGuard&) = delete; TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete; private: std::vector selected_; }; // Batch razor capture: one bank sample per razor AREA, track scope over that area's own // range (the area's owning track is the source track). Track scope renders the selected // TRACKS via master (&128), so each unit transiently selects ONLY its owning track // (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original // track selection on every path. The razor selection itself is read-only and left intact. // Persists ONCE at the end. Reports a mixed-result summary. static void RunBatchCaptureRazor() { const std::vector> areas = collectRazorAreas(); if (areas.empty()) { ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n"); return; } std::vector ranges; ranges.reserve(areas.size()); for (const auto& a : areas) ranges.push_back({a.second.startSeconds, a.second.endSeconds}); const std::vector plan = reasampler::planCaptureUnits(ranges); reasampler::BatchOutcome outcome; bool anyAdded = false; { // Restore the user's ORIGINAL track selection on every exit path. TrackSelectionGuard selGuard; std::size_t planIdx = 0; for (const auto& a : areas) { if (!(a.second.endSeconds > a.second.startSeconds)) continue; // dropped — lockstep const reasampler::CaptureUnit& unit = plan[planIdx++]; MediaTrack* tr = a.first; // Transiently select ONLY this track so the track-scope render (&128) captures // exactly it via master (over the custom time bounds we set per unit). SetOnlyTrackSelected(tr); ResolvedSource src; src.startSeconds = unit.startSeconds; src.endSeconds = unit.endSeconds; src.sourceTracks.push_back(tr); if (std::string g = reasampler::guidString(tr); !g.empty()) src.trackGuids.push_back(std::move(g)); const std::string baseName = "razor-" + std::to_string(unit.ordinal); reasampler::CaptureResult res = captureAndIndexOne( reasampler::CaptureScope::Track, src, baseName, unit.startSeconds, unit.endSeconds); const bool ok = (res.status == reasampler::CaptureStatus::Ok); outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); if (ok) anyAdded = true; } } // selGuard restores the original track selection here, on every path if (anyAdded) g_session.saveToActiveProject(); ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str()); } // --- M10: re-capture from source -------------------------------------------- // // Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT // state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and // refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the // load-bearing capture-never-places line, structurally visible: this function has no // insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore // via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places // manually if they want the new version on the timeline. // // Failure modes are handled explicitly and reported to the user (a direct response // to an explicit action is allowed by the console policy): // * the selected sample has no provenance (not a resample) -> reported, no-op. // * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op. // * the recorded source track(s) no longer exist -> reported, no-op. // * the render itself fails to satisfy the recorded request -> reported, no-op. // On success, if the source FX chain drifted since capture (recorded vs current // identity differ) the user is told — the re-capture still reflects the source AS IT // IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source). static void RunRecaptureFromSource() { const std::vector selected = reasampler::bankPanelSelectedSampleIds(); if (selected.empty()) { ShowConsoleMsg("ReaSampler re-capture: select a sample in the bank panel first.\n"); return; } if (selected.size() > 1) { ShowConsoleMsg("ReaSampler re-capture: select a single sample to re-capture.\n"); return; } const std::string sampleId = selected.front(); // Resolve the sample from the bank it lives in (the focused region's displayed bank). const std::string srcBankId = reasampler::bankPanelSelectedSourceBankId(); const reasampler::Bank* bank = g_session.book().bank(srcBankId); const reasampler::Sample* orig = bank ? bank->index.query(sampleId) : nullptr; if (!orig) { ShowConsoleMsg("ReaSampler re-capture: the selected sample is no longer in the bank.\n"); return; } if (!orig->provenance) { ShowConsoleMsg("ReaSampler re-capture: this sample has no provenance " "(it was not resampled from a bank sample).\n"); return; } // Parse the recorded capture recipe from the fingerprint. A legacy / corrupt // string fails gracefully — never a partial re-capture. const std::string recordedParentId = orig->provenance->parentSampleId; const std::string recordedFingerprint = orig->provenance->fxChainSnapshot; const std::optional recipe = reasampler::parseFingerprint(recordedFingerprint); if (!recipe) { ShowConsoleMsg("ReaSampler re-capture: this sample's provenance is unreadable " "(recorded by an older/incompatible build); cannot re-capture.\n"); return; } // Resolve the recorded source track GUID(s) to live tracks. Any missing track is a // hard failure — we will not silently re-capture a different source. std::vector sourceTracks; for (const std::string& g : recipe->trackGuids) { MediaTrack* tr = reasampler::trackByGuid(g); if (!tr) { ShowConsoleMsg("ReaSampler re-capture: a recorded source track no longer " "exists in this project; cannot re-capture from source.\n"); return; } sourceTracks.push_back(tr); } if (sourceTracks.empty()) { // The recipe recorded no source tracks (e.g. an item-scope capture whose source // tracks were not track-scoped). Without a resolvable source we cannot re-run. ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this " "sample; cannot re-capture from source.\n"); return; } const reasampler::CaptureScope scope = recipe->scope == reasampler::ProvenanceScope::Item ? reasampler::CaptureScope::Item : reasampler::CaptureScope::Track; // Rebuild the capture request verbatim from the recorded recipe — the SAME request, // re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate, // channels, bit depth all match the original so an unchanged source produces a // byte-identical file (bit-identical-repeats invariant, consumed as a feature). reasampler::CaptureRequest req; req.sourceMode = static_cast(recipe->sourceMode); req.startSeconds = recipe->startSeconds; req.endSeconds = recipe->endSeconds; req.wetDry = 1.0; req.tailMode = static_cast(recipe->tailMode); req.tailMs = recipe->tailMs; req.sampleRate = recipe->sampleRate; req.channelCount = recipe->channelCount; req.bitDepth = reasampler::WavBitDepth::Float32; req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName; req.trackGuids = recipe->trackGuids; // Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to // compare against the recorded identity for drift reporting. Mirror the same // scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*; // track scope reads the track FX chain via TrackFX_*. std::string currentIdentity; if (scope == reasampler::CaptureScope::Item) { const int n = CountSelectedMediaItems(nullptr); std::vector items; items.reserve(static_cast(n < 0 ? 0 : n)); for (int i = 0; i < n; ++i) { MediaItem* it = GetSelectedMediaItem(nullptr, i); if (it) items.push_back(it); } currentIdentity = reasampler::fxChainIdentityForItems(items); } else { std::vector perTrackNow; perTrackNow.reserve(sourceTracks.size()); for (MediaTrack* tr : sourceTracks) perTrackNow.push_back(reasampler::fxChainIdentityForTrack(tr)); currentIdentity = reasampler::combineChainIdentities(perTrackNow); } const bool drifted = (currentIdentity != recipe->fxChainIdentity); // Render (bank-only; renderOffline never touches the timeline). reasampler::CaptureResult res = renderOffline(scope, sourceTracks, req); if (res.status != reasampler::CaptureStatus::Ok) { ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str()); return; } // Update the Sample IN PLACE: keep its identity (id) and its provenance thread // (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but // adopt the regenerated file's path / hash / length / rate / timestamp. The // fingerprint is rebuilt from the recipe with the CURRENT FX identity so a // subsequent re-capture measures drift from this point, not the original. reasampler::CaptureRecipe refreshed = *recipe; refreshed.fxChainIdentity = currentIdentity; reasampler::Sample updated = *orig; // copy: preserves id, displayName, tier, key updated.relativePath = res.sample.relativePath; updated.contentHash = res.sample.contentHash; updated.sourceMode = res.sample.sourceMode; updated.sourceRange = res.sample.sourceRange; updated.channelCount = res.sample.channelCount; updated.sampleRate = res.sample.sampleRate; updated.lengthSeconds = res.sample.lengthSeconds; updated.captureTempo = res.sample.captureTempo; updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter updated.trackGuids = res.sample.trackGuids; updated.createdTimestamp = res.sample.createdTimestamp; // NOTE: levels, clipped, and lengthBeats are carried from the original (via the // *orig copy above) because the offline backend does not populate them today // (res.sample leaves them at defaults). If a later milestone populates these // fields at capture time, refresh them here from res.sample instead. reasampler::Provenance prov; prov.parentSampleId = recordedParentId; prov.fxChainSnapshot = reasampler::buildFingerprint(refreshed); updated.provenance = prov; // Single batched undo point around the in-place bank mutation (mirrors the bank // action family's R-B pattern). The mutation is index-only ext-state; the render // wrote a new file but placed nothing on the timeline. Undo_BeginBlock2(nullptr); const bool changed = g_session.book().updateSampleInPlace(sampleId, updated); if (changed) { // Record the regenerated file in the owned manifest (a new file the tool wrote); // the superseded old file becomes an orphan reclaimed by Phase R prune. g_session.owned().add(updated.relativePath); const bool persisted = g_session.saveToActiveProject(); // book + manifest + MarkProjectDirty Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", persisted ? UNDO_STATE_MISCCFG : 0); } else { Undo_EndBlock2(nullptr, "", 0); // nothing mutated -> discard the empty point } reasampler::bankPanelRefresh(); // reflect the regenerated file in the docked grid if (drifted) ShowConsoleMsg("ReaSampler re-capture: the source FX chain changed since the " "original capture -- the sample was regenerated from the source's " "current state.\n"); } // STARTS the REALTIME track capture and returns immediately — the record runs across // timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Resolves the // selected tracks + the range (razor-else-time, the same orthogonal range logic as the // offline scopes) and starts recording each selected track's OWN output into a hidden // temp track via RealtimeRecordBackend::begin (a send FROM each source track INTO the // temp — see capture_realtime.cpp §TAP); OnTimer drives it to completion, then adds the // Sample and persists. TRACK scope only this increment (item realtime is deferred). // Dialog-free. Non-bit-identical by nature (it is realtime) — offline stays the // deterministic default. FxBypassGuard is NOT used here — the track-output tap is // PRE-parent by construction (§TAP), so there is no live chain to neutralize. The // load-bearing principle holds structurally — this writes a file + a bank entry ONLY; // the temp track is a transient sink removed by the backend, nothing lands in arrange. // // A SECOND realtime capture requested while one is in progress is REJECTED — the // first keeps running (we own the transport for its window; starting a second would // collide on the transport and the temp-track/arm snapshot). static void RunCaptureRealtimeTrack() { if (g_rtCapture) { ShowConsoleMsg("ReaSampler realtime capture: a capture is already in " "progress -- let it finish (or stop the transport) first.\n"); return; } // Resolve the selected tracks + range exactly as the offline Track scope does. // No track selected -> refuse (same no-op as offline track scope). ResolvedSource src; std::string why; if (!ResolveScopeSource(reasampler::CaptureScope::Track, src, why)) { ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str()); return; } // The tail mode is the SAME panel setting the offline capture actions read (the // docked bank panel's toggle). Realtime honors it via a parallel path: the backend // records a generous window past the range end, then trims by PCM decay-scan (T2 / // capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None // keeps realtime exact-bounds / byte-identical to today. const reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); reasampler::CaptureRequest req; req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope req.startSeconds = src.startSeconds; // exact bounds — no rounding req.endSeconds = src.endSeconds; req.wetDry = 1.0; // fully wet (post-fader tap) req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto req.sampleRate = 0; // follow project rate req.channelCount = 2; req.bitDepth = reasampler::WavBitDepth::Float32; req.baseName = "realtime"; req.trackGuids = src.trackGuids; // provenance on the Sample reasampler::CaptureResult failure; reasampler::RealtimeCaptureHandle st = g_rtBackend.begin(req, src.sourceTracks, failure); if (!st) { // begin() validated/failed and already restored anything it touched. ShowConsoleMsg(("ReaSampler realtime capture failed: " + failure.message + "\n").c_str()); return; } // Started. Store the in-flight state + its project; OnTimer drives it to // completion across ticks (UI stays responsive). g_rtCaptureProject = EnumProjects(-1, nullptr, 0); g_rtCapture = std::move(st); } // Cancels the in-flight realtime capture on demand (bindable action). Force-terminates // via abort() — stop the transport + restore ALL snapshotted state (non-destructive), // committing whatever audio was already captured (best effort) so a cancel near the end // still keeps the take. Runs only against the record's OWN project (abort() self-guards // the closed-project case, review §1). No-op with a note when nothing is in flight. static void RunCancelRealtime() { if (!g_rtCapture) { ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n"); return; } reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); if (r.status == reasampler::RealtimeTickStatus::Done) CommitRealtimeResult(r.result); // Ok: keep what was captured up to the cancel else ShowConsoleMsg(("ReaSampler realtime capture cancelled -- " + r.result.message + "\n").c_str()); g_rtCapture.reset(); g_rtCaptureProject = nullptr; } // Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor // via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match // variant (never silent — it fires only from the distinct "conform" action). This // is the INTENDED placement path: it adds items to the arrange on purpose // (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action. static void RunInsertSelected(bool conform) { reasampler::InsertRequest req; // target defaults to CurrentTrack (InsertOptions::target) — inserts onto the // user's currently-selected track(s) at the edit cursor. req.options.conform = conform ? reasampler::TempoConform::Ratio1x : reasampler::TempoConform::None; // preservePitch stays true: a tempo conform matches tempo without varispeeding // pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.) reasampler::InsertResult res = reasampler::runInsert(&g_session, req); switch (res.status) { case reasampler::InsertStatus::Ok: break; // success — no console chatter case reasampler::InsertStatus::NoSelection: // "select a track first" is printed by runInsert when no track is // selected; this branch covers the no-panel-selection case. ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n"); break; case reasampler::InsertStatus::NoProject: ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n"); break; case reasampler::InsertStatus::NothingResolved: ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n"); break; } } // REAPER calls this for EVERY action fired anywhere; claim only our own id, // return false otherwise so REAPER keeps looking. static bool OnHookCommand(int command, int /*flag*/) { if (command == 0) return false; // Three-scope 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_cmdCaptureItemAssign) { RunCaptureItemAssign(); return true; } if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; } if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } if (command == g_cmdCaptureBatchItems) { RunBatchCaptureItems(); return true; } if (command == g_cmdCaptureBatchRazor) { RunBatchCaptureRazor(); return true; } if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; } if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; } if (command == g_cmdRecaptureFromSource) { RunRecaptureFromSource(); return true; } if (command == g_cmdShowVersion) { // On-demand version readout — the ONLY version output on any path. ShowConsoleMsg(("ReaSampler " + reasampler::appVersion() + "\n").c_str()); return true; } // Design View action family (D4). Claims only its own ids; returns false for the // rest so this hook keeps looking (per the contract). if (reasampler::designViewHandleCommand(command)) return true; // Multi-bank action family (B3). Same contract: claims only its own ids. if (reasampler::bankHandleCommand(command)) return true; // S8 ingest action family (Media-Explorer import). Same contract. if (reasampler::ingestHandleCommand(command)) return true; return false; } // REAPER polls this to render each of OUR actions' checked state in menus/toolbars. // Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract). static int OnToggleAction(int command) { if (command == g_cmdToggleBankPanel) return reasampler::bankPanelIsOpen() ? 1 : 0; return -1; // not ours / non-toggling } // gaccel storage must outlive registration — REAPER holds the pointer. // (The capture family's accels live in g_captureAccels, sized to the table.) static gaccel_register_t g_accelToggleBankPanel{}; static gaccel_register_t g_accelCaptureItemAssign{}; static gaccel_register_t g_accelInsertSelected{}; static gaccel_register_t g_accelInsertSelectedConform{}; static gaccel_register_t g_accelCaptureBatchItems{}; static gaccel_register_t g_accelCaptureBatchRazor{}; static gaccel_register_t g_accelCaptureTrackRealtime{}; static gaccel_register_t g_accelCancelRealtime{}; static gaccel_register_t g_accelRecaptureFromSource{}; static gaccel_register_t g_accelShowVersion{}; // gaccel desc storage. The Actions-list label is channel-qualified at runtime // (channelActionName) so it cannot be a string literal; REAPER holds the gaccel's `desc` // pointer, so each label lives here for the module lifetime. Composed once at registration. static std::string g_descToggleBankPanel; static std::string g_descCaptureItemAssign; static std::string g_descInsertSelected; static std::string g_descInsertSelectedConform; static std::string g_descCaptureBatchItems; static std::string g_descCaptureBatchRazor; static std::string g_descCaptureTrackRealtime; static std::string g_descCancelRealtime; static std::string g_descRecaptureFromSource; static std::string g_descShowVersion; // Composed command-id strings (channel-qualified), interned so register and the mirroring // '-command_id' unregister pass the SAME pointer. Set during registration; read on unload. static const char* g_idToggleBankPanel = nullptr; static const char* g_idCaptureItemAssign = nullptr; static const char* g_idInsertSelected = nullptr; static const char* g_idInsertSelectedConform = nullptr; static const char* g_idCaptureBatchItems = nullptr; static const char* g_idCaptureBatchRazor = nullptr; static const char* g_idCaptureTrackRealtime = nullptr; static const char* g_idCancelRealtime = nullptr; static const char* g_idRecaptureFromSource = nullptr; static const char* g_idShowVersion = nullptr; extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec) { if (!rec) { // rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every // callback with the same strings prefixed '-' (per the contract). if (g_rec) { // Abort any in-flight realtime capture FIRST, while the API pointers are // still live — finalize-or-abort + restore so we never leave a temp track, // an armed track, or an altered transport/cursor in the user's project on // unload. Commit whatever was captured (best effort) before tearing down. if (g_rtCapture) { reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); CommitRealtimeResult(r.result); g_rtCapture.reset(); g_rtCaptureProject = nullptr; } g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-projectconfig", (void*)&g_projectConfig); g_rec->Register("-toggleaction", (void*)&OnToggleAction); g_rec->Register("-hookcommand", (void*)&OnHookCommand); // Tear down the Design View action family (D4) — mirror-unregisters each // gaccel + command_id with '-'-prefixed strings. After the hook is gone. reasampler::designViewUnregisterActions(g_rec); // Tear down the multi-bank action family (B3) — same mirror-unregister. reasampler::bankUnregisterActions(g_rec); // Tear down the S8 ingest action family — same mirror-unregister. reasampler::ingestUnregisterActions(g_rec); // Each '-command_id' re-presents the SAME interned, channel-qualified pointer // used at register (g_id*), so the mirror-unregister matches exactly. g_rec->Register("-gaccel", (void*)&g_accelShowVersion); g_rec->Register("-command_id", (void*)g_idShowVersion); g_rec->Register("-gaccel", (void*)&g_accelRecaptureFromSource); g_rec->Register("-command_id", (void*)g_idRecaptureFromSource); g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime); g_rec->Register("-command_id", (void*)g_idCancelRealtime); g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime); g_rec->Register("-command_id", (void*)g_idCaptureTrackRealtime); g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchRazor); g_rec->Register("-command_id", (void*)g_idCaptureBatchRazor); g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchItems); g_rec->Register("-command_id", (void*)g_idCaptureBatchItems); g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform); g_rec->Register("-command_id", (void*)g_idInsertSelectedConform); g_rec->Register("-gaccel", (void*)&g_accelInsertSelected); g_rec->Register("-command_id", (void*)g_idInsertSelected); g_rec->Register("-gaccel", (void*)&g_accelCaptureItemAssign); g_rec->Register("-command_id", (void*)g_idCaptureItemAssign); g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel); g_rec->Register("-command_id", (void*)g_idToggleBankPanel); // Mirror-unregister the capture family: gaccel + command_id per row, with // '-'-prefixed strings (per the contract). The command id is re-composed from // the same suffix + channel prefix used at register — identical string. { 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]); const std::string id = reasampler::channelCommandId(table[i].commandSuffix); g_rec->Register("-command_id", (void*)id.c_str()); } } // Retire the removed M7 command ids (command_id only — we never held a gaccel // for them this session). Clears stale user keybindings on unload. Composed // per channel so a beta clears beta-qualified retired ids, stable clears its own. for (const char* suffix : kRetiredCaptureCmdSuffixes) { const std::string id = reasampler::channelCommandId(suffix); g_rec->Register("-command_id", (void*)id.c_str()); } } // Destroy the docked window and release cached thumbnails before we drop // the API pointers (DockWindowRemove/DestroyWindow need them live). reasampler::bankPanelShutdown(); g_rec = nullptr; return 0; } // ABI guard: the struct layout we compiled against must match this REAPER. if (rec->caller_version != REAPER_PLUGIN_VERSION) return 0; // Resolve every REAPER API function pointer. Returns the number that FAILED // to load; 0 == success. Non-zero usually means REAPER is older than our SDK. if (REAPERAPI_LoadAPI(rec->GetFunc) != 0) return 0; g_hInst = hInstance; g_rec = rec; // Register the three-scope 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. { const auto& table = reasampler::captureActionTable(); g_captureCmdIds.assign(table.size(), 0); g_captureAccels.assign(table.size(), gaccel_register_t{}); g_captureDescs.assign(table.size(), std::string{}); for (std::size_t i = 0; i < table.size(); ++i) { // Compose the channel-qualified id (prefix + suffix) and label // ("ReaSampler[ beta]: " + phrase). The id is interned so unregister re-presents // the same pointer; the label lives in g_captureDescs for the gaccel's lifetime. const int cmd = rec->Register("command_id", (void*)internCmdId(table[i].commandSuffix)); g_captureCmdIds[i] = cmd; if (cmd) { g_captureDescs[i] = reasampler::channelActionName(table[i].descriptionPhrase); g_captureAccels[i].accel.cmd = cmd; g_captureAccels[i].desc = g_captureDescs[i].c_str(); rec->Register("gaccel", (void*)&g_captureAccels[i]); } } } // Point the bank panel at the live session BEFORE registering its action, so // a toggle firing immediately has a session to read (M5). Does not open the // window — only stores the session pointer. reasampler::bankPanelInit(&g_session); // Register the M5 "toggle bank panel" action (command_id -> gaccel -> // hookcommand + toggleaction for the checked state). Id + label are channel-qualified. g_idToggleBankPanel = internCmdId("TOGGLE_BANK_PANEL"); g_cmdToggleBankPanel = rec->Register("command_id", (void*)g_idToggleBankPanel); if (g_cmdToggleBankPanel) { g_descToggleBankPanel = reasampler::channelActionName("toggle bank panel"); g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel; g_accelToggleBankPanel.desc = g_descToggleBankPanel.c_str(); rec->Register("gaccel", (void*)&g_accelToggleBankPanel); rec->Register("toggleaction", (void*)&OnToggleAction); } // Register the S8 "capture selected item / time-selection into bank + assign" action // (command_id -> gaccel -> hookcommand). Reuses the Item-scope offline capture path and // writes an assignment request so the active instance plays the new sample. Channel- // qualified FOREVER-STABLE id (suffix CAPTURE_ITEM_ASSIGN). MIDI-bindable like every // capture action. Registered in the capture family (main.cpp) because it leans on the // capture render machinery here; the other two ingest surfaces live in the ingest family // (Media-Explorer import) and the panel drop callback. g_idCaptureItemAssign = internCmdId("CAPTURE_ITEM_ASSIGN"); g_cmdCaptureItemAssign = rec->Register("command_id", (void*)g_idCaptureItemAssign); if (g_cmdCaptureItemAssign) { g_descCaptureItemAssign = reasampler::channelActionName( "capture selected item into bank + assign to active instance"); g_accelCaptureItemAssign.accel.cmd = g_cmdCaptureItemAssign; g_accelCaptureItemAssign.desc = g_descCaptureItemAssign.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureItemAssign); } // Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two // variants: native-length (default, no stretch) and the EXPLICIT conform-to- // tempo opt-in. Both read the bank panel selection and place at the edit cursor. g_idInsertSelected = internCmdId("INSERT_SELECTED"); g_cmdInsertSelected = rec->Register("command_id", (void*)g_idInsertSelected); if (g_cmdInsertSelected) { g_descInsertSelected = reasampler::channelActionName("insert selected sample at edit cursor"); g_accelInsertSelected.accel.cmd = g_cmdInsertSelected; g_accelInsertSelected.desc = g_descInsertSelected.c_str(); rec->Register("gaccel", (void*)&g_accelInsertSelected); } g_idInsertSelectedConform = internCmdId("INSERT_SELECTED_CONFORM"); g_cmdInsertSelectedConform = rec->Register("command_id", (void*)g_idInsertSelectedConform); if (g_cmdInsertSelectedConform) { g_descInsertSelectedConform = reasampler::channelActionName( "insert selected sample at edit cursor (conform to tempo)"); g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform; g_accelInsertSelectedConform.desc = g_descInsertSelectedConform.c_str(); rec->Register("gaccel", (void*)&g_accelInsertSelectedConform); } // Register the M11 batch-capture actions (command_id -> gaccel -> hookcommand). Each // fires N captures (one bank sample per selected item / per razor area), honoring every // precision invariant per unit and restoring the original selection on every path. // Channel-qualified FOREVER-STABLE ids. g_idCaptureBatchItems = internCmdId("CAPTURE_BATCH_ITEMS"); g_cmdCaptureBatchItems = rec->Register("command_id", (void*)g_idCaptureBatchItems); if (g_cmdCaptureBatchItems) { g_descCaptureBatchItems = reasampler::channelActionName("batch capture selected items (one per item)"); g_accelCaptureBatchItems.accel.cmd = g_cmdCaptureBatchItems; g_accelCaptureBatchItems.desc = g_descCaptureBatchItems.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureBatchItems); } g_idCaptureBatchRazor = internCmdId("CAPTURE_BATCH_RAZOR"); g_cmdCaptureBatchRazor = rec->Register("command_id", (void*)g_idCaptureBatchRazor); if (g_cmdCaptureBatchRazor) { g_descCaptureBatchRazor = reasampler::channelActionName("batch capture razor areas (one per area)"); g_accelCaptureBatchRazor.accel.cmd = g_cmdCaptureBatchRazor; g_accelCaptureBatchRazor.desc = g_descCaptureBatchRazor.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureBatchRazor); } // Register the "capture selected track (realtime)" action (command_id -> gaccel -> // hookcommand). Realtime sibling of the offline CAPTURE_TRACK scope: records the // selected track's own output in realtime into a hidden temp track, moves it into // the bank. Dialog-free. Channel-qualified FOREVER-STABLE id. g_idCaptureTrackRealtime = internCmdId("CAPTURE_TRACK_REALTIME"); g_cmdCaptureTrackRealtime = rec->Register("command_id", (void*)g_idCaptureTrackRealtime); if (g_cmdCaptureTrackRealtime) { g_descCaptureTrackRealtime = reasampler::channelActionName("capture selected track (realtime)"); g_accelCaptureTrackRealtime.accel.cmd = g_cmdCaptureTrackRealtime; g_accelCaptureTrackRealtime.desc = g_descCaptureTrackRealtime.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureTrackRealtime); } // Cancel-in-flight sibling: aborts a running realtime capture (stop + restore). // Channel-qualified FOREVER-STABLE id. g_idCancelRealtime = internCmdId("CANCEL_REALTIME_CAPTURE"); g_cmdCancelRealtime = rec->Register("command_id", (void*)g_idCancelRealtime); if (g_cmdCancelRealtime) { g_descCancelRealtime = reasampler::channelActionName("cancel realtime capture"); g_accelCancelRealtime.accel.cmd = g_cmdCancelRealtime; g_accelCancelRealtime.desc = g_descCancelRealtime.c_str(); rec->Register("gaccel", (void*)&g_accelCancelRealtime); } // Register the M10 "re-capture from source" action (command_id -> gaccel -> // hookcommand). Regenerates the selected provenanced sample from its recorded // source's current state; bank-only, never places on the timeline. Channel- // qualified FOREVER-STABLE id (suffix RECAPTURE_FROM_SOURCE). g_idRecaptureFromSource = internCmdId("RECAPTURE_FROM_SOURCE"); g_cmdRecaptureFromSource = rec->Register("command_id", (void*)g_idRecaptureFromSource); if (g_cmdRecaptureFromSource) { g_descRecaptureFromSource = reasampler::channelActionName("re-capture from source"); g_accelRecaptureFromSource.accel.cmd = g_cmdRecaptureFromSource; g_accelRecaptureFromSource.desc = g_descRecaptureFromSource.c_str(); rec->Register("gaccel", (void*)&g_accelRecaptureFromSource); } // Register the Phase V "show version" action (command_id -> gaccel -> hookcommand). // On-demand only — prints the CMake-sourced version to the console when fired; no // startup print. Channel-qualified FOREVER-STABLE id; label carries the channel prefix // so a beta's "show version" is distinguishable from stable's in the Actions list. g_idShowVersion = internCmdId("SHOW_VERSION"); g_cmdShowVersion = rec->Register("command_id", (void*)g_idShowVersion); if (g_cmdShowVersion) { g_descShowVersion = reasampler::channelActionName("show version"); g_accelShowVersion.accel.cmd = g_cmdShowVersion; g_accelShowVersion.desc = g_descShowVersion.c_str(); rec->Register("gaccel", (void*)&g_accelShowVersion); } // Register the Design View action family (D4): toggle/activate mode, tag/untag/ // show-both selected tracks. Each mints its own command_id + gaccel; the single // hookcommand below routes them via designViewHandleCommand. Registered before // the hook so every id is minted first. reasampler::designViewRegisterActions(rec, &g_session); // Register the multi-bank action family (B3): create/rename/delete/evacuate bank, // activate (cycle + pool), move/copy selected samples to a bank, and the two // full-height layout toggles. Shares g_session with the Design View family; routed // by the same hookcommand via bankHandleCommand. Registered before the hook. reasampler::bankRegisterActions(rec, &g_session); // Register the S8 ingest action family: the Media-Explorer import-into-bank+assign // action. Shares g_session with the other families; routed by the same hookcommand via // ingestHandleCommand. (The arrange capture+assign action is registered in the capture // family above; the drop path is a bank_panel callback, not a bindable action.) reasampler::ingestRegisterActions(rec, &g_session); // One hookcommand routes every ReaSampler action (spike + toggle + Design View). // Registered once, after all command ids are minted. rec->Register("hookcommand", (void*)&OnHookCommand); // Drive project-load / Save-As detection (M4 persist). The timer polls the // active project each tick; on a project load it reloads the bank from ext // state, on a Save-As it relocates the bank folder under the new .rpp. rec->Register("timer", (void*)&OnTimer); // Register the projectconfig hook so an UNDO/REDO state restore reloads the // session's book + view from the restored ext state (R-B). The timer's identity // poll cannot see an undo (same project identity), so this hook owns undo/redo; it // requests a deferred reload that the next timer tick drains (see the hook comment). rec->Register("projectconfig", (void*)&g_projectConfig); return 1; // success — REAPER keeps us loaded }