Q-W3: main.cpp → pointers+entry+dispatch via 4 capture hoists; one pure wav_codec RIFF owner; ICaptureBackend deleted; capture_realtime rename + finalize split; shared stampCaptureSample; makeUniqueTag gains monotonic counter (fixes same-second batch collisions). 60/60 green.

This commit is contained in:
2026-07-29 10:56:11 -04:00
parent d7d7f7e084
commit 09f7173db2
29 changed files with 2972 additions and 2426 deletions
+70 -41
View File
@@ -1,19 +1,23 @@
#pragma once
#include "core/namespaces.h"
// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split).
//
// This header declares the capture *seam* the later milestones fill:
// * CaptureRequest — everything a capture needs, source-mode-agnostic.
// * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements
// (headless, immediate, returns a finished Sample).
// * OfflineRenderBackend — the deterministic default; drives the offline scopes.
// * OfflineRenderBackend — the deterministic default; a plain CONCRETE class
// (the former ICaptureBackend interface was deleted in
// Q-W3, T4-26 — it had one deriver and zero polymorphic
// call sites; every construction site instantiates the
// concrete type).
// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven
// across timer ticks; deliberately NOT an ICaptureBackend
// across timer ticks; a genuinely different lifecycle
// (see the SEAM CHOICE note at its declaration).
// * makeUniqueTag / stampCaptureSample — the shared file-tag mint and the shared
// finished-capture metadata stamp both backends call
// (Q-W3 riders T1-11 / T2-09).
//
// 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
// REAPER-free lets callers (the capture orchestration TUs) depend on the seam
// without dragging the SDK into every include site.
#include <memory>
@@ -23,13 +27,17 @@
#include "core/model/bank_model.h"
#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract
// MediaTrack is forward-declared (like track_guid.h) so this header stays
// REAPER-free while RealtimeRecordBackend::begin can take the resolved source
// MediaTrack* to tap. The pointers are opaque here — never dereferenced in a
// pure/header context; only the REAPER-facing capture_realtime.cpp touches them.
// MediaTrack / ReaProject are forward-declared (like track_guid.h) so this header
// stays REAPER-free while RealtimeRecordBackend::begin can take the resolved source
// MediaTrack* to tap and stampCaptureSample can take the project handles its reads
// pin. The pointers are opaque here — never dereferenced in a pure/header context;
// only the REAPER-facing capture TUs touch them.
class MediaTrack;
class ReaProject;
namespace reasampler {
namespace reasampler::capture {
using model::Sample;
// Audio bit-depth for the rendered wav. 32-bit float is the M3 default —
// rationale lives in capture.cpp next to the sink-config bytes.
@@ -106,27 +114,48 @@ struct CaptureResult {
std::string message; // human-readable detail for the console log
};
// The capture seam. One method: run a request, return a populated Sample (or a
// failure code). Backends are non-destructive — they must restore any global
// state they touch before returning (OfflineRenderBackend snapshots/restores the
// RENDER_* project settings).
class ICaptureBackend {
public:
virtual ~ICaptureBackend() = default;
virtual CaptureResult capture(const CaptureRequest& request) = 0;
};
// Deterministic offline-render backend. Drives the full offline source family —
// master mix / time selection, selected tracks, selected items, razor area — all
// wet-only (render_settings.h) with optional tail. The source selection + range
// are resolved by the caller (the action layer) and handed in via the
// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection
// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend).
class OfflineRenderBackend : public ICaptureBackend {
// Non-destructive: restores every RENDER_* setting it touches on every path.
// A plain concrete class — the former ICaptureBackend interface was deleted
// (Q-W3, T4-26): it had one deriver, zero polymorphic call sites, and the async
// realtime backend deliberately never implemented it (see SEAM CHOICE below).
class OfflineRenderBackend {
public:
CaptureResult capture(const CaptureRequest& request) override;
CaptureResult capture(const CaptureRequest& request);
};
// --- Shared backend helpers (Q-W3 riders) ------------------------------------
// Mints the filesystem-safe disambiguating tag for one capture's file stem +
// Sample id: "<prefix><unix-epoch-seconds>-<n>" where <n> is a PER-SESSION
// MONOTONIC counter (T1-11 fix). The wall-clock second alone had a collision
// window: two captures of the same baseName within one second derived the same
// stem, so the second render silently overwrote the first file (reachable via
// batch capture driving short renders back-to-back). The counter makes every tag
// of a session distinct regardless of timing. `prefix` is the backend's family
// marker ("" offline, "rt-" realtime).
std::string makeUniqueTag(const std::string& prefix);
// Stamps the SHARED finished-capture metadata onto `s` (T2-09 dedupe — this stamp
// was copy-pasted per backend and had silently diverged): trackGuids +
// channelCount (echoed from the request), the resolved sampleRate (request rate,
// else PROJECT_SRATE read from `rateProj`; 0 stays 0 when unknown), captureTempo
// (Master_GetTempo), the capture-start time signature (TimeMap_GetTimeSigAtTime
// against `timeSigProj` — the offline path passes nullptr = active project, the
// realtime path pins the record's own project; the divergence stays caller-visible
// as this argument), the WAV-aware contentHash of the finished file at
// `absolutePath` (left empty when unreadable — the safe, confirm-eliciting
// direction), and createdTimestamp (now). The per-backend bits (id, paths, bounds,
// tier, realtime's recorded-length override) stay with each caller.
void stampCaptureSample(Sample& s, const CaptureRequest& req,
ReaProject* rateProj, ReaProject* timeSigProj,
const std::string& absolutePath);
// --- Realtime-record backend: the ASYNC seam ---------------------------------
//
// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport
@@ -137,16 +166,17 @@ public:
// 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.
// SEAM CHOICE (surfaced): the two backends deliberately share NO interface. The
// lifecycles are genuinely different (offline is headless + immediate — one
// synchronous capture() call returns a finished Sample; realtime is
// transport-driven + async — begin/tick/abort across timer ticks), so a shared
// 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; the realtime backend owns this small bespoke async seam,
// driven by exactly one caller (the timer-driven realtime_lifecycle). This is the
// split-sync/async fork, chosen over a unified async interface for that reason.
// (The old synchronous ICaptureBackend interface over OfflineRenderBackend was
// deleted in Q-W3 — T4-26: one deriver, zero polymorphic call sites.)
// One tick's verdict from the in-flight record.
enum class RealtimeTickStatus {
@@ -163,9 +193,8 @@ struct RealtimeTickResult {
// The opaque in-flight capture state. Owns the snapshot of everything to restore
// (temp track + its receive sends from the source tracks, other tracks' I_RECARM,
// 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.
// Defined in capture_realtime_shell.cpp; the header stays REAPER-free (nothing is
// dereferenced 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.
@@ -173,10 +202,10 @@ struct RealtimeTickResult {
// 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).
// Out-of-line deleter so callers (realtime_lifecycle) can own a unique_ptr to the
// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the
// delete is compiled in capture_realtime_shell.cpp where the type is complete,
// keeping this header REAPER-free (load-bearing split).
struct RealtimeCaptureStateDeleter {
void operator()(RealtimeCaptureState* p) const noexcept;
};
@@ -232,4 +261,4 @@ public:
RealtimeTickResult abort(RealtimeCaptureState& state);
};
} // namespace reasampler
} // namespace reasampler::capture