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:
+238
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user