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
+238 -4
View File
@@ -18,6 +18,7 @@
#include "reaper_plugin.h"
#include "reaper_plugin_functions.h"
#include <memory>
#include <string>
#include <vector>
@@ -84,6 +85,19 @@ static int g_cmdToggleBankPanel = 0;
static int g_cmdInsertSelected = 0;
static int g_cmdInsertSelectedConform = 0;
// Command id for the M8 "capture master (realtime)" action. FOREVER-STABLE string.
// Records the master output in realtime (transport-driven) into a hidden temp track
// via RealtimeRecordBackend, then moves the recorded file into the bank. The
// realtime SIBLING of the offline CAPTURE_MASTER scope action: same range logic
// (razor-else-time), same bank/persist path, different backend. Dialog-free.
static int g_cmdCaptureMasterRealtime = 0;
// Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string.
// Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user
// who started a long capture can bail without waiting for the range end or hunting for
// the transport-stop. No-op (with a note) when nothing is in flight.
static int g_cmdCancelRealtime = 0;
// The persistence session (M4): owns the in-memory BankIndex and bridges it to
// project ext state. A timer tick drives g_session.poll() to detect project
// load / Save-As; capture adds Samples to g_session.bank(); after a capture we
@@ -91,11 +105,96 @@ static int g_cmdInsertSelectedConform = 0;
// the .rpp. Replaces the M3 session-only g_bank.
static reasampler::ReaSamplerSession g_session;
// --- M8 in-flight realtime capture (async, timer-driven) --------------------
// A realtime record spans many timer ticks (it takes end-start wall-clock seconds
// and must NOT block REAPER's UI). The action STARTS it (g_rtBackend.begin), which
// returns immediately with the in-flight state owned here; OnTimer drives it
// (g_rtBackend.tick) each tick until a terminal verdict; then this pointer is
// cleared. Non-null == a capture is in progress (used to reject a second one, and to
// abort on project switch / unload).
static reasampler::RealtimeRecordBackend g_rtBackend;
static reasampler::RealtimeCaptureHandle g_rtCapture;
// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets
// OnTimer detect a project switch mid-capture and abort+restore rather than leak the
// temp track/arm/transport into or across projects. Only meaningful when
// g_rtCapture != nullptr.
static ReaProject* g_rtCaptureProject = nullptr;
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
// Sample to the bank, persist + MarkProjectDirty, log. Shared by the tick-completion
// path and the abort paths. On a non-Ok result, logs the failure only.
static void CommitRealtimeResult(const reasampler::CaptureResult& res)
{
if (res.status != reasampler::CaptureStatus::Ok)
{
ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str());
return;
}
reasampler::AddResult added = g_session.bank().add(res.sample);
g_session.saveToActiveProject(); // persist + MarkProjectDirty (travels with .rpp)
std::string log = "ReaSampler: " + res.message + "\n";
log += " bank size now " + std::to_string(g_session.bank().size()) +
(added == reasampler::AddResult::Added ? " (added)\n"
: added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n"
: " (rejected)\n");
ShowConsoleMsg(log.c_str());
}
// Advance any in-flight realtime capture one tick. Cheap when none is running (a
// null check) and fast even mid-record (tick() only reads the transport until the
// terminal tick). Detects a project switch mid-capture and aborts+restores so the
// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so
// poll's project-switch handling sees a cleaned-up project.
static void DriveRealtimeCapture()
{
if (!g_rtCapture) return;
// Project switch guard: if the active project is no longer the one the capture
// belongs to, a new/other project became active mid-record — abort + restore
// (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize
// into the new project.
ReaProject* active = EnumProjects(-1, nullptr, 0);
if (active != g_rtCaptureProject)
{
reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
// Only commit if the ORIGINAL project is still open and active would be it —
// on a switch we restored into the original but must not persist into the
// now-active foreign project. Log the outcome without persisting. On a Failed
// abort surface abort()'s own message — it distinguishes a clean tab-switch
// abort from the closed-project DROP (the captured project was closed mid-record,
// review §1: nothing restored because the pointers were already freed).
if (r.status == reasampler::RealtimeTickStatus::Done)
ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record — "
"captured audio restored into the original project; not "
"persisted to avoid crossing projects.\n");
else
ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record — " +
r.result.message + "\n").c_str());
g_rtCapture.reset();
g_rtCaptureProject = nullptr;
return;
}
reasampler::RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture);
if (r.status == reasampler::RealtimeTickStatus::InProgress) return;
// Terminal (Done or Failed): commit/log and drop the in-flight state.
CommitRealtimeResult(r.result);
g_rtCapture.reset();
g_rtCaptureProject = nullptr;
}
// The timer callback REAPER runs periodically (registered via "timer"). It only
// forwards to the session poll — cheap per tick (reads the active project id and
// its .rpp path, acts only on a change).
static void OnTimer()
{
// Advance any in-flight realtime capture FIRST, so a project switch is caught and
// the capture torn down/restored before session.poll() reacts to that switch.
DriveRealtimeCapture();
g_session.poll();
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view
@@ -414,10 +513,12 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
req.baseName = def.baseName;
req.trackGuids = src.trackGuids; // recorded on the Sample (provenance)
// Bypass the out-of-scope FX and neutralize their fader gain to unity for the
// duration of the render (so parent/master fader level is not baked into the
// file). Restored on EVERY exit path below (RAII), including backend failures.
// proj = active project.
// Bypass the out-of-scope FX and neutralize their fader gain (D_VOL -> unity)
// AND full pan chain (D_PAN/D_WIDTH/D_PANLAW/I_PANMODE -> uncolored) for the
// duration of the render — so parent/master fader level AND pan/width/law/mode
// are not baked into the file (see the FxBypassGuard header comment for the
// authoritative neutralize set). Restored on EVERY exit path below (RAII),
// including backend failures. proj = active project.
ReaProject* proj = EnumProjects(-1, nullptr, 0);
FxBypassGuard fxGuard(def.scope, src.sourceTracks, proj);
@@ -444,6 +545,90 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
ShowConsoleMsg(log.c_str());
}
// STARTS the M8 REALTIME master capture and returns immediately — the record runs
// across timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Infers
// the range (razor-else-time, the same orthogonal range logic as the offline scopes)
// and starts recording the master output into a hidden temp track via
// RealtimeRecordBackend::begin; OnTimer drives it to completion, then adds the Sample
// and persists. MASTER scope only this increment (track/item realtime routing is a
// surfaced fork — see capture_realtime.cpp §FORK). Dialog-free. Non-bit-identical by
// nature (it is realtime) — offline stays the deterministic default. FxBypassGuard is
// NOT used here (it neutralizes the live chain, altering the user's monitoring). The
// load-bearing principle holds structurally — this writes a file + a bank entry ONLY;
// the temp track is a transient sink removed by the backend, nothing lands in arrange.
//
// A SECOND realtime capture requested while one is in progress is REJECTED — the
// first keeps running (we own the transport for its window; starting a second would
// collide on the transport and the temp-track/arm snapshot).
static void RunCaptureRealtimeMaster()
{
if (g_rtCapture)
{
ShowConsoleMsg("ReaSampler realtime capture: a capture is already in "
"progress — let it finish (or stop the transport) first.\n");
return;
}
double start = 0.0, end = 0.0;
std::string why;
if (!resolveRange(start, end, why))
{
ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str());
return;
}
reasampler::CaptureRequest req;
req.sourceMode = reasampler::SourceMode::MasterMix; // realtime master scope
req.startSeconds = start; // exact bounds — no rounding
req.endSeconds = end;
req.wetDry = 1.0; // fully wet (post-fader tap)
req.renderTail = false;
req.tailMs = 0.0;
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
req.bitDepth = reasampler::WavBitDepth::Float32;
req.baseName = "realtime";
// No trackGuids — master scope is not track-provenanced.
reasampler::CaptureResult failure;
reasampler::RealtimeCaptureHandle st = g_rtBackend.begin(req, failure);
if (!st)
{
// begin() validated/failed and already restored anything it touched.
ShowConsoleMsg(("ReaSampler realtime capture failed: " + failure.message + "\n").c_str());
return;
}
// Started. Store the in-flight state + its project; OnTimer drives it to
// completion across ticks (UI stays responsive).
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
g_rtCapture = std::move(st);
ShowConsoleMsg("ReaSampler: realtime capture started — recording in the "
"background; the bank updates when it reaches the range end.\n");
}
// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates
// via abort() — stop the transport + restore ALL snapshotted state (non-destructive),
// committing whatever audio was already captured (best effort) so a cancel near the end
// still keeps the take. Runs only against the record's OWN project (abort() self-guards
// the closed-project case, review §1). No-op with a note when nothing is in flight.
static void RunCancelRealtime()
{
if (!g_rtCapture)
{
ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n");
return;
}
reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
if (r.status == reasampler::RealtimeTickStatus::Done)
CommitRealtimeResult(r.result); // Ok: keep what was captured up to the cancel
else
ShowConsoleMsg(("ReaSampler realtime capture cancelled — " +
r.result.message + "\n").c_str());
g_rtCapture.reset();
g_rtCaptureProject = nullptr;
}
// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor
// via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match
// variant (never silent — it fires only from the distinct "conform" action). This
@@ -500,6 +685,8 @@ static bool OnHookCommand(int command, int /*flag*/)
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; }
if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; }
if (command == g_cmdCaptureMasterRealtime) { RunCaptureRealtimeMaster(); return true; }
if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; }
// Design View action family (D4). Claims only its own ids; returns false for the
// rest so this hook keeps looking (per the contract).
if (reasampler::designViewHandleCommand(command)) return true;
@@ -520,6 +707,8 @@ static int OnToggleAction(int command)
static gaccel_register_t g_accelToggleBankPanel{};
static gaccel_register_t g_accelInsertSelected{};
static gaccel_register_t g_accelInsertSelectedConform{};
static gaccel_register_t g_accelCaptureMasterRealtime{};
static gaccel_register_t g_accelCancelRealtime{};
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
@@ -530,12 +719,30 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
// callback with the same strings prefixed '-' (per the contract).
if (g_rec)
{
// Abort any in-flight realtime capture FIRST, while the API pointers are
// still live — finalize-or-abort + restore so we never leave a temp track,
// an armed track, or an altered transport/cursor in the user's project on
// unload. Commit whatever was captured (best effort) before tearing down.
if (g_rtCapture)
{
reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
CommitRealtimeResult(r.result);
g_rtCapture.reset();
g_rtCaptureProject = nullptr;
}
g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
// Tear down the Design View action family (D4) — mirror-unregisters each
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
reasampler::designViewUnregisterActions(g_rec);
g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE"));
g_rec->Register("-gaccel", (void*)&g_accelCaptureMasterRealtime);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_REALTIME"));
g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM"));
@@ -646,6 +853,33 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
rec->Register("gaccel", (void*)&g_accelInsertSelectedConform);
}
// Register the M8 "capture master (realtime)" action (command_id -> gaccel ->
// hookcommand). Realtime sibling of the offline CAPTURE_MASTER scope: records
// the master output in realtime into a hidden temp track, moves it into the
// bank. Dialog-free. FOREVER-STABLE id string.
g_cmdCaptureMasterRealtime = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_REALTIME"));
if (g_cmdCaptureMasterRealtime)
{
g_accelCaptureMasterRealtime.accel.cmd = g_cmdCaptureMasterRealtime;
g_accelCaptureMasterRealtime.desc =
"ReaSampler: capture master (realtime)";
rec->Register("gaccel", (void*)&g_accelCaptureMasterRealtime);
}
// Cancel-in-flight sibling: aborts a running realtime capture (stop + restore).
// FOREVER-STABLE id string.
g_cmdCancelRealtime = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE"));
if (g_cmdCancelRealtime)
{
g_accelCancelRealtime.accel.cmd = g_cmdCancelRealtime;
g_accelCancelRealtime.desc = "ReaSampler: cancel realtime capture";
rec->Register("gaccel", (void*)&g_accelCancelRealtime);
}
// Register the Design View action family (D4): toggle/activate mode, tag/untag/
// show-both selected tracks. Each mints its own command_id + gaccel; the single
// hookcommand below routes them via designViewHandleCommand. Registered before