From bd187d4f8961832042ad4c6501ccb734bf6c3532 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 15:16:36 -0400 Subject: [PATCH] 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. --- CMakeLists.txt | 20 +- src/capture.h | 112 +++++- src/capture_realtime.cpp | 625 +++++++++++++++++++++++++++++++++ src/main.cpp | 242 ++++++++++++- src/realtime_record.cpp | 119 +++++++ src/realtime_record.h | 230 ++++++++++++ tests/test_realtime_record.cpp | 333 ++++++++++++++++++ 7 files changed, 1672 insertions(+), 9 deletions(-) create mode 100644 src/capture_realtime.cpp create mode 100644 src/realtime_record.cpp create mode 100644 src/realtime_record.h create mode 100644 tests/test_realtime_record.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 488d9bb..6b2a298 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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") diff --git a/src/capture.h b/src/capture.h index 4be8aa6..770fb3b 100644 --- a/src/capture.h +++ b/src/capture.h @@ -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 #include #include @@ -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; + +// 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 diff --git a/src/capture_realtime.cpp b/src/capture_realtime.cpp new file mode 100644 index 0000000..633b3d3 --- /dev/null +++ b/src/capture_realtime.cpp @@ -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 +#include +#include +#include +#include +#include + +#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(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 buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(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 buf(4096, '\0'); + GetMediaSourceFileName(src, buf.data(), static_cast(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(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( + 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( + 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 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(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false)); + cap.captureTempo = Master_GetTempo(); + cap.createdTimestamp = static_cast(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(rec.recMode)); + SetMediaTrackInfo_Value(st->temp_, "I_RECMODE_FLAGS", + static_cast(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 diff --git a/src/main.cpp b/src/main.cpp index 8cbdb12..bca7482 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include "reaper_plugin.h" #include "reaper_plugin_functions.h" +#include #include #include @@ -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 diff --git a/src/realtime_record.cpp b/src/realtime_record.cpp new file mode 100644 index 0000000..0f2bb55 --- /dev/null +++ b/src/realtime_record.cpp @@ -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--" 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 diff --git a/src/realtime_record.h b/src/realtime_record.h new file mode 100644 index 0000000..043277b --- /dev/null +++ b/src/realtime_record.h @@ -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 +#include +#include + +#include "bank_model.h" // Sample, SourceMode (pure) + +namespace reasampler { + +// --- I_RECMODE values (verbatim from SDK header ~2197) ----------------------- +// +// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none, +// 3=stereo out w/latency compensation, 4=midi output, 5=mono out, +// 6=mono out w/ latency compensation, 7=midi overdub, 8=midi replace. +// +// We record a track's OUTPUT (the scoped signal routed into the temp track), +// latency-compensated, so the recorded file lines up sample-accurately with the +// source. Stereo vs mono is chosen by the request's channel count. +inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp +inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp + +// --- I_RECMODE_FLAGS values (verbatim from SDK header ~2198) ------------------ +// +// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode +// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). +// +// This is the ONLY documented pre-FX tap in the whole SDK — offline render has no +// pre-FX bit (see render_settings.h note + the M10 null-test note in PLAN.md). +// The realtime backend is therefore the true pre-FX "dry" path. +inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet) +inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry) +inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader + +// The tap point on the source track's output the temp track records from. +// Orthogonal to the record mode (stereo/mono); this only sets the &3 flags bits. +enum class OutputTap { + PostFader, // fully wet, after this track's fader (kRecOutPostFader) + PreFx, // true dry, before this track's FX (kRecOutPreFx) + PostFxPreFader, // wet FX, before the fader (kRecOutPostFxPreFader) +}; + +// The concrete record-mode values a temp track must carry to capture the scoped +// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags` +// sets the &3 output-recording tap bits (higher bits are left at their default 0 +// here — we only own the tap-point bits). +struct RecordModePlan { + int recMode = kRecModeStereoOutLatComp; + int recModeFlags = kRecOutPostFader; +}; + +// Maps (channelCount, tap) to the record-mode values. +// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp. +// tap -> the &3 output-recording bits. +// Pure so the "which I_RECMODE for N channels + this tap" rule is unit-tested +// without a DAW; the shell reads the request and applies these via +// SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS). +RecordModePlan recordModePlanFor(int channelCount, OutputTap tap); + +// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any +// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing +// capability). Kept pure + separate from recordModePlanFor so the wet/dry -> +// tap decision is tested on its own; PostFxPreFader is not selected by wetDry +// (it is an explicit future option, not on the wet/dry axis). +OutputTap outputTapForWetDry(double wetDry); + +// --- Recorded-file -> Sample mapping ---------------------------------------- +// +// The inputs a finished realtime capture yields, gathered by the shell into a +// pure struct so the Sample population is a single tested transform (mirror of +// the inline population in OfflineRenderBackend::capture). +struct RecordedCapture { + // Project-relative path of the recorded file (relative-paths-only invariant; + // the shell resolves REAPER's recorded absolute path back to project-relative). + std::string relativePath; + + // The disambiguating tag that named the file (feeds the Sample id, so id and + // file name stay consistent — same discipline as the offline path). + std::string uniqueTag; + + // Echoed from the request (exact bounds — no re-measuring the file). + SourceMode sourceMode = SourceMode::Realtime; + double startSeconds = 0.0; + double endSeconds = 0.0; + double wetDry = 1.0; + std::string displayName; + std::vector trackGuids; + + int channelCount = 0; + int sampleRate = 0; // 0 when the project rate was unknown (as offline) + double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo) + std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock) +}; + +// Builds the Sample for a finished realtime capture. Deliberately identical in +// shape to OfflineRenderBackend's population: exact request bounds (no rounding), +// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start. +// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline). +Sample sampleFromRecordedCapture(const RecordedCapture& cap); + +// --- Async record-phase state machine (M8 rework) ---------------------------- +// +// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport on +// REAPER's audio thread and returns immediately — it does NOT block until the range +// completes). The completion decision — "given where the transport is now, should +// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and +// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is +// factored out here; the REAPER shell only reads the transport/clock/file and applies +// the verdict (stop, wait for the file to flush, then finalize/abort + restore). +// +// The lifecycle has TWO waits, not one: +// 1. the RECORD wait (Recording): the transport is running; we wait for the play +// cursor to reach the range end — OR the user stops early — OR a wall-clock +// safety ceiling trips (a started-but-never-advancing transport, §3 of review). +// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes +// the recorded take on the AUDIO thread — the file may not be fully written/closed +// for a tick or two. We defer the file move until the file exists AND is stable +// (§2 of review), bounded by a flush ceiling so a file that never appears fails +// cleanly rather than hanging. + +// Where an in-progress capture is in its lifecycle. +// Recording — live: transport running, shell keeps ticking. +// Finalizing — live-but-stopped: transport halted, shell stops the transport once +// then ticks waiting for the recorded file to flush/stabilize. +// Done — terminal: the file is flushed + stable, finalize (move + Sample) now. +// Failed — terminal: the flush ceiling tripped without a stable file — give up +// (RenderFailed) + restore. (A record that produced NO file at all also +// lands here via the shell's finalize returning RenderFailed.) +// Only Recording and Finalizing are live phases the shell advances per tick; Done and +// Failed are the shell's verdict to act on (finalize-or-fail, then restore). +enum class RecordPhase { + Recording, + Finalizing, + Done, + Failed +}; + +// A distilled transport reading for the pure transition, so the state machine never +// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` +// is GetPlayPositionEx (latency-compensated what-you-hear position). +struct TransportReading { + bool recording = false; + double playPosition = 0.0; +}; + +// Everything the pure transition needs beyond the current phase, gathered by the +// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling +// decision (the shell only reads and reports; it never decides a transition itself). +struct RecordTickInputs { + TransportReading transport; + + // Wall-clock seconds since begin() (the shell reads a steady clock). Drives the + // record safety ceiling: a transport that starts but never advances to the range + // end (stuck / looping) would otherwise keep the machine in Recording forever. + double elapsedSeconds = 0.0; + + // Wall-clock seconds spent in the Finalizing phase (since the transport stop). + // Drives the flush ceiling: bound the deferred-finalize wait so a file that never + // stabilizes fails cleanly instead of hanging. + double finalizingSeconds = 0.0; + + // Whether the recorded take's file exists AND is stable/closed this tick (the + // shell resolves the take source path and checks size-stable-across-a-tick). + // Only consulted in Finalizing. + bool fileReady = false; +}; + +// --- Safety ceilings (named constants, review §2/§3) ------------------------- +// +// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form +// the record wall-clock ceiling. Generous so a normal record (with pre-roll, count-in, +// or transport latency) never trips it; tight enough that a stuck transport is force- +// terminated within a few seconds of overrun. +inline constexpr double kRecordMarginSeconds = 5.0; + +// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the +// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the +// take on the audio thread within a tick or two in practice; this is a generous bound. +inline constexpr double kFinalizeFlushCeilingSeconds = 5.0; + +// The pure transition: given the current phase, this tick's inputs, and the record +// range end, return the next phase. Total + deterministic. +// +// From Recording: +// * recording AND cursor < end AND under the record ceiling -> Recording (wait) +// * recording AND cursor >= end -> Finalizing (reached end) +// * NOT recording -> Finalizing (stopped early) +// * recording BUT over the record ceiling (end-start+margin)-> Finalizing (stuck: forced) +// From Finalizing: +// * fileReady -> Done (flushed + stable) +// * over the flush ceiling without a stable file -> Failed (give up) +// * otherwise -> Finalizing (keep flushing) +// Done and Failed are sticky: feeding a terminal phase back returns it unchanged, so a +// late tick before teardown finishes cannot flip the verdict (the idempotence the +// shell's single-restore relies on). +RecordPhase advanceRecordPhase(RecordPhase current, + const RecordTickInputs& inputs, + double rangeStartSeconds, + double rangeEndSeconds); + +// True once the shell must STOP the transport and begin the flush wait — i.e. the +// phase has left Recording (Finalizing/Done/Failed). Used by the shell to fire the +// (idempotent) transport stop exactly on the Recording -> Finalizing edge. +bool isStopRequested(RecordPhase phase); + +// True for the phases the shell must ACT on to conclude (finalize-or-fail + restore). +// Only Done and Failed are terminal; Recording and Finalizing are live. +bool isTerminalPhase(RecordPhase phase); + +} // namespace reasampler diff --git a/tests/test_realtime_record.cpp b/tests/test_realtime_record.cpp new file mode 100644 index 0000000..c904ecc --- /dev/null +++ b/tests/test_realtime_record.cpp @@ -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 +#include + +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; +}