// capture_orchestrator.cpp — the single-capture orchestration + realtime/insert // action bodies (Q-W3 hoist out of main.cpp; the code moved verbatim, the session // threaded as a parameter). See the header. FxBypassGuard lives here as a STACK // RAII object (precision-invariant-critical — it must restore on every exit path // of exactly one render call). // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API // pointers; here they are extern (CLAUDE.md §contract). #include "shell/capture/capture_orchestrator.h" #include #include #include "bank_panel.h" // bankPanelTailSetting / bankPanelRefresh #include "core/capture/tail_control.h" // TailSetting #include "core/model/provenance.h" // model::Provenance #include "ingest.h" // ingestAssignActiveInstance #include "persist.h" // ReaSamplerSession #include "shell/capture/insert.h" // runInsert / InsertRequest #include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state #include "reaper_plugin.h" // UNDO_STATE_MISCCFG #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetParentTrack #define REAPERAPI_WANT_GetMasterTrack #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_SetMediaTrackInfo_Value #define REAPERAPI_WANT_ShowConsoleMsg #define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler::capture { namespace { // --- 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(CaptureScope scope, const std::vector& sourceTracks, ReaProject* proj) { const FxBypassPlan plan = 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) } }; } // namespace // 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. CaptureResult renderOffline(CaptureScope scope, const std::vector& sourceTracks, const CaptureRequest& req) { ReaProject* proj = EnumProjects(-1, nullptr, 0); FxBypassGuard fxGuard(scope, sourceTracks, proj); 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. CaptureResult captureAndIndexOne(ReaSamplerSession& session, 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 TailSetting tail = bankPanelTailSetting(); CaptureRequest req; req.sourceMode = 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 = 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(session.book(), 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. CaptureResult res = renderOffline(scope, src.sourceTracks, req); if (res.status != 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: 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 model::AddResult addResult = 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). 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 == model::AddResult::Collapsed && !res.sample.contentHash.empty()) { if (const model::Sample* existing = 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). std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def) { ResolvedSource src; std::string why; if (!ResolveScopeSource(def.scope, src, why)) { ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str()); return {}; } CaptureResult res = captureAndIndexOne(session, def.scope, src, def.baseName, src.startSeconds, src.endSeconds); if (res.status != 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. // S9: a capture add is a bank-content change -> bump before the persist so an assigned // live instance refreshes hands-free (the S8 capture+assign path builds on this). session.bumpBankGeneration(); 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. void RunCaptureItemAssign(ReaSamplerSession& session) { // 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(session, 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. ingestAssignActiveInstance(session.book().activeBankId(), sampleId); Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance", UNDO_STATE_MISCCFG); bankPanelRefresh(); ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " "instance.\n"); } // STARTS the REALTIME track capture and returns immediately — the record runs across // timer ticks (DriveRealtimeCapture in realtime_lifecycle), 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_shell.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). void RunCaptureRealtimeTrack(ReaSamplerSession& session) { (void)session; // start path persists nothing — commit happens on the terminal tick 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(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 TailSetting tail = bankPanelTailSetting(); CaptureRequest req; req.sourceMode = 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 = WavBitDepth::Float32; req.baseName = "realtime"; req.trackGuids = src.trackGuids; // provenance on the Sample CaptureResult failure; 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. void RunCancelRealtime(ReaSamplerSession& session) { if (!g_rtCapture) { ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n"); return; } RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); if (r.status == RealtimeTickStatus::Done) CommitRealtimeResult(session, 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. void RunInsertSelected(ReaSamplerSession& session, bool conform) { 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 ? TempoConform::Ratio1x : 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.) InsertResult res = runInsert(&session, req); switch (res.status) { case InsertStatus::Ok: break; // success — no console chatter case 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 InsertStatus::NoProject: ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n"); break; case InsertStatus::NothingResolved: ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n"); break; } } } // namespace reasampler::capture