bd187d4f89
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.
231 lines
12 KiB
C++
231 lines
12 KiB
C++
#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
|