#pragma once // capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split). // // This header declares the capture *seam* the later milestones fill: // * CaptureRequest — everything a capture needs, source-mode-agnostic. // * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements // (headless, immediate, returns a finished Sample). // * OfflineRenderBackend — the deterministic default; drives the offline scopes. // * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven // across timer ticks; deliberately NOT an ICaptureBackend // (see the SEAM CHOICE note at its declaration). // // It includes bank_model (pure) to hand back a populated Sample, but NO REAPER // headers — the .cpp is the REAPER-facing translation unit. Keeping this header // REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam // without dragging the SDK into every include site. #include #include #include #include "bank_model.h" // MediaTrack is forward-declared (like track_guid.h) so this header stays // REAPER-free while RealtimeRecordBackend::begin can take the resolved source // MediaTrack* to tap. The pointers are opaque here — never dereferenced in a // pure/header context; only the REAPER-facing capture_realtime.cpp touches them. class MediaTrack; namespace reasampler { // Audio bit-depth for the rendered wav. 32-bit float is the M3 default — // rationale lives in capture.cpp next to the sink-config bytes. enum class WavBitDepth { Int16, Int24, Float32, }; // One capture, independent of source mode. Populated by the caller (the action // handler in M3; the action family in M7) and consumed by a backend. // // M3 fills only the fields the master-mix/time-selection path needs; the rest // are declared now so M7/M8 do not reshape the struct (they are the seam). struct CaptureRequest { SourceMode sourceMode = SourceMode::MasterMix; // Sample-accurate render bounds in project seconds. For the M3 spike these // come straight from the time selection (GetSet_LoopTimeRange) — NO rounding. double startSeconds = 0.0; double endSeconds = 0.0; // 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet). // The field is kept as the seam for future true-dry work (M10 null test): // true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires // FX-bypass-around-render or the M8 realtime pre-FX path, and will be // designed alongside the M10 null test. Also recorded on the Sample. double wetDry = 1.0; // Track GUID(s) the capture came from, when the source mode is track-scoped // (SelectedTracks). Empty for master/items/razor. The action layer (M7) // resolves the selection to canonical GUID strings and passes them here; the // backend copies them onto the Sample (it does NOT itself read the selection — // it stays source-agnostic, driven entirely by the request). std::vector trackGuids; // Render tail. Default OFF for the spike (exact bounds, no added silence — // precision invariant). M7 makes this bindable. bool renderTail = false; double tailMs = 0.0; // Output format. 0 sampleRate => follow project rate (deterministic: the // project rate is fixed for a given project). int sampleRate = 0; int channelCount = 2; WavBitDepth bitDepth = WavBitDepth::Float32; // Human base name for the file stem; sanitized by capture_paths. The unique // tag (disambiguator) is supplied separately by the backend caller so the // pure naming logic stays testable. std::string baseName = "capture"; std::string uniqueTag; // e.g. a timestamp/counter; may be empty }; // Outcome of a capture attempt. `Ok` carries the populated Sample; every failure // is an explicit code (never a thrown exception across the REAPER boundary) so // the action handler can log a precise reason. enum class CaptureStatus { Ok, NoProject, // no active project to render / resolve a bank folder EmptyRange, // start >= end: nothing to render UnsupportedMode, // backend does not implement this source mode (M3 scope) UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only) RenderFailed, // the render action ran but produced no output file TransportBusy, // realtime backend: transport already playing/recording — refused }; struct CaptureResult { CaptureStatus status = CaptureStatus::RenderFailed; Sample sample; // valid only when status == Ok std::string message; // human-readable detail for the console log }; // The capture seam. One method: run a request, return a populated Sample (or a // failure code). Backends are non-destructive — they must restore any global // state they touch before returning (OfflineRenderBackend snapshots/restores the // RENDER_* project settings). class ICaptureBackend { public: virtual ~ICaptureBackend() = default; virtual CaptureResult capture(const CaptureRequest& request) = 0; }; // Deterministic offline-render backend. Drives the full offline source family — // master mix / time selection, selected tracks, selected items, razor area — all // wet-only (render_settings.h) with optional tail. The source selection + range // are resolved by the caller (the action layer) and handed in via the // CaptureRequest; the backend drives RENDER_* and never reads the DAW selection // itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend). class OfflineRenderBackend : public ICaptureBackend { public: CaptureResult capture(const CaptureRequest& request) override; }; // --- Realtime-record backend: the ASYNC seam --------------------------------- // // A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport // on REAPER's audio thread and returns immediately — it does NOT block until the // range completes, which takes (end - start) wall-clock seconds. Blocking the main // thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN // ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called // from the same OnTimer that runs session.poll()) advances the in-flight record and // reports when it is done. // // SEAM CHOICE (surfaced): RealtimeRecordBackend deliberately does NOT implement the // synchronous ICaptureBackend — that interface returns a finished Sample from one // call, which no longer fits a record that spans ticks. The two backends have // genuinely different lifecycles (offline is headless + immediate; realtime is // transport-driven + async), so forcing a shared async interface would make offline // fake a lifecycle it does not have (its tick() would always be Done on the first // call — dead code / an LSP smell). Offline stays synchronous and unchanged; the // realtime backend owns this small bespoke async seam, driven by exactly one caller // (main.cpp's OnTimer). This is the split-sync/async fork, chosen over a unified // async interface for that reason. // One tick's verdict from the in-flight record. enum class RealtimeTickStatus { InProgress, // still recording — call tick() again next timer tick Done, // finished (range end reached, or the user stopped) — `result` is set Failed, // an error tore the capture down — `result.message` explains }; struct RealtimeTickResult { RealtimeTickStatus status = RealtimeTickStatus::InProgress; CaptureResult result; // meaningful only when status == Done or Failed }; // The opaque in-flight capture state. Owns the snapshot of everything to restore // (temp track + its receive sends from the source tracks, other tracks' I_RECARM, // transport, edit cursor, time selection) and the record's own project handle. // Defined in // capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject* // leaks here) by holding it behind a forward-declared type + unique_ptr. // // restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope // RAII guard) because the record spans ticks — no single stack frame outlives it. // Every terminal path (normal completion, user stop, error, project switch, unload) // funnels through the same single restore, safe to call once from whichever fires. class RealtimeCaptureState; // Out-of-line deleter so callers (main.cpp) can own a unique_ptr to the opaque // RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the delete is // compiled in capture_realtime.cpp where the type is complete, keeping this header // REAPER-free (load-bearing split). struct RealtimeCaptureStateDeleter { void operator()(RealtimeCaptureState* p) const noexcept; }; using RealtimeCaptureHandle = std::unique_ptr; // Realtime-record backend — captures by RECORDING in realtime (transport-driven) // into a hidden temp track, then moves the recorded file into the bank as a Sample. // For sources offline render cannot do (hardware, performed FX) and as the true // pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline // render has none). Dialog-free: never invokes the offline-render progress window. // // Non-bit-identical by nature (it is realtime); offline stays the deterministic // default. Non-destructive across EVERY terminal path — the review gate — which is // harder here than offline because the record spans ticks: the snapshot + restore // live on RealtimeCaptureState, not a function-scope RAII destructor. // // SCOPE (this increment): TRACK scope only — records the selected track's OWN // output (item + that track's own FX + its own fader/pan, PRE-parent), matching // offline's track scope. This needs NO FxBypassGuard: a send tapping a track's // output is naturally PRE-parent (the parent has not summed it yet), so the tap is // chain-independent by construction. Item realtime is deferred (UnsupportedMode). class RealtimeRecordBackend { public: // Starts a realtime record: validates the request (track scope, non-empty range, // at least one source track, active + saved project, transport idle), snapshots // all state to restore, creates the hidden temp track, routes a send FROM each // source track INTO the temp track, arms, and CSurf_OnRecord — then returns // IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to // tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free, // carrying only the provenance GUIDs). On success the returned unique_ptr owns the // in-flight state; drive it with tick(). On a validation/setup failure returns // nullptr and fills `outFailure` with the CaptureStatus + message (nothing was // left mutated — begin() restores on its own failure paths). RealtimeCaptureHandle begin(const CaptureRequest& request, const std::vector& sourceTracks, CaptureResult& outFailure); // Advances the in-flight record one tick. Reads the transport (bound to the // record's OWN project handle so a project switch cannot confuse it), and on a // terminal verdict stops the transport, finalizes the recorded file into the // bank Sample (Done) or reports the failure (Failed), then restores ALL // snapshotted state. Returns InProgress while the record is still running. // After Done/Failed the state is spent — the caller drops the unique_ptr. RealtimeTickResult tick(RealtimeCaptureState& state); // Force-terminate an in-flight record NOW without waiting for the range end: // stops the transport, finalizes whatever was captured (best effort) or abandons // it, and restores ALL snapshotted state. For the shutdown / project-switch // paths (extension unload, a new project became active) where the record must // not leak a temp track / armed track / altered transport into the user's // project. Idempotent — safe even if a prior tick already tore the state down. RealtimeTickResult abort(RealtimeCaptureState& state); }; } // namespace reasampler