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:
+19
-1
@@ -96,6 +96,18 @@ add_library(render_settings STATIC src/render_settings.cpp)
|
||||
target_include_directories(render_settings PUBLIC src)
|
||||
target_link_libraries(render_settings PUBLIC bank_model)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record
|
||||
# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values,
|
||||
# wet/dry -> tap point, and the recorded-file -> Sample mapping. Split out so
|
||||
# the fiddly record-mode bit values + Sample population are unit-tested outside
|
||||
# the DAW; the transport/temp-track/send/file-move recipe stays in capture.cpp.
|
||||
# Depends on bank_model for the pure Sample / SourceMode types.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(realtime_record STATIC src/realtime_record.cpp)
|
||||
target_include_directories(realtime_record PUBLIC src)
|
||||
target_link_libraries(realtime_record PUBLIC bank_model)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -136,6 +148,10 @@ add_executable(render_settings_tests tests/test_render_settings.cpp)
|
||||
target_link_libraries(render_settings_tests PRIVATE render_settings)
|
||||
add_test(NAME render_settings_tests COMMAND render_settings_tests)
|
||||
|
||||
add_executable(realtime_record_tests tests/test_realtime_record.cpp)
|
||||
target_link_libraries(realtime_record_tests PRIVATE realtime_record)
|
||||
add_test(NAME realtime_record_tests COMMAND realtime_record_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -153,6 +169,8 @@ set(LICE_SRC
|
||||
add_library(reaper_reasampler MODULE
|
||||
src/main.cpp
|
||||
src/capture.cpp
|
||||
src/capture_realtime.cpp
|
||||
src/realtime_record.cpp
|
||||
src/persist.cpp
|
||||
src/bank_panel.cpp
|
||||
src/mode_switch.cpp
|
||||
@@ -165,7 +183,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/track_guid.cpp
|
||||
src/actions.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings realtime_record)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||
|
||||
|
||||
+108
-4
@@ -3,16 +3,19 @@
|
||||
//
|
||||
// This header declares the capture *seam* the later milestones fill:
|
||||
// * CaptureRequest — everything a capture needs, source-mode-agnostic.
|
||||
// * ICaptureBackend — the one interface behind which OfflineRenderBackend
|
||||
// (M3, here) and RealtimeRecordBackend (M8) both sit.
|
||||
// * OfflineRenderBackend — the deterministic default; M3 implements ONLY the
|
||||
// time-selection master-mix case.
|
||||
// * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements
|
||||
// (headless, immediate, returns a finished Sample).
|
||||
// * OfflineRenderBackend — the deterministic default; drives the offline scopes.
|
||||
// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven
|
||||
// across timer ticks; deliberately NOT an ICaptureBackend
|
||||
// (see the SEAM CHOICE note at its declaration).
|
||||
//
|
||||
// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER
|
||||
// headers — the .cpp is the REAPER-facing translation unit. Keeping this header
|
||||
// REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam
|
||||
// without dragging the SDK into every include site.
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -83,6 +86,7 @@ enum class CaptureStatus {
|
||||
UnsupportedMode, // backend does not implement this source mode (M3 scope)
|
||||
UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only)
|
||||
RenderFailed, // the render action ran but produced no output file
|
||||
TransportBusy, // realtime backend: transport already playing/recording — refused
|
||||
};
|
||||
|
||||
struct CaptureResult {
|
||||
@@ -112,4 +116,104 @@ public:
|
||||
CaptureResult capture(const CaptureRequest& request) override;
|
||||
};
|
||||
|
||||
// --- Realtime-record backend: the ASYNC seam ---------------------------------
|
||||
//
|
||||
// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport
|
||||
// on REAPER's audio thread and returns immediately — it does NOT block until the
|
||||
// range completes, which takes (end - start) wall-clock seconds. Blocking the main
|
||||
// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN
|
||||
// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called
|
||||
// from the same OnTimer that runs session.poll()) advances the in-flight record and
|
||||
// reports when it is done.
|
||||
//
|
||||
// SEAM CHOICE (surfaced): RealtimeRecordBackend deliberately does NOT implement the
|
||||
// synchronous ICaptureBackend — that interface returns a finished Sample from one
|
||||
// call, which no longer fits a record that spans ticks. The two backends have
|
||||
// genuinely different lifecycles (offline is headless + immediate; realtime is
|
||||
// transport-driven + async), so forcing a shared async interface would make offline
|
||||
// fake a lifecycle it does not have (its tick() would always be Done on the first
|
||||
// call — dead code / an LSP smell). Offline stays synchronous and unchanged; the
|
||||
// realtime backend owns this small bespoke async seam, driven by exactly one caller
|
||||
// (main.cpp's OnTimer). This is the split-sync/async fork, chosen over a unified
|
||||
// async interface for that reason.
|
||||
|
||||
// One tick's verdict from the in-flight record.
|
||||
enum class RealtimeTickStatus {
|
||||
InProgress, // still recording — call tick() again next timer tick
|
||||
Done, // finished (range end reached, or the user stopped) — `result` is set
|
||||
Failed, // an error tore the capture down — `result.message` explains
|
||||
};
|
||||
|
||||
struct RealtimeTickResult {
|
||||
RealtimeTickStatus status = RealtimeTickStatus::InProgress;
|
||||
CaptureResult result; // meaningful only when status == Done or Failed
|
||||
};
|
||||
|
||||
// The opaque in-flight capture state. Owns the snapshot of everything to restore
|
||||
// (temp track, other tracks' I_RECARM, master send/routing, transport, edit cursor,
|
||||
// time selection) and the record's own project handle. Defined in
|
||||
// capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject*
|
||||
// leaks here) by holding it behind a forward-declared type + unique_ptr.
|
||||
//
|
||||
// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope
|
||||
// RAII guard) because the record spans ticks — no single stack frame outlives it.
|
||||
// Every terminal path (normal completion, user stop, error, project switch, unload)
|
||||
// funnels through the same single restore, safe to call once from whichever fires.
|
||||
class RealtimeCaptureState;
|
||||
|
||||
// Out-of-line deleter so callers (main.cpp) can own a unique_ptr to the opaque
|
||||
// RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the delete is
|
||||
// compiled in capture_realtime.cpp where the type is complete, keeping this header
|
||||
// REAPER-free (load-bearing split).
|
||||
struct RealtimeCaptureStateDeleter {
|
||||
void operator()(RealtimeCaptureState* p) const noexcept;
|
||||
};
|
||||
using RealtimeCaptureHandle =
|
||||
std::unique_ptr<RealtimeCaptureState, RealtimeCaptureStateDeleter>;
|
||||
|
||||
// Realtime-record backend — captures by RECORDING in realtime (transport-driven)
|
||||
// into a hidden temp track, then moves the recorded file into the bank as a Sample.
|
||||
// For sources offline render cannot do (hardware, performed FX) and as the true
|
||||
// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline
|
||||
// render has none). Dialog-free: never invokes the offline-render progress window.
|
||||
//
|
||||
// Non-bit-identical by nature (it is realtime); offline stays the deterministic
|
||||
// default. Non-destructive across EVERY terminal path — the review gate — which is
|
||||
// harder here than offline because the record spans ticks: the snapshot + restore
|
||||
// live on RealtimeCaptureState, not a function-scope RAII destructor.
|
||||
//
|
||||
// SCOPE (this increment): MASTER scope only — records the master-mix output, which
|
||||
// does not need the FxBypassGuard scope isolation (the whole chain is in scope).
|
||||
// Track/Item scopes are a genuine routing fork surfaced to Daniel, NOT silently
|
||||
// built (see capture_realtime.cpp §FORK). A Track/Item request is refused.
|
||||
class RealtimeRecordBackend {
|
||||
public:
|
||||
// Starts a realtime record: validates the request (master scope, non-empty
|
||||
// range, active + saved project, transport idle), snapshots all state to
|
||||
// restore, creates the hidden temp track, routes the master send, arms, and
|
||||
// CSurf_OnRecord — then returns IMMEDIATELY (no wait, no UI block). On success
|
||||
// the returned unique_ptr owns the in-flight state; drive it with tick(). On a
|
||||
// validation/setup failure returns nullptr and fills `outFailure` with the
|
||||
// CaptureStatus + message (nothing was left mutated — begin() restores on its
|
||||
// own failure paths).
|
||||
RealtimeCaptureHandle begin(const CaptureRequest& request,
|
||||
CaptureResult& outFailure);
|
||||
|
||||
// Advances the in-flight record one tick. Reads the transport (bound to the
|
||||
// record's OWN project handle so a project switch cannot confuse it), and on a
|
||||
// terminal verdict stops the transport, finalizes the recorded file into the
|
||||
// bank Sample (Done) or reports the failure (Failed), then restores ALL
|
||||
// snapshotted state. Returns InProgress while the record is still running.
|
||||
// After Done/Failed the state is spent — the caller drops the unique_ptr.
|
||||
RealtimeTickResult tick(RealtimeCaptureState& state);
|
||||
|
||||
// Force-terminate an in-flight record NOW without waiting for the range end:
|
||||
// stops the transport, finalizes whatever was captured (best effort) or abandons
|
||||
// it, and restores ALL snapshotted state. For the shutdown / project-switch
|
||||
// paths (extension unload, a new project became active) where the record must
|
||||
// not leak a temp track / armed track / altered transport into the user's
|
||||
// project. Idempotent — safe even if a prior tick already tore the state down.
|
||||
RealtimeTickResult abort(RealtimeCaptureState& state);
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
// capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
//
|
||||
// Captures the requested scope over the requested range by RECORDING in realtime
|
||||
// (transport-driven) into a hidden temp track, then moves the recorded file into
|
||||
// the bank as a Sample — non-destructively. This increment implements the MASTER
|
||||
// scope only (records the master-mix output). Track/Item scopes are a genuine
|
||||
// routing fork (see §FORK below) and are refused rather than silently half-built.
|
||||
//
|
||||
// ============================================================================
|
||||
// §ASYNC — timer-driven, no UI block (M8 rework — Daniel: "do it right")
|
||||
// ============================================================================
|
||||
// A realtime record takes (end - start) wall-clock seconds. The earlier spike ran a
|
||||
// bounded MAIN-THREAD wait for the transport to reach the range end — which FREEZES
|
||||
// REAPER's UI for the whole record. That is gone. The record is now driven across
|
||||
// timer ticks:
|
||||
// begin() — validate, snapshot ALL state to restore, create the temp track,
|
||||
// route the master send, arm, CSurf_OnRecord, RETURN IMMEDIATELY.
|
||||
// tick() — (from OnTimer, the same tick as session.poll()) read the transport,
|
||||
// and on a terminal verdict stop + finalize/abort + RESTORE everything.
|
||||
// abort() — force-terminate now (shutdown / project switch) + RESTORE everything.
|
||||
//
|
||||
// The snapshot + restore live on RealtimeCaptureState (below), NOT a function-scope
|
||||
// RAII guard — because the record spans ticks, no single stack frame outlives it.
|
||||
// restore() is idempotent (a restored_ latch): every terminal path — normal
|
||||
// completion, user stop, error, second-capture reject, project switch, unload —
|
||||
// funnels through the SAME single restore, safe to call once from whichever fires.
|
||||
// The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the
|
||||
// completion state machine (advanceRecordPhase) all live in realtime_record.{h,cpp}
|
||||
// (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe.
|
||||
//
|
||||
// ============================================================================
|
||||
// §FORK — wet-master / per-scope routing (SURFACED, NOT SILENTLY BUILT)
|
||||
// ============================================================================
|
||||
// The open design question (CONTEXT.md / PLAN.md "realtime wet-master routing"):
|
||||
// tap the SCOPED output into the hidden record track WITHOUT altering the user's
|
||||
// monitoring, with correct latency compensation.
|
||||
//
|
||||
// This increment resolves it for MASTER scope with the cleanest header-verifiable
|
||||
// recipe: a temp track carrying a SEND from the master track, recorded in
|
||||
// output-record mode (I_RECMODE = stereo/mono-out w/latency comp). The temp
|
||||
// track's own B_MAINSEND is cleared (it does NOT sum back into the master), so the
|
||||
// user hears no change or double — the record tap is a pure branch off the master
|
||||
// bus. Latency compensation is REAPER's (I_RECMODE 3/6 are the *latency-compensated*
|
||||
// output modes), so the recorded file lines up with the source.
|
||||
//
|
||||
// Track/Item scopes DO NOT compose cleanly with this recipe (they need per-scope
|
||||
// source-track routing + the send-isolation rule) — that is the fork the brief says
|
||||
// to STOP before, and they are refused with UnsupportedMode. FxBypassGuard is NOT
|
||||
// reused here — it alters live monitoring (wrong tool for realtime); the master-send
|
||||
// recipe needs no chain neutralization.
|
||||
|
||||
#include "capture.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "capture_paths.h"
|
||||
#include "realtime_record.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_GetMasterTrack
|
||||
#define REAPERAPI_WANT_InsertTrackAtIndex
|
||||
#define REAPERAPI_WANT_DeleteTrack
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_CreateTrackSend
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_GetTrackNumMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
||||
#define REAPERAPI_WANT_CSurf_OnRecord
|
||||
#define REAPERAPI_WANT_OnStopButtonEx
|
||||
#define REAPERAPI_WANT_GetPlayStateEx
|
||||
#define REAPERAPI_WANT_GetPlayPositionEx
|
||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
||||
#define REAPERAPI_WANT_GetCursorPosition
|
||||
#define REAPERAPI_WANT_SetEditCurPos
|
||||
#define REAPERAPI_WANT_ValidatePtr2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// A monotonic, filesystem-safe timestamp tag so repeated captures do not collide.
|
||||
std::string makeUniqueTag() {
|
||||
std::time_t now = std::time(nullptr);
|
||||
return "rt-" + std::to_string(static_cast<long long>(now));
|
||||
}
|
||||
|
||||
std::string normSlashes(std::string s) {
|
||||
for (char& c : s) if (c == '\\') c = '/';
|
||||
if (s.size() > 1 && s.back() == '/') s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// Reads the ACTIVE project's .rpp path (empty if unsaved). Only needed at begin()
|
||||
// time, when the record's project IS the active project.
|
||||
std::string readRppPath() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
// Discovers the file REAPER actually recorded onto the temp track: the first media
|
||||
// item's active take's source file. Empty string if nothing was recorded.
|
||||
std::string recordedFilePath(MediaTrack* temp) {
|
||||
if (!temp) return {};
|
||||
if (GetTrackNumMediaItems(temp) <= 0) return {};
|
||||
MediaItem* item = GetTrackMediaItem(temp, 0);
|
||||
if (!item) return {};
|
||||
MediaItem_Take* take = GetMediaItemTake(item, 0);
|
||||
if (!take) return {};
|
||||
PCM_source* src = GetMediaItemTake_Source(take);
|
||||
if (!src) return {};
|
||||
std::vector<char> buf(4096, '\0');
|
||||
GetMediaSourceFileName(src, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
// The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no
|
||||
// item/take/source, or the file does not exist on disk this tick). Used by the flush
|
||||
// wait to detect stability (size unchanged across a tick) BEFORE moving the file — a
|
||||
// take REAPER is still flushing on the audio thread grows tick over tick.
|
||||
std::int64_t recordedFileSize(MediaTrack* temp) {
|
||||
const std::string path = recordedFilePath(temp);
|
||||
if (path.empty()) return -1;
|
||||
std::error_code ec;
|
||||
const auto sz = std::filesystem::file_size(path, ec);
|
||||
if (ec) return -1;
|
||||
return static_cast<std::int64_t>(sz);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ============================================================================
|
||||
// RealtimeCaptureState — the in-flight snapshot + idempotent restore
|
||||
// ============================================================================
|
||||
// Holds EVERYTHING to restore across the many ticks the record spans (temp track,
|
||||
// other tracks' I_RECARM, master send, transport, edit cursor, time selection),
|
||||
// plus the request echo needed to finalize the Sample. restore() is idempotent
|
||||
// (restored_ latch) and is the single teardown every terminal path calls.
|
||||
class RealtimeCaptureState {
|
||||
public:
|
||||
// Bound at begin(): the record's OWN project (transport reads use *Ex(proj_) so
|
||||
// a project switch mid-record cannot read the wrong transport), the request
|
||||
// echo, and the resolved bank paths + tag for finalize.
|
||||
ReaProject* proj_ = nullptr;
|
||||
CaptureRequest request_;
|
||||
BankPaths paths_;
|
||||
std::string uniqueTag_;
|
||||
|
||||
// The transient sink + the send we made from the master into it.
|
||||
MediaTrack* temp_ = nullptr;
|
||||
MediaTrack* master_ = nullptr;
|
||||
|
||||
// The record phase (pure state machine drives the transition). Starts Recording.
|
||||
RecordPhase phase_ = RecordPhase::Recording;
|
||||
|
||||
// Wall-clock anchors for the pure machine's safety ceilings (a steady clock — not
|
||||
// the play cursor — so a stuck/looping transport is still caught, review §3).
|
||||
// begunAt_ is set at begin(); finalizingAt_ is set on the Recording->Finalizing
|
||||
// edge (the transport stop) so the flush wait is bounded from the stop, not begin.
|
||||
std::chrono::steady_clock::time_point begunAt_{};
|
||||
std::chrono::steady_clock::time_point finalizingAt_{};
|
||||
|
||||
// Deferred-finalize (review §2) flush tracking: the recorded file's size the
|
||||
// previous tick, so "size unchanged across a tick" signals REAPER finished
|
||||
// flushing/closing the take. -1 = not yet seen.
|
||||
std::int64_t lastFileSize_ = -1;
|
||||
|
||||
void markElapsedStart() { begunAt_ = std::chrono::steady_clock::now(); }
|
||||
double elapsedSeconds() const {
|
||||
return std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now() - begunAt_).count();
|
||||
}
|
||||
// Set the flush-wait anchor once, on the first Finalizing tick.
|
||||
void markFinalizingStartOnce() {
|
||||
if (finalizingAt_.time_since_epoch().count() == 0)
|
||||
finalizingAt_ = std::chrono::steady_clock::now();
|
||||
}
|
||||
double finalizingSeconds() const {
|
||||
if (finalizingAt_.time_since_epoch().count() == 0) return 0.0;
|
||||
return std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now() - finalizingAt_).count();
|
||||
}
|
||||
|
||||
// Snapshot of state to restore. Filled at begin(), replayed once by restore().
|
||||
double curPos_ = 0.0;
|
||||
double tsStart_ = 0.0;
|
||||
double tsEnd_ = 0.0;
|
||||
struct ArmSnap { MediaTrack* track; double recarm; };
|
||||
std::vector<ArmSnap> armSnaps_;
|
||||
|
||||
// Snapshot the transport-adjacent state (cursor + time selection) and every
|
||||
// OTHER track's arm, disarming them so only our sink records. Call ONCE, before
|
||||
// the temp track exists (so the temp track is never in the arm snapshot).
|
||||
void snapshotAndDisarmOthers() {
|
||||
curPos_ = GetCursorPosition();
|
||||
GetSet_LoopTimeRange(false, false, &tsStart_, &tsEnd_, false);
|
||||
const int n = CountTracks(proj_);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaTrack* tr = GetTrack(proj_, i);
|
||||
if (!tr) continue;
|
||||
const double armed = GetMediaTrackInfo_Value(tr, "I_RECARM");
|
||||
if (armed != 0.0) {
|
||||
armSnaps_.push_back({tr, armed});
|
||||
SetMediaTrackInfo_Value(tr, "I_RECARM", 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The single, idempotent teardown. Called on EVERY terminal path (normal
|
||||
// completion, user stop, error, project switch, unload). Safe to call more than
|
||||
// once — the restored_ latch makes every call after the first a no-op. Order:
|
||||
// 1. stop the transport if anything is still running (we own it),
|
||||
// 2. delete the temp track (drops its send + the recorded arrange item),
|
||||
// 3. restore every other track's arm,
|
||||
// 4. restore the time selection + edit cursor.
|
||||
// Stop the record's OWN project transport if it is still playing/recording. Uses
|
||||
// the project-scoped OnStopButtonEx(proj_) (not the global CSurf_OnStop) so a
|
||||
// project switch mid-record — where proj_ is no longer the ACTIVE project — stops
|
||||
// OUR project's transport, never the foreign now-active one. &1=playing,
|
||||
// &4=recording. Idempotent to call (the playstate guard makes a repeat a no-op).
|
||||
void stopOwnTransport() {
|
||||
if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_);
|
||||
}
|
||||
|
||||
// Is the captured project STILL OPEN? (review §1 — CRITICAL). If the captured
|
||||
// project was CLOSED mid-record, proj_/temp_/master_ point at freed memory;
|
||||
// touching them (stopOwnTransport, DeleteTrack, arm restore) is a use-after-free.
|
||||
// ValidatePtr2 with a null project validates the ReaProject* itself (the header:
|
||||
// "proj is ignored if pointer is itself a project"). Every teardown that
|
||||
// dereferences a captured REAPER object MUST gate on this first.
|
||||
bool captureProjectStillOpen() const {
|
||||
return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*");
|
||||
}
|
||||
|
||||
// Drop the handle WITHOUT touching any REAPER state — for the closed-project case
|
||||
// (review §1). A closed project already reclaimed its temp track, arms, and
|
||||
// transport; there is nothing to restore and the pointers are freed. Latch
|
||||
// restored_ so any later terminal path is a no-op (idempotent), but skip every
|
||||
// REAPER call restore() would make.
|
||||
void dropWithoutRestore() {
|
||||
restored_ = true;
|
||||
temp_ = nullptr;
|
||||
master_ = nullptr;
|
||||
armSnaps_.clear();
|
||||
}
|
||||
|
||||
void restore() {
|
||||
if (restored_) return;
|
||||
restored_ = true;
|
||||
|
||||
// 1. Transport: stop OUR project's if still running (usually already stopped
|
||||
// by the terminal path's explicit stop-before-finalize — a safe no-op then).
|
||||
stopOwnTransport();
|
||||
|
||||
// 2. Temp track: deleting it drops the master send AND the recorded arrange
|
||||
// item in one move — nothing stays in the arrange (load-bearing principle).
|
||||
if (temp_) { DeleteTrack(temp_); temp_ = nullptr; }
|
||||
|
||||
// 3. Other tracks' record-arm.
|
||||
for (const ArmSnap& s : armSnaps_)
|
||||
SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm);
|
||||
armSnaps_.clear();
|
||||
|
||||
// 4. Time selection + edit cursor (no view move, no seek).
|
||||
GetSet_LoopTimeRange(true, false, &tsStart_, &tsEnd_, false);
|
||||
SetEditCurPos(curPos_, false, false);
|
||||
}
|
||||
|
||||
bool restored() const { return restored_; }
|
||||
bool finalized() const { return finalized_; }
|
||||
void markFinalized() { finalized_ = true; }
|
||||
|
||||
private:
|
||||
bool restored_ = false;
|
||||
bool finalized_ = false;
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
||||
// move it into the bank, populate the Sample via the pure mapping. Returns Ok +
|
||||
// Sample on success, or a RenderFailed result. Does NOT restore — the caller
|
||||
// restores unconditionally afterward (finalize + restore are separate steps so a
|
||||
// finalize failure still restores).
|
||||
CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
||||
CaptureResult result;
|
||||
|
||||
const std::string recorded = normSlashes(recordedFilePath(st.temp_));
|
||||
if (recorded.empty() || !std::filesystem::exists(recorded)) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Realtime record produced no file (check transport/record "
|
||||
"settings in the DAW).";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(st.paths_.absoluteDir, ec);
|
||||
const std::string destPath = st.paths_.absoluteDir + "/" + st.paths_.fileName;
|
||||
std::filesystem::rename(recorded, destPath, ec);
|
||||
if (ec) {
|
||||
// Cross-volume rename can fail; fall back to copy+remove.
|
||||
ec.clear();
|
||||
std::filesystem::copy_file(
|
||||
recorded, destPath,
|
||||
std::filesystem::copy_options::overwrite_existing, ec);
|
||||
if (ec) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Recorded file could not be moved into the bank: " +
|
||||
ec.message();
|
||||
return result;
|
||||
}
|
||||
std::error_code rmEc;
|
||||
std::filesystem::remove(recorded, rmEc); // best-effort
|
||||
}
|
||||
|
||||
RecordedCapture cap;
|
||||
cap.relativePath = st.paths_.relativePath;
|
||||
cap.uniqueTag = st.uniqueTag_;
|
||||
cap.sourceMode = SourceMode::Realtime;
|
||||
cap.startSeconds = st.request_.startSeconds;
|
||||
cap.endSeconds = st.request_.endSeconds;
|
||||
cap.wetDry = st.request_.wetDry;
|
||||
cap.displayName = st.request_.baseName;
|
||||
cap.trackGuids = st.request_.trackGuids;
|
||||
cap.channelCount = st.request_.channelCount;
|
||||
cap.sampleRate = (st.request_.sampleRate > 0)
|
||||
? st.request_.sampleRate
|
||||
: static_cast<int>(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false));
|
||||
cap.captureTempo = Master_GetTempo();
|
||||
cap.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = sampleFromRecordedCapture(cap);
|
||||
result.message = "Realtime-captured [" +
|
||||
std::to_string(st.request_.startSeconds) + "s, " +
|
||||
std::to_string(st.request_.endSeconds) + "s] -> " +
|
||||
st.paths_.relativePath;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ============================================================================
|
||||
// begin — start the record, snapshot, return immediately (no UI block)
|
||||
// ============================================================================
|
||||
void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noexcept {
|
||||
delete p; // full type is visible here — keeps capture.h REAPER-free
|
||||
}
|
||||
|
||||
RealtimeCaptureHandle
|
||||
RealtimeRecordBackend::begin(const CaptureRequest& request, CaptureResult& outFailure) {
|
||||
// Only the master scope is implemented this increment (see §FORK).
|
||||
if (request.sourceMode != SourceMode::MasterMix) {
|
||||
outFailure.status = CaptureStatus::UnsupportedMode;
|
||||
outFailure.message = "RealtimeRecordBackend implements MASTER scope only this "
|
||||
"increment (track/item realtime routing is a surfaced fork).";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Exact bounds: refuse an empty/inverted range rather than record silence.
|
||||
if (!(request.endSeconds > request.startSeconds)) {
|
||||
outFailure.status = CaptureStatus::EmptyRange;
|
||||
outFailure.message = "Capture range is empty (end <= start).";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) {
|
||||
outFailure.status = CaptureStatus::NoProject;
|
||||
outFailure.message = "No active project.";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Refuse if the transport is already playing/recording — we own the transport for
|
||||
// the capture window and must not hijack a user's live take.
|
||||
if (GetPlayStateEx(proj) & (1 | 4)) {
|
||||
outFailure.status = CaptureStatus::TransportBusy;
|
||||
outFailure.message = "Transport is already playing/recording — realtime capture "
|
||||
"refused. Stop the transport first.";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Saved-project gate (same as offline): the bank folder resolves against the
|
||||
// .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved.
|
||||
std::string rppPath = readRppPath();
|
||||
if (rppPath.empty()) {
|
||||
Main_SaveProject(proj, true); // DAW-only: opens Save-As, blocks (verify)
|
||||
rppPath = readRppPath();
|
||||
}
|
||||
if (rppPath.empty()) {
|
||||
outFailure.status = CaptureStatus::NoProject;
|
||||
outFailure.message = "Project must be saved before capture — nothing captured.";
|
||||
return nullptr;
|
||||
}
|
||||
const std::string projectDir =
|
||||
normSlashes(std::filesystem::path(rppPath).parent_path().string());
|
||||
|
||||
// --- Build the in-flight state (owns the snapshot + teardown) ---------------
|
||||
RealtimeCaptureHandle st(new RealtimeCaptureState());
|
||||
st->proj_ = proj;
|
||||
st->request_ = request;
|
||||
st->uniqueTag_ = makeUniqueTag();
|
||||
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
|
||||
|
||||
// DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT
|
||||
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
|
||||
// shells is intentional. This backend fully restores its own state across every
|
||||
// terminal path (the restore() latch); an undo point would surface an internal,
|
||||
// fully-reversed scaffold in the user's undo history for no user-meaningful action.
|
||||
// Snapshot cursor + time selection, and disarm every OTHER track BEFORE the temp
|
||||
// track exists (so it is never in the arm snapshot and keeps the arm we set).
|
||||
st->snapshotAndDisarmOthers();
|
||||
|
||||
// Hidden temp track at the end: no default FX/envelopes (clean sink), hidden from
|
||||
// both panels, B_MAINSEND=0 so it does not sum back into the master (monitoring
|
||||
// invariant — the record tap is a pure branch off the master bus).
|
||||
const int idx = CountTracks(proj);
|
||||
InsertTrackAtIndex(idx, false);
|
||||
st->temp_ = GetTrack(proj, idx);
|
||||
if (!st->temp_) {
|
||||
outFailure.status = CaptureStatus::RenderFailed;
|
||||
outFailure.message = "Could not create the hidden temp record track.";
|
||||
st->restore(); // undo the disarm + cursor/time-sel snapshot
|
||||
return nullptr;
|
||||
}
|
||||
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINTCP", 0.0);
|
||||
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINMIXER", 0.0);
|
||||
SetMediaTrackInfo_Value(st->temp_, "B_MAINSEND", 0.0);
|
||||
|
||||
// Route the MASTER output into the temp track (a send master -> temp). The temp
|
||||
// track records this in output-record mode.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION (flag): whether output-record mode (I_RECMODE 3/6) on a
|
||||
// track fed only by a master send records THAT send's signal is the crux to
|
||||
// verify live — named here so DAW testing targets it directly.
|
||||
st->master_ = GetMasterTrack(proj);
|
||||
if (!st->master_) {
|
||||
outFailure.status = CaptureStatus::RenderFailed;
|
||||
outFailure.message = "Could not resolve the master track for realtime routing.";
|
||||
st->restore(); // temp track removed here
|
||||
return nullptr;
|
||||
}
|
||||
const int sendIdx = CreateTrackSend(st->master_, st->temp_);
|
||||
if (sendIdx < 0) {
|
||||
outFailure.status = CaptureStatus::RenderFailed;
|
||||
outFailure.message = "Could not route the master output into the record track.";
|
||||
st->restore(); // deleting the temp track drops any partial send too
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Record-mode values from the pure planner. Master mix is fully wet -> PostFader.
|
||||
const OutputTap tap = outputTapForWetDry(request.wetDry);
|
||||
const RecordModePlan rec = recordModePlanFor(request.channelCount, tap);
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast<double>(rec.recMode));
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE_FLAGS",
|
||||
static_cast<double>(rec.recModeFlags));
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
|
||||
|
||||
// Record range: time selection over [start,end], play cursor at start. Both were
|
||||
// snapshotted and will be restored by restore().
|
||||
double rs = request.startSeconds, re = request.endSeconds;
|
||||
GetSet_LoopTimeRange(true, false, &rs, &re, false);
|
||||
SetEditCurPos(request.startSeconds, false, false);
|
||||
|
||||
// Start the transport and RETURN. tick() drives the rest across timer ticks.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION (flag): CSurf_OnRecord starts recording and the exact
|
||||
// range/auto-punch/stop behavior depends on the user's transport settings — not
|
||||
// header-guaranteed. tick() detects completion via the play cursor reaching the
|
||||
// range end (the pure state machine), independent of REAPER's auto-punch.
|
||||
CSurf_OnRecord();
|
||||
|
||||
// Anchor the wall-clock safety ceiling from here (steady clock — independent of the
|
||||
// play cursor, so a transport that starts but never advances is still bounded).
|
||||
st->markElapsedStart();
|
||||
|
||||
return st;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// tick — advance the in-flight record; on terminal, finalize/abort + restore
|
||||
// ============================================================================
|
||||
RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
||||
RealtimeTickResult out;
|
||||
|
||||
// If a prior terminal path already tore this down (e.g. abort() then a stray
|
||||
// tick), do nothing — the state is spent.
|
||||
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
|
||||
|
||||
const RecordPhase prevPhase = state.phase_;
|
||||
|
||||
// Read the transport bound to the record's OWN project (a project switch cannot
|
||||
// point these reads at the wrong transport). &4 = recording. Gather everything the
|
||||
// pure machine needs (transport + wall-clock ceilings + file-flush readiness).
|
||||
RecordTickInputs inputs;
|
||||
inputs.transport.recording = (GetPlayStateEx(state.proj_) & 4) != 0;
|
||||
inputs.transport.playPosition = GetPlayPositionEx(state.proj_);
|
||||
inputs.elapsedSeconds = state.elapsedSeconds();
|
||||
|
||||
// Deferred-finalize flush check (review §2), only meaningful once stopped. The
|
||||
// recorded file is READY when its size is a valid positive value AND unchanged
|
||||
// from the previous tick — REAPER finished flushing/closing the take on the audio
|
||||
// thread. Comparing across a tick avoids moving a file mid-write (truncated take).
|
||||
if (prevPhase == RecordPhase::Finalizing) {
|
||||
state.markFinalizingStartOnce();
|
||||
inputs.finalizingSeconds = state.finalizingSeconds();
|
||||
const std::int64_t sz = recordedFileSize(state.temp_);
|
||||
inputs.fileReady = (sz > 0 && sz == state.lastFileSize_);
|
||||
state.lastFileSize_ = sz;
|
||||
}
|
||||
|
||||
state.phase_ = advanceRecordPhase(state.phase_, inputs,
|
||||
state.request_.startSeconds,
|
||||
state.request_.endSeconds);
|
||||
|
||||
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
|
||||
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
|
||||
// — never the global CSurf_OnStop, which would stop whatever project is ACTIVE (a
|
||||
// foreign one during a project switch), not the record's own. The flush wait then
|
||||
// proceeds across subsequent ticks before the file is moved.
|
||||
if (prevPhase == RecordPhase::Recording &&
|
||||
isStopRequested(state.phase_)) {
|
||||
state.stopOwnTransport();
|
||||
state.markFinalizingStartOnce(); // anchor the flush ceiling from the stop
|
||||
}
|
||||
|
||||
if (!isTerminalPhase(state.phase_)) {
|
||||
out.status = RealtimeTickStatus::InProgress;
|
||||
return out; // keep the OnTimer tick fast — recording or flushing
|
||||
}
|
||||
|
||||
// Terminal (Done: file flushed + stable; Failed: flush ceiling tripped). On Done,
|
||||
// finalize moves the now-stable file into the bank + builds the Sample. On Failed
|
||||
// (the flush timeout) there is nothing usable — report RenderFailed. Then restore
|
||||
// ALL snapshotted state — the non-destructive gate, idempotent + unconditional.
|
||||
CaptureResult res;
|
||||
if (state.phase_ == RecordPhase::Done) {
|
||||
res = finalizeRecording(state);
|
||||
} else {
|
||||
res.status = CaptureStatus::RenderFailed;
|
||||
res.message = "Realtime record timed out waiting for the recorded file to "
|
||||
"flush/close (nothing captured).";
|
||||
}
|
||||
state.markFinalized();
|
||||
state.restore();
|
||||
|
||||
out.result = res;
|
||||
out.status = (res.status == CaptureStatus::Ok)
|
||||
? RealtimeTickStatus::Done
|
||||
: RealtimeTickStatus::Failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// abort — force-terminate now (shutdown / project switch) + restore
|
||||
// ============================================================================
|
||||
RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
|
||||
RealtimeTickResult out;
|
||||
|
||||
// Already torn down (idempotent): report Failed and leave it.
|
||||
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
|
||||
|
||||
// CRITICAL (review §1): if the captured project was CLOSED mid-record, proj_ /
|
||||
// temp_ / master_ point at freed memory. The closed project already reclaimed its
|
||||
// temp track, arms, and transport — so DROP the handle WITHOUT touching any REAPER
|
||||
// state (no stop, no finalize, no DeleteTrack, no arm restore). Touching those
|
||||
// freed pointers is the use-after-free bug this guard exists to prevent. This is
|
||||
// the ONE terminal path that can run against a possibly-closed project (tick() only
|
||||
// runs while proj_ is the active — hence still-open — project); guarding here covers
|
||||
// both the project-switch and unload callers.
|
||||
if (!state.captureProjectStillOpen()) {
|
||||
state.dropWithoutRestore();
|
||||
out.result.status = CaptureStatus::RenderFailed;
|
||||
out.result.message = "Realtime capture dropped — the captured project was closed "
|
||||
"mid-record (nothing to restore; no capture persisted).";
|
||||
out.status = RealtimeTickStatus::Failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
// The project is still open (a tab-switch, or a clean unload with the project
|
||||
// present): stop the transport, then TRY to finalize whatever was captured so a
|
||||
// near-complete record still keeps the audio; if nothing was recorded (or the file
|
||||
// has not flushed yet), finalize returns RenderFailed and we abort clean.
|
||||
// Project-scoped stop (OnStopButtonEx(proj_)) — on a project switch proj_ is no
|
||||
// longer active, so the global CSurf_OnStop would stop the wrong (foreign) project.
|
||||
//
|
||||
// NOTE (residual timing — DAW-verify): abort is the force-terminate path (unload /
|
||||
// switch); it cannot span ticks to wait for the flush the way tick() does, so its
|
||||
// finalize still races REAPER's audio-thread take close. That is inherent to a
|
||||
// best-effort terminal grab and is acceptable — the normal completion path (tick)
|
||||
// is the one that must be flush-safe.
|
||||
state.stopOwnTransport();
|
||||
|
||||
CaptureResult res = finalizeRecording(state);
|
||||
state.markFinalized();
|
||||
state.restore(); // the non-destructive gate — always runs
|
||||
|
||||
out.result = res;
|
||||
out.status = (res.status == CaptureStatus::Ok)
|
||||
? RealtimeTickStatus::Done
|
||||
: RealtimeTickStatus::Failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// realtime_record.cpp — pure logic for the realtime-record backend (M8). See header.
|
||||
// NO REAPER types; unit-tested by tests/test_realtime_record.cpp.
|
||||
|
||||
#include "realtime_record.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
|
||||
RecordModePlan p;
|
||||
|
||||
// Stereo vs mono output recording, latency-compensated either way so the
|
||||
// recorded file lines up with the source. A request asking for <= 1 channel
|
||||
// records mono-out; anything else records stereo-out. (Higher channel counts
|
||||
// still record stereo-out here — REAPER's output-record modes are mono/stereo
|
||||
// only; a >2-channel realtime capture is out of scope for this increment.)
|
||||
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
|
||||
: kRecModeStereoOutLatComp;
|
||||
|
||||
switch (tap) {
|
||||
case OutputTap::PostFader: p.recModeFlags = kRecOutPostFader; break;
|
||||
case OutputTap::PreFx: p.recModeFlags = kRecOutPreFx; break;
|
||||
case OutputTap::PostFxPreFader: p.recModeFlags = kRecOutPostFxPreFader; break;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
OutputTap outputTapForWetDry(double wetDry) {
|
||||
// Fully wet (1.0) taps post-fader; any dry-ward value taps pre-FX — the true
|
||||
// pre-FX dry that offline render cannot produce (the realtime backend's whole
|
||||
// reason to exist for the M10 null test). PostFxPreFader is an explicit future
|
||||
// option, not reachable from the wet/dry axis, so it is not returned here.
|
||||
return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
|
||||
}
|
||||
|
||||
Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
|
||||
Sample s;
|
||||
// Same id shape as the offline path: "cap-<tag>-<fileName>" would need the file
|
||||
// name; here the recorded file name is the tail of relativePath. Keep the id
|
||||
// stable + unique via the tag, and include the relative path tail so two
|
||||
// captures with the same tag (impossible in practice) still differ.
|
||||
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
|
||||
s.displayName = cap.displayName;
|
||||
s.relativePath = cap.relativePath; // project-relative (invariant)
|
||||
s.sourceMode = cap.sourceMode;
|
||||
s.sourceRange.startSeconds = cap.startSeconds;
|
||||
s.sourceRange.endSeconds = cap.endSeconds;
|
||||
// PPQ/beats deferred (musical-placement concern) — identical to the offline path.
|
||||
s.wetDry = cap.wetDry;
|
||||
s.trackGuids = cap.trackGuids;
|
||||
s.channelCount = cap.channelCount;
|
||||
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
|
||||
s.lengthSeconds = cap.endSeconds - cap.startSeconds;
|
||||
s.captureTempo = cap.captureTempo;
|
||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
||||
// contentHash left empty: empty hashes do not participate in dedup (bank_model).
|
||||
s.createdTimestamp = cap.createdTimestamp;
|
||||
return s;
|
||||
}
|
||||
|
||||
RecordPhase advanceRecordPhase(RecordPhase current,
|
||||
const RecordTickInputs& inputs,
|
||||
double rangeStartSeconds,
|
||||
double rangeEndSeconds) {
|
||||
switch (current) {
|
||||
case RecordPhase::Recording: {
|
||||
// Transport stopped while we still expected to be recording -> the user
|
||||
// (or REAPER) stopped early. Move to the flush wait and finalize whatever
|
||||
// was captured up to the stop.
|
||||
if (!inputs.transport.recording) return RecordPhase::Finalizing;
|
||||
|
||||
// Reached the range end (latency-compensated play position). >= (not >)
|
||||
// so a cursor landing exactly on the end completes.
|
||||
if (inputs.transport.playPosition >= rangeEndSeconds)
|
||||
return RecordPhase::Finalizing;
|
||||
|
||||
// Self-defense (review §3): the transport is running but the play cursor
|
||||
// is not advancing to the end (stuck / looping). Without this the machine
|
||||
// stays in Recording forever, leaking the temp track + armed sink. Force
|
||||
// the flush wait once wall-clock exceeds the nominal duration + margin.
|
||||
const double ceiling =
|
||||
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
|
||||
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
|
||||
|
||||
return RecordPhase::Recording;
|
||||
}
|
||||
|
||||
case RecordPhase::Finalizing: {
|
||||
// The transport is stopped; wait for REAPER to flush/close the recorded
|
||||
// take on the audio thread. Finalize (move + Sample) only once the file
|
||||
// exists AND is stable (review §2) — moving it early races the flush and
|
||||
// yields a truncated / missing capture.
|
||||
if (inputs.fileReady) return RecordPhase::Done;
|
||||
|
||||
// Bound the wait: a file that never stabilizes fails cleanly rather than
|
||||
// hanging the in-flight state for the session.
|
||||
if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
|
||||
return RecordPhase::Failed;
|
||||
|
||||
return RecordPhase::Finalizing;
|
||||
}
|
||||
|
||||
// Terminal phases are sticky: once the verdict is in, a later tick (a stray
|
||||
// extra call before the shell has finished tearing down) must not flip it.
|
||||
case RecordPhase::Done:
|
||||
case RecordPhase::Failed:
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
bool isStopRequested(RecordPhase phase) {
|
||||
return phase != RecordPhase::Recording;
|
||||
}
|
||||
|
||||
bool isTerminalPhase(RecordPhase phase) {
|
||||
return phase == RecordPhase::Done || phase == RecordPhase::Failed;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -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
|
||||
@@ -0,0 +1,333 @@
|
||||
// Standalone tests for reasampler::realtime_record — no REAPER, no framework.
|
||||
// Covers the two pure pieces behind the realtime-record backend (M8): the
|
||||
// record-mode/recipe bookkeeping (channel count + tap -> I_RECMODE / I_RECMODE_FLAGS)
|
||||
// and the wet/dry -> tap decision, plus the recorded-file -> Sample mapping.
|
||||
|
||||
#include "../src/realtime_record.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- recordModePlanFor: channel count -> stereo/mono, latency-compensated -----
|
||||
|
||||
static void testStereoOutForTwoChannels() {
|
||||
// A 2-channel request records stereo-out, latency-compensated (I_RECMODE 3).
|
||||
RecordModePlan p = recordModePlanFor(2, OutputTap::PostFader);
|
||||
CHECK(p.recMode == kRecModeStereoOutLatComp);
|
||||
CHECK(p.recMode == 3);
|
||||
}
|
||||
|
||||
static void testMonoOutForOneChannel() {
|
||||
// A 1-channel request records mono-out, latency-compensated (I_RECMODE 6).
|
||||
RecordModePlan p = recordModePlanFor(1, OutputTap::PostFader);
|
||||
CHECK(p.recMode == kRecModeMonoOutLatComp);
|
||||
CHECK(p.recMode == 6);
|
||||
// Zero/negative channel counts also fall to mono-out (defensive, <= 1).
|
||||
CHECK(recordModePlanFor(0, OutputTap::PostFader).recMode == kRecModeMonoOutLatComp);
|
||||
}
|
||||
|
||||
static void testMoreThanTwoChannelsStillStereoOut() {
|
||||
// >2 channels still record stereo-out — REAPER's output-record modes are
|
||||
// mono/stereo only. (A >2ch realtime capture is out of this increment's scope.)
|
||||
CHECK(recordModePlanFor(4, OutputTap::PostFader).recMode == kRecModeStereoOutLatComp);
|
||||
}
|
||||
|
||||
// --- recordModePlanFor: tap -> I_RECMODE_FLAGS &3 bits -------------------------
|
||||
|
||||
static void testPostFaderTapFlags() {
|
||||
// PostFader = fully wet, &3==0.
|
||||
CHECK(recordModePlanFor(2, OutputTap::PostFader).recModeFlags == kRecOutPostFader);
|
||||
CHECK((recordModePlanFor(2, OutputTap::PostFader).recModeFlags & 3) == 0);
|
||||
}
|
||||
|
||||
static void testPreFxTapFlags() {
|
||||
// PreFx = true dry, &3==1 — the only documented pre-FX tap in the SDK.
|
||||
CHECK(recordModePlanFor(2, OutputTap::PreFx).recModeFlags == kRecOutPreFx);
|
||||
CHECK((recordModePlanFor(2, OutputTap::PreFx).recModeFlags & 3) == 1);
|
||||
}
|
||||
|
||||
static void testPostFxPreFaderTapFlags() {
|
||||
// PostFxPreFader = wet FX, pre-fader, &3==2.
|
||||
CHECK(recordModePlanFor(2, OutputTap::PostFxPreFader).recModeFlags == kRecOutPostFxPreFader);
|
||||
CHECK((recordModePlanFor(2, OutputTap::PostFxPreFader).recModeFlags & 3) == 2);
|
||||
}
|
||||
|
||||
static void testTapIsIndependentOfChannelCount() {
|
||||
// The tap bits do not vary with channel count; the record mode does not vary
|
||||
// with the tap. The two axes are orthogonal.
|
||||
CHECK(recordModePlanFor(1, OutputTap::PreFx).recModeFlags == kRecOutPreFx);
|
||||
CHECK(recordModePlanFor(4, OutputTap::PreFx).recModeFlags == kRecOutPreFx);
|
||||
CHECK(recordModePlanFor(1, OutputTap::PreFx).recMode == kRecModeMonoOutLatComp);
|
||||
CHECK(recordModePlanFor(2, OutputTap::PreFx).recMode == kRecModeStereoOutLatComp);
|
||||
}
|
||||
|
||||
// --- outputTapForWetDry: wet -> PostFader, dry -> PreFx ------------------------
|
||||
|
||||
static void testFullyWetTapsPostFader() {
|
||||
CHECK(outputTapForWetDry(1.0) == OutputTap::PostFader);
|
||||
}
|
||||
|
||||
static void testDryTapsPreFx() {
|
||||
// Any value below fully-wet is the true pre-FX dry tap.
|
||||
CHECK(outputTapForWetDry(0.0) == OutputTap::PreFx);
|
||||
CHECK(outputTapForWetDry(0.5) == OutputTap::PreFx);
|
||||
// 0.999 (just under wet) still taps pre-FX — there is no blend, it is a switch.
|
||||
CHECK(outputTapForWetDry(0.999) == OutputTap::PreFx);
|
||||
}
|
||||
|
||||
// --- sampleFromRecordedCapture: exact bounds, scratch tier, no dedup ----------
|
||||
|
||||
static RecordedCapture makeCapture() {
|
||||
RecordedCapture cap;
|
||||
cap.relativePath = "reasampler_bank/realtime_1700000000.wav";
|
||||
cap.uniqueTag = "1700000000";
|
||||
cap.sourceMode = SourceMode::Realtime;
|
||||
cap.startSeconds = 4.0;
|
||||
cap.endSeconds = 6.5;
|
||||
cap.wetDry = 1.0;
|
||||
cap.displayName = "realtime";
|
||||
cap.trackGuids = {"{GUID-A}"};
|
||||
cap.channelCount = 2;
|
||||
cap.sampleRate = 48000;
|
||||
cap.captureTempo = 120.0;
|
||||
cap.createdTimestamp = 1700000000;
|
||||
return cap;
|
||||
}
|
||||
|
||||
static void testSampleExactBoundsNoRounding() {
|
||||
Sample s = sampleFromRecordedCapture(makeCapture());
|
||||
// Bounds are echoed exactly — no re-measuring, no rounding.
|
||||
CHECK(s.sourceRange.startSeconds == 4.0);
|
||||
CHECK(s.sourceRange.endSeconds == 6.5);
|
||||
CHECK(s.lengthSeconds == 2.5); // end - start, computed here
|
||||
}
|
||||
|
||||
static void testSampleMetadataCarriedThrough() {
|
||||
Sample s = sampleFromRecordedCapture(makeCapture());
|
||||
CHECK(s.sourceMode == SourceMode::Realtime);
|
||||
CHECK(s.relativePath == "reasampler_bank/realtime_1700000000.wav");
|
||||
CHECK(s.channelCount == 2);
|
||||
CHECK(s.sampleRate == 48000);
|
||||
CHECK(s.captureTempo == 120.0);
|
||||
CHECK(s.wetDry == 1.0);
|
||||
CHECK(s.trackGuids.size() == 1);
|
||||
CHECK(s.trackGuids[0] == "{GUID-A}");
|
||||
CHECK(s.createdTimestamp == 1700000000);
|
||||
CHECK(s.displayName == "realtime");
|
||||
}
|
||||
|
||||
static void testSampleLandsInScratchWithNoHash() {
|
||||
Sample s = sampleFromRecordedCapture(makeCapture());
|
||||
// Captures land in scratch by default (auto-prunable), same as offline.
|
||||
CHECK(s.tier == Tier::Scratch);
|
||||
CHECK(s.isAutoPrunable());
|
||||
// Empty content hash so a realtime capture never collapses (bank_model treats
|
||||
// "" as non-participating in dedup) — realtime is not bit-identical, so it must
|
||||
// never dedup against a prior capture.
|
||||
CHECK(s.contentHash.empty());
|
||||
}
|
||||
|
||||
static void testSampleIdIsStableAndUnique() {
|
||||
Sample s = sampleFromRecordedCapture(makeCapture());
|
||||
// The id carries the unique tag so repeated captures do not collide, and is
|
||||
// consistent with the file that produced it (same discipline as offline).
|
||||
CHECK(s.id.find("1700000000") != std::string::npos);
|
||||
CHECK(!s.id.empty());
|
||||
}
|
||||
|
||||
static void testUnknownSampleRateStaysZero() {
|
||||
// When the shell could not resolve the project rate it passes 0; the mapping
|
||||
// must not invent a value (mirror of the offline "unknown rate -> 0" behavior).
|
||||
RecordedCapture cap = makeCapture();
|
||||
cap.sampleRate = 0;
|
||||
Sample s = sampleFromRecordedCapture(cap);
|
||||
CHECK(s.sampleRate == 0);
|
||||
}
|
||||
|
||||
// --- advanceRecordPhase: the async completion state machine -------------------
|
||||
//
|
||||
// The machine now has two waits: Recording (transport running) and Finalizing (stopped,
|
||||
// waiting for the recorded file to flush). Inputs bundle the transport reading, the
|
||||
// wall-clock ceilings, and the file-flush readiness. Helpers keep the tests terse.
|
||||
|
||||
static const double kStart = 4.0;
|
||||
static const double kEnd = 6.5;
|
||||
|
||||
// Recording-phase inputs: transport recording flag + play position + elapsed wall clock.
|
||||
static RecordTickInputs recTick(bool rec, double pos, double elapsed) {
|
||||
RecordTickInputs in;
|
||||
in.transport.recording = rec;
|
||||
in.transport.playPosition = pos;
|
||||
in.elapsedSeconds = elapsed;
|
||||
return in;
|
||||
}
|
||||
// Finalizing-phase inputs: file readiness + time spent flushing.
|
||||
static RecordTickInputs finTick(bool fileReady, double finalizing) {
|
||||
RecordTickInputs in;
|
||||
in.transport.recording = false; // stopped by the time we are finalizing
|
||||
in.fileReady = fileReady;
|
||||
in.finalizingSeconds = finalizing;
|
||||
return in;
|
||||
}
|
||||
|
||||
static RecordPhase advance(RecordPhase cur, const RecordTickInputs& in) {
|
||||
return advanceRecordPhase(cur, in, kStart, kEnd);
|
||||
}
|
||||
|
||||
// -- Recording -> keep waiting / reached end / stopped early --------------------
|
||||
|
||||
static void testStaysRecordingBeforeRangeEnd() {
|
||||
// Cursor short of the end, well under the wall-clock ceiling -> keep waiting.
|
||||
RecordPhase p = advance(RecordPhase::Recording, recTick(true, 5.0, 1.0));
|
||||
CHECK(p == RecordPhase::Recording);
|
||||
CHECK(!isTerminalPhase(p));
|
||||
CHECK(!isStopRequested(p));
|
||||
}
|
||||
|
||||
static void testReachesEndAtOrPastRangeEnd() {
|
||||
// Cursor exactly on the end goes to Finalizing (>=, not >), and past the end too.
|
||||
CHECK(advance(RecordPhase::Recording, recTick(true, 6.5, 3.0))
|
||||
== RecordPhase::Finalizing);
|
||||
CHECK(advance(RecordPhase::Recording, recTick(true, 7.0, 3.0))
|
||||
== RecordPhase::Finalizing);
|
||||
// Finalizing is the shell's stop-and-flush signal, not yet terminal.
|
||||
CHECK(isStopRequested(RecordPhase::Finalizing));
|
||||
CHECK(!isTerminalPhase(RecordPhase::Finalizing));
|
||||
}
|
||||
|
||||
static void testStopsEarlyGoesToFinalizing() {
|
||||
// Transport no longer recording (user hit stop) before the end -> Finalizing,
|
||||
// regardless of where the cursor was.
|
||||
RecordPhase p = advance(RecordPhase::Recording, recTick(false, 5.0, 1.0));
|
||||
CHECK(p == RecordPhase::Finalizing);
|
||||
CHECK(isStopRequested(p));
|
||||
}
|
||||
|
||||
static void testStopBeatsCursorPositionCheck() {
|
||||
// NOT recording is the signal even if the cursor sits past the end — a stop that
|
||||
// raced the end is still a stop; both routes converge on Finalizing anyway.
|
||||
CHECK(advance(RecordPhase::Recording, recTick(false, 9.0, 1.0))
|
||||
== RecordPhase::Finalizing);
|
||||
}
|
||||
|
||||
static void testReachedEndImmediatelyOnFirstTick() {
|
||||
// A degenerate range where the cursor is already at/past end on the first tick
|
||||
// moves to Finalizing at once rather than waiting a full transport lap.
|
||||
CHECK(advance(RecordPhase::Recording, recTick(true, 6.5, 0.1))
|
||||
== RecordPhase::Finalizing);
|
||||
}
|
||||
|
||||
// -- Recording safety ceiling (review §3): stuck/non-advancing transport --------
|
||||
|
||||
static void testStuckTransportTripsWallClockCeiling() {
|
||||
// Transport reports recording, but the cursor never advances to the end. Before the
|
||||
// ceiling: keep waiting. Past (end-start)+margin of wall clock: force Finalizing so
|
||||
// the temp track + armed sink are not leaked for the session.
|
||||
const double duration = kEnd - kStart; // 2.5s nominal
|
||||
const double underCeiling = duration + kRecordMarginSeconds - 0.5;
|
||||
const double overCeiling = duration + kRecordMarginSeconds + 0.5;
|
||||
// Cursor stuck at start the whole time.
|
||||
CHECK(advance(RecordPhase::Recording, recTick(true, kStart, underCeiling))
|
||||
== RecordPhase::Recording);
|
||||
CHECK(advance(RecordPhase::Recording, recTick(true, kStart, overCeiling))
|
||||
== RecordPhase::Finalizing);
|
||||
}
|
||||
|
||||
// -- Finalizing (review §2): deferred finalize, flush wait ----------------------
|
||||
|
||||
static void testFinalizingWaitsUntilFileReady() {
|
||||
// File not yet flushed/stable, within the flush ceiling -> keep waiting in
|
||||
// Finalizing (do NOT move the file mid-write).
|
||||
RecordPhase p = advance(RecordPhase::Finalizing, finTick(false, 1.0));
|
||||
CHECK(p == RecordPhase::Finalizing);
|
||||
CHECK(!isTerminalPhase(p));
|
||||
}
|
||||
|
||||
static void testFinalizingCompletesWhenFileReady() {
|
||||
// File exists AND is stable -> Done (the shell now moves it + builds the Sample).
|
||||
RecordPhase p = advance(RecordPhase::Finalizing, finTick(true, 1.0));
|
||||
CHECK(p == RecordPhase::Done);
|
||||
CHECK(isTerminalPhase(p));
|
||||
}
|
||||
|
||||
static void testFinalizingFailsWhenFlushCeilingTrips() {
|
||||
// File never stabilizes; past the flush ceiling -> Failed (give up, RenderFailed).
|
||||
const double overCeiling = kFinalizeFlushCeilingSeconds + 0.5;
|
||||
RecordPhase p = advance(RecordPhase::Finalizing, finTick(false, overCeiling));
|
||||
CHECK(p == RecordPhase::Failed);
|
||||
CHECK(isTerminalPhase(p));
|
||||
}
|
||||
|
||||
static void testFinalizingReadyBeatsCeiling() {
|
||||
// If the file is ready ON the same tick the ceiling trips, ready wins -> Done
|
||||
// (we do not discard a capture that just became available).
|
||||
const double overCeiling = kFinalizeFlushCeilingSeconds + 0.5;
|
||||
CHECK(advance(RecordPhase::Finalizing, finTick(true, overCeiling))
|
||||
== RecordPhase::Done);
|
||||
}
|
||||
|
||||
// -- Terminal stickiness + classification --------------------------------------
|
||||
|
||||
static void testTerminalPhasesAreSticky() {
|
||||
// Feeding a terminal phase back returns it unchanged — a stray late tick before
|
||||
// teardown finishes cannot flip the verdict (the idempotence the shell relies on).
|
||||
CHECK(advance(RecordPhase::Done, recTick(true, 2.0, 1.0)) == RecordPhase::Done);
|
||||
CHECK(advance(RecordPhase::Done, finTick(false, 1.0)) == RecordPhase::Done);
|
||||
CHECK(advance(RecordPhase::Failed, recTick(true, 8.0, 1.0)) == RecordPhase::Failed);
|
||||
CHECK(advance(RecordPhase::Failed, finTick(true, 1.0)) == RecordPhase::Failed);
|
||||
}
|
||||
|
||||
static void testStopRequestedClassification() {
|
||||
// isStopRequested fires for every phase past Recording (drives the one-shot stop).
|
||||
CHECK(!isStopRequested(RecordPhase::Recording));
|
||||
CHECK(isStopRequested(RecordPhase::Finalizing));
|
||||
CHECK(isStopRequested(RecordPhase::Done));
|
||||
CHECK(isStopRequested(RecordPhase::Failed));
|
||||
}
|
||||
|
||||
static void testIsTerminalPhaseClassification() {
|
||||
CHECK(!isTerminalPhase(RecordPhase::Recording));
|
||||
CHECK(!isTerminalPhase(RecordPhase::Finalizing));
|
||||
CHECK(isTerminalPhase(RecordPhase::Done));
|
||||
CHECK(isTerminalPhase(RecordPhase::Failed));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testStereoOutForTwoChannels();
|
||||
testMonoOutForOneChannel();
|
||||
testMoreThanTwoChannelsStillStereoOut();
|
||||
testPostFaderTapFlags();
|
||||
testPreFxTapFlags();
|
||||
testPostFxPreFaderTapFlags();
|
||||
testTapIsIndependentOfChannelCount();
|
||||
testFullyWetTapsPostFader();
|
||||
testDryTapsPreFx();
|
||||
testSampleExactBoundsNoRounding();
|
||||
testSampleMetadataCarriedThrough();
|
||||
testSampleLandsInScratchWithNoHash();
|
||||
testSampleIdIsStableAndUnique();
|
||||
testUnknownSampleRateStaysZero();
|
||||
testStaysRecordingBeforeRangeEnd();
|
||||
testReachesEndAtOrPastRangeEnd();
|
||||
testStopsEarlyGoesToFinalizing();
|
||||
testStopBeatsCursorPositionCheck();
|
||||
testReachedEndImmediatelyOnFirstTick();
|
||||
testStuckTransportTripsWallClockCeiling();
|
||||
testFinalizingWaitsUntilFileReady();
|
||||
testFinalizingCompletesWhenFileReady();
|
||||
testFinalizingFailsWhenFlushCeilingTrips();
|
||||
testFinalizingReadyBeatsCeiling();
|
||||
testTerminalPhasesAreSticky();
|
||||
testStopRequestedClassification();
|
||||
testIsTerminalPhaseClassification();
|
||||
|
||||
if (g_fail == 0) std::printf("realtime_record: all tests passed\n");
|
||||
else std::printf("realtime_record: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user