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
+523
View File
@@ -0,0 +1,523 @@
// capture_batch.cpp — the M11 batch-capture family + the M10 re-capture-from-source
// action (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded
// as a parameter). See the header.
//
// 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).
#include "shell/capture/capture_batch.h"
#include <cstddef>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "bank_panel.h" // bankPanelSelectedSampleIds / Refresh
#include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome
#include "core/model/bank_book.h" // BankBook / Bank
#include "core/model/provenance.h" // recipe parse/build, fingerprint
#include "persist.h" // ReaSamplerSession
#include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / trackByGuid
#include "shell/capture/scope_resolve.h" // ResolvedSource
#include "shell/capture/track_guid.h" // guidString
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedMediaItems
#define REAPERAPI_WANT_GetSelectedMediaItem
#define REAPERAPI_WANT_GetMediaItem_Track
#define REAPERAPI_WANT_GetMediaItemInfo_Value
#define REAPERAPI_WANT_CountMediaItems
#define REAPERAPI_WANT_GetMediaItem
#define REAPERAPI_WANT_SetMediaItemSelected
#define REAPERAPI_WANT_UpdateArrange
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_GetSelectedTrack
#define REAPERAPI_WANT_SetTrackSelected
#define REAPERAPI_WANT_SetOnlyTrackSelected
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler::capture {
// --- M11: batch capture (per selected item / per razor area) ----------------
//
// One action fires N captures — one bank sample per selected item (item scope) or per
// razor area (track scope, each area's own range). Each individual capture honors every
// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan
// neutralize, relative paths, channel preservation) and M10 provenance stamping applies
// per capture where its detection rule matches. The load-bearing principle holds: each
// unit writes a file + a bank index entry ONLY; nothing lands in the arrange.
//
// Per-unit FILE NAMING: each unit's baseName carries its ordinal ("item-1",
// "item-2", ...) so two units are never asked to write the same stem within one
// batch, and the shared makeUniqueTag now appends a per-session monotonic counter
// (T1-11 fix) so even same-second units across batches cannot collide.
namespace {
// RAII snapshot/restore of the project's media-item selection. Batch item capture must
// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is
// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including
// a mid-batch failure or early return — because selection restoration is part of the
// non-destructive invariant. Snapshot on construct (the currently-selected item set),
// restore on destruct (deselect everything, then re-select exactly the snapshot).
class ItemSelectionGuard
{
public:
ItemSelectionGuard()
{
const int n = CountSelectedMediaItems(nullptr);
for (int i = 0; i < n; ++i)
if (MediaItem* it = GetSelectedMediaItem(nullptr, i))
selected_.push_back(it);
}
~ItemSelectionGuard()
{
// Deselect every item in the project, then re-select the snapshot — restoring the
// exact original set regardless of what the batch selected in between. Iterate ALL
// items (not just the currently-selected) so any transient selection is cleared.
const int total = CountMediaItems(nullptr);
for (int i = 0; i < total; ++i)
if (MediaItem* it = GetMediaItem(nullptr, i))
SetMediaItemSelected(it, false);
for (MediaItem* it : selected_)
SetMediaItemSelected(it, true);
UpdateArrange(); // reflect the restored selection in the arrange view
}
ItemSelectionGuard(const ItemSelectionGuard&) = delete;
ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete;
private:
std::vector<MediaItem*> selected_;
};
// Selects exactly `item` (deselect-all then select-one) so the offline render's
// selected-items bit (&32) captures a single item. Used inside the batch loop under the
// ItemSelectionGuard, which restores the user's original selection afterward.
void selectOnlyItem(MediaItem* item)
{
const int total = CountMediaItems(nullptr);
for (int i = 0; i < total; ++i)
if (MediaItem* it = GetMediaItem(nullptr, i))
SetMediaItemSelected(it, it == item);
}
// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving
// track order then area order — the batch analog of resolveRazorRange, which unions them.
// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser.
std::vector<std::pair<MediaTrack*, RazorRange>> collectRazorAreas()
{
std::vector<std::pair<MediaTrack*, RazorRange>> areas;
const int n = CountTracks(nullptr);
for (int i = 0; i < n; ++i)
{
MediaTrack* tr = GetTrack(nullptr, i);
if (!tr) continue;
std::vector<char> buf(8192, '\0');
if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
continue;
for (const RazorRange& r : parseRazorEdits(std::string(buf.data())))
areas.push_back({tr, r});
}
return areas;
}
// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must
// transiently select exactly the area's owning track per render (track scope's &128 bit
// renders whatever TRACKS are selected); the user's original track selection is restored
// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard.
class TrackSelectionGuard
{
public:
TrackSelectionGuard()
{
const int n = CountSelectedTracks(nullptr);
for (int i = 0; i < n; ++i)
if (MediaTrack* tr = GetSelectedTrack(nullptr, i))
selected_.push_back(tr);
}
~TrackSelectionGuard()
{
// Deselect every track, then re-select the snapshot — the exact original set.
const int total = CountTracks(nullptr);
for (int i = 0; i < total; ++i)
if (MediaTrack* tr = GetTrack(nullptr, i))
SetTrackSelected(tr, false);
for (MediaTrack* tr : selected_)
SetTrackSelected(tr, true);
}
TrackSelectionGuard(const TrackSelectionGuard&) = delete;
TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete;
private:
std::vector<MediaTrack*> selected_;
};
} // namespace
// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the
// selection (RAII restore on every path), then for each selected item transiently selects
// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the
// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for
// the whole batch). Reports a mixed-result summary (explicit-action response — allowed).
void RunBatchCaptureItems(ReaSamplerSession& session)
{
// Read the selected items up front (pointers stay valid — batch mutates only selection
// flags, never adds/removes items). Also capture each item's exact bounds and owning
// track NOW, while the full selection is live, before any transient re-selection.
struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; };
std::vector<ItemUnit> itemUnits;
{
const int n = CountSelectedMediaItems(nullptr);
for (int i = 0; i < n; ++i)
{
MediaItem* it = GetSelectedMediaItem(nullptr, i);
if (!it) continue;
MediaTrack* tr = GetMediaItem_Track(it);
if (!tr) continue;
const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
itemUnits.push_back({it, tr, pos, pos + len});
}
}
if (itemUnits.empty())
{
ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n");
return;
}
// Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/
// inverted item ranges (a zero-length item) are dropped here so no stray render runs.
std::vector<BatchRange> ranges;
ranges.reserve(itemUnits.size());
for (const ItemUnit& u : itemUnits)
ranges.push_back({u.start, u.end});
const std::vector<CaptureUnit> plan = planCaptureUnits(ranges);
BatchOutcome outcome;
bool anyAdded = false;
{
// Restore the user's ORIGINAL item selection on every exit path (incl. early
// return / mid-batch failure) — non-destructive invariant.
ItemSelectionGuard selGuard;
// The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only
// for those whose range survived planning (same drop rule), matching by ordinal.
std::size_t planIdx = 0;
for (const ItemUnit& u : itemUnits)
{
if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep
const CaptureUnit& unit = plan[planIdx++];
// Transiently select ONLY this item so the item-scope render captures exactly it.
selectOnlyItem(u.item);
ResolvedSource src;
src.startSeconds = unit.startSeconds;
src.endSeconds = unit.endSeconds;
src.sourceTracks.push_back(u.track);
if (std::string g = guidString(u.track); !g.empty())
src.trackGuids.push_back(std::move(g));
const std::string baseName = "item-" + std::to_string(unit.ordinal);
CaptureResult res = captureAndIndexOne(
session, CaptureScope::Item, src, baseName,
unit.startSeconds, unit.endSeconds);
const bool ok = (res.status == CaptureStatus::Ok);
outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message);
if (ok) anyAdded = true;
}
} // selGuard restores the original selection here, on every path
// Persist ONCE for the whole batch (one ext-state write) — only if something landed.
// S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample,
// so a single increment past the last-seen value is enough to trigger one instance reload.
if (anyAdded) {
session.bumpBankGeneration();
session.saveToActiveProject();
}
ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str());
}
// Batch razor capture: one bank sample per razor AREA, track scope over that area's own
// range (the area's owning track is the source track). Track scope renders the selected
// TRACKS via master (&128), so each unit transiently selects ONLY its owning track
// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original
// track selection on every path. The razor selection itself is read-only and left intact.
// Persists ONCE at the end. Reports a mixed-result summary.
void RunBatchCaptureRazor(ReaSamplerSession& session)
{
const std::vector<std::pair<MediaTrack*, RazorRange>> areas = collectRazorAreas();
if (areas.empty())
{
ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n");
return;
}
std::vector<BatchRange> ranges;
ranges.reserve(areas.size());
for (const auto& a : areas)
ranges.push_back({a.second.startSeconds, a.second.endSeconds});
const std::vector<CaptureUnit> plan = planCaptureUnits(ranges);
BatchOutcome outcome;
bool anyAdded = false;
{
// Restore the user's ORIGINAL track selection on every exit path.
TrackSelectionGuard selGuard;
std::size_t planIdx = 0;
for (const auto& a : areas)
{
if (!(a.second.endSeconds > a.second.startSeconds)) continue; // dropped — lockstep
const CaptureUnit& unit = plan[planIdx++];
MediaTrack* tr = a.first;
// Transiently select ONLY this track so the track-scope render (&128) captures
// exactly it via master (over the custom time bounds we set per unit).
SetOnlyTrackSelected(tr);
ResolvedSource src;
src.startSeconds = unit.startSeconds;
src.endSeconds = unit.endSeconds;
src.sourceTracks.push_back(tr);
if (std::string g = guidString(tr); !g.empty())
src.trackGuids.push_back(std::move(g));
const std::string baseName = "razor-" + std::to_string(unit.ordinal);
CaptureResult res = captureAndIndexOne(
session, CaptureScope::Track, src, baseName,
unit.startSeconds, unit.endSeconds);
const bool ok = (res.status == CaptureStatus::Ok);
outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message);
if (ok) anyAdded = true;
}
} // selGuard restores the original track selection here, on every path
// S9: one coalesced bump for the whole razor batch (see the item-batch note above).
if (anyAdded) {
session.bumpBankGeneration();
session.saveToActiveProject();
}
ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str());
}
// --- M10: re-capture from source --------------------------------------------
//
// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT
// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and
// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the
// load-bearing capture-never-places line, structurally visible: this function has no
// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore
// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places
// manually if they want the new version on the timeline.
//
// Failure modes are handled explicitly and reported to the user (a direct response
// to an explicit action is allowed by the console policy):
// * the selected sample has no provenance (not a resample) -> reported, no-op.
// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op.
// * the recorded source track(s) no longer exist -> reported, no-op.
// * the render itself fails to satisfy the recorded request -> reported, no-op.
// On success, if the source FX chain drifted since capture (recorded vs current
// identity differ) the user is told — the re-capture still reflects the source AS IT
// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source).
void RunRecaptureFromSource(ReaSamplerSession& session)
{
const std::vector<std::string> selected = bankPanelSelectedSampleIds();
if (selected.empty())
{
ShowConsoleMsg("ReaSampler re-capture: select a sample in the bank panel first.\n");
return;
}
if (selected.size() > 1)
{
ShowConsoleMsg("ReaSampler re-capture: select a single sample to re-capture.\n");
return;
}
const std::string sampleId = selected.front();
// Resolve the sample from the bank it lives in (the focused region's displayed bank).
const std::string srcBankId = bankPanelSelectedSourceBankId();
const Bank* bank = session.book().bank(srcBankId);
const model::Sample* orig = bank ? bank->index.query(sampleId) : nullptr;
if (!orig)
{
ShowConsoleMsg("ReaSampler re-capture: the selected sample is no longer in the bank.\n");
return;
}
if (!orig->provenance)
{
ShowConsoleMsg("ReaSampler re-capture: this sample has no provenance "
"(it was not resampled from a bank sample).\n");
return;
}
// Parse the recorded capture recipe from the fingerprint. A legacy / corrupt
// string fails gracefully — never a partial re-capture.
const std::string recordedParentId = orig->provenance->parentSampleId;
const std::string recordedFingerprint = orig->provenance->fxChainSnapshot;
const std::optional<model::CaptureRecipe> recipe =
model::parseFingerprint(recordedFingerprint);
if (!recipe)
{
ShowConsoleMsg("ReaSampler re-capture: this sample's provenance is unreadable "
"(recorded by an older/incompatible build); cannot re-capture.\n");
return;
}
// Resolve the recorded source track GUID(s) to live tracks. Any missing track is a
// hard failure — we will not silently re-capture a different source.
std::vector<MediaTrack*> sourceTracks;
for (const std::string& g : recipe->trackGuids)
{
MediaTrack* tr = trackByGuid(g);
if (!tr)
{
ShowConsoleMsg("ReaSampler re-capture: a recorded source track no longer "
"exists in this project; cannot re-capture from source.\n");
return;
}
sourceTracks.push_back(tr);
}
if (sourceTracks.empty())
{
// The recipe recorded no source tracks (e.g. an item-scope capture whose source
// tracks were not track-scoped). Without a resolvable source we cannot re-run.
ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this "
"sample; cannot re-capture from source.\n");
return;
}
const CaptureScope scope =
recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item
: CaptureScope::Track;
// Rebuild the capture request verbatim from the recorded recipe — the SAME request,
// re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate,
// channels, bit depth all match the original so an unchanged source produces a
// byte-identical file (bit-identical-repeats invariant, consumed as a feature).
CaptureRequest req;
req.sourceMode = static_cast<SourceMode>(recipe->sourceMode);
req.startSeconds = recipe->startSeconds;
req.endSeconds = recipe->endSeconds;
req.wetDry = 1.0;
req.tailMode = static_cast<TailMode>(recipe->tailMode);
req.tailMs = recipe->tailMs;
req.sampleRate = recipe->sampleRate;
req.channelCount = recipe->channelCount;
req.bitDepth = WavBitDepth::Float32;
req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName;
req.trackGuids = recipe->trackGuids;
// Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to
// compare against the recorded identity for drift reporting. Mirror the same
// scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*;
// track scope reads the track FX chain via TrackFX_*.
std::string currentIdentity;
if (scope == CaptureScope::Item) {
const int n = CountSelectedMediaItems(nullptr);
std::vector<MediaItem*> items;
items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
for (int i = 0; i < n; ++i) {
MediaItem* it = GetSelectedMediaItem(nullptr, i);
if (it) items.push_back(it);
}
currentIdentity = fxChainIdentityForItems(items);
} else {
std::vector<std::string> perTrackNow;
perTrackNow.reserve(sourceTracks.size());
for (MediaTrack* tr : sourceTracks)
perTrackNow.push_back(fxChainIdentityForTrack(tr));
currentIdentity = model::combineChainIdentities(perTrackNow);
}
const bool drifted = (currentIdentity != recipe->fxChainIdentity);
// Render (bank-only; renderOffline never touches the timeline).
CaptureResult res = renderOffline(scope, sourceTracks, req);
if (res.status != CaptureStatus::Ok)
{
ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str());
return;
}
// Update the Sample IN PLACE: keep its identity (id) and its provenance thread
// (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but
// adopt the regenerated file's path / hash / length / rate / timestamp. The
// fingerprint is rebuilt from the recipe with the CURRENT FX identity so a
// subsequent re-capture measures drift from this point, not the original.
model::CaptureRecipe refreshed = *recipe;
refreshed.fxChainIdentity = currentIdentity;
model::Sample updated = *orig; // copy: preserves id, displayName, tier, key
updated.relativePath = res.sample.relativePath;
updated.contentHash = res.sample.contentHash;
updated.sourceMode = res.sample.sourceMode;
updated.sourceRange = res.sample.sourceRange;
updated.channelCount = res.sample.channelCount;
updated.sampleRate = res.sample.sampleRate;
updated.lengthSeconds = res.sample.lengthSeconds;
updated.captureTempo = res.sample.captureTempo;
updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp
updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter
updated.trackGuids = res.sample.trackGuids;
updated.createdTimestamp = res.sample.createdTimestamp;
// NOTE: levels, clipped, and lengthBeats are carried from the original (via the
// *orig copy above) because the offline backend does not populate them today
// (res.sample leaves them at defaults). If a later milestone populates these
// fields at capture time, refresh them here from res.sample instead.
model::Provenance prov;
prov.parentSampleId = recordedParentId;
prov.fxChainSnapshot = model::buildFingerprint(refreshed);
updated.provenance = prov;
// Single batched undo point around the in-place bank mutation (mirrors the bank
// action family's R-B pattern). The mutation is index-only ext-state; the render
// wrote a new file but placed nothing on the timeline.
Undo_BeginBlock2(nullptr);
const bool changed = session.book().updateSampleInPlace(sampleId, updated);
if (changed)
{
// Record the regenerated file in the owned manifest (a new file the tool wrote);
// the superseded old file becomes an orphan reclaimed by Phase R prune.
session.owned().add(updated.relativePath);
// S9: re-capture-in-place regenerates the SAME id's audio — the exact case the
// hands-free refresh exists for (an instance referencing this id keeps playing the
// OLD audio until it reloads). Bump inside the undo block so undo rolls back the
// generation with the rest of the blob.
session.bumpBankGeneration();
const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty
Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
persisted ? UNDO_STATE_MISCCFG : 0);
}
else
{
Undo_EndBlock2(nullptr, "", 0); // nothing mutated -> discard the empty point
}
bankPanelRefresh(); // reflect the regenerated file in the docked grid
if (drifted)
ShowConsoleMsg("ReaSampler re-capture: the source FX chain changed since the "
"original capture -- the sample was regenerated from the source's "
"current state.\n");
}
} // namespace reasampler::capture