#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 #include #include #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 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