feat(capture): M8 async realtime-record backend (master scope)

Timer-driven realtime capture behind ICaptureBackend (begin/tick/abort via
OnTimer, non-blocking). Records master into a hidden temp track, moved to the
bank non-destructively with idempotent restore across every terminal path.
Pure phase machine unit-tested. Track/item deferred; realtime is non-deterministic.
This commit is contained in:
2026-07-23 15:16:36 -04:00
parent e1b2c0ba1b
commit bd187d4f89
7 changed files with 1672 additions and 9 deletions
+230
View File
@@ -0,0 +1,230 @@
#pragma once
// realtime_record — the REAPER-free logic behind the realtime-record backend (M8).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The realtime backend (capture.cpp)
// drives the transport, the temp track, the send routing, and the file move —
// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong
// pieces are split out here and unit-tested outside the DAW:
//
// 1. the record-mode/recipe bookkeeping: given a capture scope + a desired
// FX-tap point (post-fader / pre-FX / post-FX-pre-fader), the I_RECMODE and
// I_RECMODE_FLAGS integer values the temp track must carry.
// 2. the recorded-file -> Sample mapping: given a finished capture (the
// recorded file's project-relative path + the request's own bounds/format),
// the populated Sample handed to bank_model. Mirrors the inline Sample
// population OfflineRenderBackend does — factored out so it is tested once,
// without a DAW, and shared shape with the offline path is guaranteed.
//
// The I_RECMODE / I_RECMODE_FLAGS bit MEANINGS are transcribed verbatim from
// reaper_plugin_functions.h line ~2197-2198 (see kRecMode* constants); the CHOICE
// of which values each scope needs is this module's logic and is tested.
#include <cstdint>
#include <string>
#include <vector>
#include "bank_model.h" // Sample, SourceMode (pure)
namespace reasampler {
// --- I_RECMODE values (verbatim from SDK header ~2197) -----------------------
//
// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none,
// 3=stereo out w/latency compensation, 4=midi output, 5=mono out,
// 6=mono out w/ latency compensation, 7=midi overdub, 8=midi replace.
//
// We record a track's OUTPUT (the scoped signal routed into the temp track),
// latency-compensated, so the recorded file lines up sample-accurately with the
// source. Stereo vs mono is chosen by the request's channel count.
inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp
inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp
// --- I_RECMODE_FLAGS values (verbatim from SDK header ~2198) ------------------
//
// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader).
//
// This is the ONLY documented pre-FX tap in the whole SDK — offline render has no
// pre-FX bit (see render_settings.h note + the M10 null-test note in PLAN.md).
// The realtime backend is therefore the true pre-FX "dry" path.
inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet)
inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry)
inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader
// The tap point on the source track's output the temp track records from.
// Orthogonal to the record mode (stereo/mono); this only sets the &3 flags bits.
enum class OutputTap {
PostFader, // fully wet, after this track's fader (kRecOutPostFader)
PreFx, // true dry, before this track's FX (kRecOutPreFx)
PostFxPreFader, // wet FX, before the fader (kRecOutPostFxPreFader)
};
// The concrete record-mode values a temp track must carry to capture the scoped
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags`
// sets the &3 output-recording tap bits (higher bits are left at their default 0
// here — we only own the tap-point bits).
struct RecordModePlan {
int recMode = kRecModeStereoOutLatComp;
int recModeFlags = kRecOutPostFader;
};
// Maps (channelCount, tap) to the record-mode values.
// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp.
// tap -> the &3 output-recording bits.
// Pure so the "which I_RECMODE for N channels + this tap" rule is unit-tested
// without a DAW; the shell reads the request and applies these via
// SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap);
// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any
// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing
// capability). Kept pure + separate from recordModePlanFor so the wet/dry ->
// tap decision is tested on its own; PostFxPreFader is not selected by wetDry
// (it is an explicit future option, not on the wet/dry axis).
OutputTap outputTapForWetDry(double wetDry);
// --- Recorded-file -> Sample mapping ----------------------------------------
//
// The inputs a finished realtime capture yields, gathered by the shell into a
// pure struct so the Sample population is a single tested transform (mirror of
// the inline population in OfflineRenderBackend::capture).
struct RecordedCapture {
// Project-relative path of the recorded file (relative-paths-only invariant;
// the shell resolves REAPER's recorded absolute path back to project-relative).
std::string relativePath;
// The disambiguating tag that named the file (feeds the Sample id, so id and
// file name stay consistent — same discipline as the offline path).
std::string uniqueTag;
// Echoed from the request (exact bounds — no re-measuring the file).
SourceMode sourceMode = SourceMode::Realtime;
double startSeconds = 0.0;
double endSeconds = 0.0;
double wetDry = 1.0;
std::string displayName;
std::vector<std::string> trackGuids;
int channelCount = 0;
int sampleRate = 0; // 0 when the project rate was unknown (as offline)
double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo)
std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock)
};
// Builds the Sample for a finished realtime capture. Deliberately identical in
// shape to OfflineRenderBackend's population: exact request bounds (no rounding),
// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start.
// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline).
Sample sampleFromRecordedCapture(const RecordedCapture& cap);
// --- Async record-phase state machine (M8 rework) ----------------------------
//
// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport on
// REAPER's audio thread and returns immediately — it does NOT block until the range
// completes). The completion decision — "given where the transport is now, should
// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and
// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is
// factored out here; the REAPER shell only reads the transport/clock/file and applies
// the verdict (stop, wait for the file to flush, then finalize/abort + restore).
//
// The lifecycle has TWO waits, not one:
// 1. the RECORD wait (Recording): the transport is running; we wait for the play
// cursor to reach the range end — OR the user stops early — OR a wall-clock
// safety ceiling trips (a started-but-never-advancing transport, §3 of review).
// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes
// the recorded take on the AUDIO thread — the file may not be fully written/closed
// for a tick or two. We defer the file move until the file exists AND is stable
// (§2 of review), bounded by a flush ceiling so a file that never appears fails
// cleanly rather than hanging.
// Where an in-progress capture is in its lifecycle.
// Recording — live: transport running, shell keeps ticking.
// Finalizing — live-but-stopped: transport halted, shell stops the transport once
// then ticks waiting for the recorded file to flush/stabilize.
// Done — terminal: the file is flushed + stable, finalize (move + Sample) now.
// Failed — terminal: the flush ceiling tripped without a stable file — give up
// (RenderFailed) + restore. (A record that produced NO file at all also
// lands here via the shell's finalize returning RenderFailed.)
// Only Recording and Finalizing are live phases the shell advances per tick; Done and
// Failed are the shell's verdict to act on (finalize-or-fail, then restore).
enum class RecordPhase {
Recording,
Finalizing,
Done,
Failed
};
// A distilled transport reading for the pure transition, so the state machine never
// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition`
// is GetPlayPositionEx (latency-compensated what-you-hear position).
struct TransportReading {
bool recording = false;
double playPosition = 0.0;
};
// Everything the pure transition needs beyond the current phase, gathered by the
// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling
// decision (the shell only reads and reports; it never decides a transition itself).
struct RecordTickInputs {
TransportReading transport;
// Wall-clock seconds since begin() (the shell reads a steady clock). Drives the
// record safety ceiling: a transport that starts but never advances to the range
// end (stuck / looping) would otherwise keep the machine in Recording forever.
double elapsedSeconds = 0.0;
// Wall-clock seconds spent in the Finalizing phase (since the transport stop).
// Drives the flush ceiling: bound the deferred-finalize wait so a file that never
// stabilizes fails cleanly instead of hanging.
double finalizingSeconds = 0.0;
// Whether the recorded take's file exists AND is stable/closed this tick (the
// shell resolves the take source path and checks size-stable-across-a-tick).
// Only consulted in Finalizing.
bool fileReady = false;
};
// --- Safety ceilings (named constants, review §2/§3) -------------------------
//
// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form
// the record wall-clock ceiling. Generous so a normal record (with pre-roll, count-in,
// or transport latency) never trips it; tight enough that a stuck transport is force-
// terminated within a few seconds of overrun.
inline constexpr double kRecordMarginSeconds = 5.0;
// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the
// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the
// take on the audio thread within a tick or two in practice; this is a generous bound.
inline constexpr double kFinalizeFlushCeilingSeconds = 5.0;
// The pure transition: given the current phase, this tick's inputs, and the record
// range end, return the next phase. Total + deterministic.
//
// From Recording:
// * recording AND cursor < end AND under the record ceiling -> Recording (wait)
// * recording AND cursor >= end -> Finalizing (reached end)
// * NOT recording -> Finalizing (stopped early)
// * recording BUT over the record ceiling (end-start+margin)-> Finalizing (stuck: forced)
// From Finalizing:
// * fileReady -> Done (flushed + stable)
// * over the flush ceiling without a stable file -> Failed (give up)
// * otherwise -> Finalizing (keep flushing)
// Done and Failed are sticky: feeding a terminal phase back returns it unchanged, so a
// late tick before teardown finishes cannot flip the verdict (the idempotence the
// shell's single-restore relies on).
RecordPhase advanceRecordPhase(RecordPhase current,
const RecordTickInputs& inputs,
double rangeStartSeconds,
double rangeEndSeconds);
// True once the shell must STOP the transport and begin the flush wait — i.e. the
// phase has left Recording (Finalizing/Done/Failed). Used by the shell to fire the
// (idempotent) transport stop exactly on the Recording -> Finalizing edge.
bool isStopRequested(RecordPhase phase);
// True for the phases the shell must ACT on to conclude (finalize-or-fail + restore).
// Only Done and Failed are terminal; Recording and Finalizing are live.
bool isTerminalPhase(RecordPhase phase);
} // namespace reasampler