Files
reasampler/src/shell/capture/capture.h
T

265 lines
15 KiB
C++

#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.
// * OfflineRenderBackend — the deterministic default; a plain CONCRETE class
// (the former ICaptureBackend interface was deleted in
// Q-W3, T4-26 — it had one deriver and zero polymorphic
// call sites; every construction site instantiates the
// concrete type).
// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven
// across timer ticks; a genuinely different lifecycle
// (see the SEAM CHOICE note at its declaration).
// * makeUniqueTag / stampCaptureSample — the shared file-tag mint and the shared
// finished-capture metadata stamp both backends call
// (Q-W3 riders T1-11 / T2-09).
//
// 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 (the capture orchestration TUs) depend on the seam
// without dragging the SDK into every include site.
#include <memory>
#include <string>
#include <vector>
#include "core/model/bank_model.h"
#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract
// MediaTrack / ReaProject are forward-declared (like track_guid.h) so this header
// stays REAPER-free while RealtimeRecordBackend::begin can take the resolved source
// MediaTrack* to tap and stampCaptureSample can take the project handles its reads
// pin. The pointers are opaque here — never dereferenced in a pure/header context;
// only the REAPER-facing capture TUs touch them.
class MediaTrack;
class ReaProject;
namespace reasampler::capture {
using model::Sample;
// 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<std::string> trackGuids;
// Render tail (docs/product/capture-tail.md §The three tail states). Default
// None: exact bounds, no added silence — the precision invariant, and the only
// mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for
// TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the
// 8 s cap + -72 dB trim internally, None ignores it.
TailMode tailMode = TailMode::None;
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
};
// 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).
// Non-destructive: restores every RENDER_* setting it touches on every path.
// A plain concrete class — the former ICaptureBackend interface was deleted
// (Q-W3, T4-26): it had one deriver, zero polymorphic call sites, and the async
// realtime backend deliberately never implemented it (see SEAM CHOICE below).
class OfflineRenderBackend {
public:
CaptureResult capture(const CaptureRequest& request);
};
// --- Shared backend helpers (Q-W3 riders) ------------------------------------
// Mints the filesystem-safe disambiguating tag for one capture's file stem +
// Sample id: "<prefix><unix-epoch-seconds>-<n>" where <n> is a PER-SESSION
// MONOTONIC counter (T1-11 fix). The wall-clock second alone had a collision
// window: two captures of the same baseName within one second derived the same
// stem, so the second render silently overwrote the first file (reachable via
// batch capture driving short renders back-to-back). The counter makes every tag
// of a session distinct regardless of timing. `prefix` is the backend's family
// marker ("" offline, "rt-" realtime).
std::string makeUniqueTag(const std::string& prefix);
// Stamps the SHARED finished-capture metadata onto `s` (T2-09 dedupe — this stamp
// was copy-pasted per backend and had silently diverged): trackGuids +
// channelCount (echoed from the request), the resolved sampleRate (request rate,
// else PROJECT_SRATE read from `rateProj`; 0 stays 0 when unknown), captureTempo
// (Master_GetTempo), the capture-start time signature (TimeMap_GetTimeSigAtTime
// against `timeSigProj` — the offline path passes nullptr = active project, the
// realtime path pins the record's own project; the divergence stays caller-visible
// as this argument), the WAV-aware contentHash of the finished file at
// `absolutePath` (left empty when unreadable — the safe, confirm-eliciting
// direction), and createdTimestamp (now). The per-backend bits (id, paths, bounds,
// tier, realtime's recorded-length override) stay with each caller.
void stampCaptureSample(Sample& s, const CaptureRequest& req,
ReaProject* rateProj, ReaProject* timeSigProj,
const std::string& absolutePath);
// --- 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): the two backends deliberately share NO interface. The
// lifecycles are genuinely different (offline is headless + immediate — one
// synchronous capture() call returns a finished Sample; realtime is
// transport-driven + async — begin/tick/abort across timer ticks), so a shared
// 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; the realtime backend owns this small bespoke async seam,
// driven by exactly one caller (the timer-driven realtime_lifecycle). This is the
// split-sync/async fork, chosen over a unified async interface for that reason.
// (The old synchronous ICaptureBackend interface over OfflineRenderBackend was
// deleted in Q-W3 — T4-26: one deriver, zero polymorphic call sites.)
// 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_shell.cpp; the header stays REAPER-free (nothing is
// dereferenced 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 (realtime_lifecycle) can own a unique_ptr to the
// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the
// delete is compiled in capture_realtime_shell.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<RealtimeCaptureState, RealtimeCaptureStateDeleter>;
// 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<MediaTrack*>& 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::capture