M10: provenance populate + re-capture from source (bank-only)

Pure provenance core (recipe fingerprint, FX-chain identity, parent
detection) + shell reads; capture stamps provenance on resample-from-sample;
re-capture regenerates a provenanced sample from its source, never touching
the timeline. Adds BankIndex/BankBook in-place update. CTest-covered.
This commit is contained in:
2026-07-26 17:33:22 -04:00
parent 399dafa859
commit 20018c1df2
13 changed files with 1368 additions and 11 deletions
+323 -10
View File
@@ -31,10 +31,14 @@
#include "capture.h"
#include "insert.h"
#include "persist.h"
#include "provenance.h"
#include "provenance_shell.h"
#include "render_settings.h"
#include "track_guid.h"
#include "view.h"
#include <filesystem> // project-dir derivation for provenance parent resolution
// Persistent action-id family (Phase V, V4 — channel-qualified). Every bindable action
// mints its command id from commandIdPrefix() + a per-action SUFFIX, and its Actions-list
// name from actionDisplayPrefix() + a phrase, both derived from the ONE channel bit in the
@@ -128,6 +132,14 @@ static int g_cmdInsertSelectedConform = 0;
// backend. Dialog-free. (Replaces the removed CAPTURE_MASTER_REALTIME action.)
static int g_cmdCaptureTrackRealtime = 0;
// Command id for the M10 "re-capture from source" action. NEW FOREVER-STABLE string
// (suffix RECAPTURE_FROM_SOURCE). Regenerates the bank panel's selected PROVENANCED
// sample from its recorded source's current state and updates the Sample in place —
// BANK-ONLY, never places on the timeline (load-bearing principle). Reports the no-
// provenance / vanished-source / drift cases to the console (a direct response to an
// explicit action, allowed by the console policy).
static int g_cmdRecaptureFromSource = 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
@@ -392,6 +404,90 @@ static bool resolveRange(double& start, double& end, std::string& why)
return false;
}
// Current project's directory (parent of its .rpp), forward-slashed, no trailing
// slash — the same derivation capture.cpp does internally, needed here so M10 can
// resolve the bank's relative paths to absolute for parent detection. Empty for an
// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank
// file resolve empty -> no false parentage. Read-only; mutates nothing.
static std::string currentProjectDir()
{
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
const std::string rpp(buf.data());
if (rpp.empty()) return {};
namespace fs = std::filesystem;
std::string dir = fs::path(rpp).parent_path().string();
for (char& c : dir) if (c == '\\') c = '/';
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
return dir;
}
// Maps a capture FX scope onto the pure provenance scope (kept decoupled so the
// pure provenance module does not depend on render_settings).
static reasampler::ProvenanceScope provenanceScopeFor(reasampler::CaptureScope scope)
{
return scope == reasampler::CaptureScope::Item ? reasampler::ProvenanceScope::Item
: reasampler::ProvenanceScope::Track;
}
// Builds the M10 provenance for a capture IF it genuinely resamples from a bank
// sample, else returns nullopt (the common, non-resample case). Detection rule
// (stated honestly): the capture's source item media file(s) must all resolve, by
// exact normalized absolute path, to ONE bank sample's file (detectParent). On a
// match, records that sample's id as the parent plus a THIN capture-recipe
// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels +
// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from
// source" can replay the request and report drift. NEVER a serialized chain to
// restore. Item scope folds an empty FX identity (take/item FX are not enumerable
// via TrackFX_*); the drift signal then keys on scope+range, which is honest.
static std::optional<reasampler::Provenance> buildCaptureProvenance(
const reasampler::CaptureRequest& req,
reasampler::CaptureScope scope,
const ResolvedSource& src)
{
const std::string projectDir = currentProjectDir();
const std::vector<reasampler::BankFileRef> bankFiles =
reasampler::bankFileRefs(g_session.book(), projectDir);
// The "what audio is being captured" source set depends on scope: item scope uses
// the SELECTED items (the user picked them); track scope uses the range-overlapping
// items ON the source tracks (the user picked the track, not the item).
const std::vector<std::string> sourceFiles =
scope == reasampler::CaptureScope::Item
? reasampler::selectedItemSourceFiles()
: reasampler::trackItemSourceFiles(src.sourceTracks, req.startSeconds,
req.endSeconds);
const std::optional<std::string> parentId =
reasampler::detectParent(sourceFiles, bankFiles);
if (!parentId) return std::nullopt; // not a resample-from-sample — no provenance
reasampler::CaptureRecipe recipe;
recipe.scope = provenanceScopeFor(scope);
recipe.sourceMode = static_cast<int>(req.sourceMode);
recipe.startSeconds = req.startSeconds;
recipe.endSeconds = req.endSeconds;
recipe.tailMode = static_cast<int>(req.tailMode);
recipe.tailMs = req.tailMs;
recipe.sampleRate = req.sampleRate;
recipe.channelCount = req.channelCount;
recipe.trackGuids = req.trackGuids;
// The in-scope FX-chain identity is the per-track chains combined in track order
// (Track scope), length-prefixed so distinct partitions never collide. Item scope
// has no readable take-FX chain, so each track folds to an empty identity and the
// combined result stays stable/honest (drift then keys on scope + range).
std::vector<std::string> perTrack;
perTrack.reserve(src.sourceTracks.size());
for (MediaTrack* tr : src.sourceTracks)
perTrack.push_back(reasampler::fxChainIdentityForTrack(tr));
recipe.fxChainIdentity = reasampler::combineChainIdentities(perTrack);
reasampler::Provenance prov;
prov.parentSampleId = *parentId;
prov.fxChainSnapshot = reasampler::buildFingerprint(recipe);
return prov;
}
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
static bool collectSelectedTracks(ResolvedSource& out)
{
@@ -589,6 +685,23 @@ private:
}
};
// Renders one CaptureRequest through the offline backend under the scope's
// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and
// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE
// place: the out-of-scope FX / fader / pan chain is snapshotted, neutralized for the
// render, and fully restored on every path (RAII). Non-destructive; touches no
// timeline item (load-bearing principle) — it writes a file only.
static reasampler::CaptureResult renderOffline(
reasampler::CaptureScope scope,
const std::vector<MediaTrack*>& sourceTracks,
const reasampler::CaptureRequest& req)
{
ReaProject* proj = EnumProjects(-1, nullptr, 0);
FxBypassGuard fxGuard(scope, sourceTracks, proj);
reasampler::OfflineRenderBackend backend;
return backend.capture(req);
}
// Runs one capture-action-table row: resolve its scope source + range, snapshot &
// clear the out-of-scope FX AND neutralize their fader gain + pan chain (RAII),
// render via the offline backend, add the Sample to the bank, persist + mark dirty.
@@ -625,17 +738,17 @@ 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 (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);
// M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain —
// the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and
// the source selection is still live here. Returns nullopt unless this capture
// genuinely resamples from a bank sample (detectParent). Read-only.
const std::optional<reasampler::Provenance> prov =
buildCaptureProvenance(req, def.scope, src);
reasampler::OfflineRenderBackend backend;
reasampler::CaptureResult res = backend.capture(req);
// Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain
// neutralized for the render, fully restored on every path — see renderOffline
// and the FxBypassGuard header comment). Non-destructive; writes a file only.
reasampler::CaptureResult res = renderOffline(def.scope, src.sourceTracks, req);
if (res.status != reasampler::CaptureStatus::Ok)
{
@@ -643,6 +756,10 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
return;
}
// Stamp provenance onto the captured Sample (only set when this was a genuine
// resample-from-sample; otherwise the optional stays empty, per M1's contract).
res.sample.provenance = prov;
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
g_session.bank().add(res.sample);
// B-cap: record the created file in the owned-file manifest, at the same point the
@@ -657,6 +774,182 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
g_session.saveToActiveProject();
}
// --- 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).
static void RunRecaptureFromSource()
{
const std::vector<std::string> selected = reasampler::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 = reasampler::bankPanelSelectedSourceBankId();
const reasampler::Bank* bank = g_session.book().bank(srcBankId);
const reasampler::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<reasampler::CaptureRecipe> recipe =
reasampler::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 = reasampler::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 reasampler::CaptureScope scope =
recipe->scope == reasampler::ProvenanceScope::Item
? reasampler::CaptureScope::Item
: reasampler::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).
reasampler::CaptureRequest req;
req.sourceMode = static_cast<reasampler::SourceMode>(recipe->sourceMode);
req.startSeconds = recipe->startSeconds;
req.endSeconds = recipe->endSeconds;
req.wetDry = 1.0;
req.tailMode = static_cast<reasampler::TailMode>(recipe->tailMode);
req.tailMs = recipe->tailMs;
req.sampleRate = recipe->sampleRate;
req.channelCount = recipe->channelCount;
req.bitDepth = reasampler::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.
std::vector<std::string> perTrackNow;
perTrackNow.reserve(sourceTracks.size());
for (MediaTrack* tr : sourceTracks)
perTrackNow.push_back(reasampler::fxChainIdentityForTrack(tr));
const std::string currentIdentity = reasampler::combineChainIdentities(perTrackNow);
const bool drifted = (currentIdentity != recipe->fxChainIdentity);
// Render (bank-only; renderOffline never touches the timeline).
reasampler::CaptureResult res = renderOffline(scope, sourceTracks, req);
if (res.status != reasampler::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.
reasampler::CaptureRecipe refreshed = *recipe;
refreshed.fxChainIdentity = currentIdentity;
reasampler::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.trackGuids = res.sample.trackGuids;
updated.createdTimestamp = res.sample.createdTimestamp;
reasampler::Provenance prov;
prov.parentSampleId = recordedParentId;
prov.fxChainSnapshot = reasampler::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 = g_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.
g_session.owned().add(updated.relativePath);
const bool persisted = g_session.saveToActiveProject(); // book + manifest + 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
}
reasampler::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");
}
// STARTS the REALTIME track capture and returns immediately — the record runs across
// timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Resolves the
// selected tracks + the range (razor-else-time, the same orthogonal range logic as the
@@ -803,6 +1096,7 @@ static bool OnHookCommand(int command, int /*flag*/)
if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; }
if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; }
if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; }
if (command == g_cmdRecaptureFromSource) { RunRecaptureFromSource(); return true; }
if (command == g_cmdShowVersion)
{
// On-demand version readout — the ONLY version output on any path.
@@ -833,6 +1127,7 @@ static gaccel_register_t g_accelInsertSelected{};
static gaccel_register_t g_accelInsertSelectedConform{};
static gaccel_register_t g_accelCaptureTrackRealtime{};
static gaccel_register_t g_accelCancelRealtime{};
static gaccel_register_t g_accelRecaptureFromSource{};
static gaccel_register_t g_accelShowVersion{};
// gaccel desc storage. The Actions-list label is channel-qualified at runtime
@@ -843,6 +1138,7 @@ static std::string g_descInsertSelected;
static std::string g_descInsertSelectedConform;
static std::string g_descCaptureTrackRealtime;
static std::string g_descCancelRealtime;
static std::string g_descRecaptureFromSource;
static std::string g_descShowVersion;
// Composed command-id strings (channel-qualified), interned so register and the mirroring
@@ -852,6 +1148,7 @@ static const char* g_idInsertSelected = nullptr;
static const char* g_idInsertSelectedConform = nullptr;
static const char* g_idCaptureTrackRealtime = nullptr;
static const char* g_idCancelRealtime = nullptr;
static const char* g_idRecaptureFromSource = nullptr;
static const char* g_idShowVersion = nullptr;
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
@@ -888,6 +1185,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
// used at register (g_id*), so the mirror-unregister matches exactly.
g_rec->Register("-gaccel", (void*)&g_accelShowVersion);
g_rec->Register("-command_id", (void*)g_idShowVersion);
g_rec->Register("-gaccel", (void*)&g_accelRecaptureFromSource);
g_rec->Register("-command_id", (void*)g_idRecaptureFromSource);
g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime);
g_rec->Register("-command_id", (void*)g_idCancelRealtime);
g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime);
@@ -1038,6 +1337,20 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
rec->Register("gaccel", (void*)&g_accelCancelRealtime);
}
// Register the M10 "re-capture from source" action (command_id -> gaccel ->
// hookcommand). Regenerates the selected provenanced sample from its recorded
// source's current state; bank-only, never places on the timeline. Channel-
// qualified FOREVER-STABLE id (suffix RECAPTURE_FROM_SOURCE).
g_idRecaptureFromSource = internCmdId("RECAPTURE_FROM_SOURCE");
g_cmdRecaptureFromSource = rec->Register("command_id", (void*)g_idRecaptureFromSource);
if (g_cmdRecaptureFromSource)
{
g_descRecaptureFromSource = reasampler::channelActionName("re-capture from source");
g_accelRecaptureFromSource.accel.cmd = g_cmdRecaptureFromSource;
g_accelRecaptureFromSource.desc = g_descRecaptureFromSource.c_str();
rec->Register("gaccel", (void*)&g_accelRecaptureFromSource);
}
// Register the Phase V "show version" action (command_id -> gaccel -> hookcommand).
// On-demand only — prints the CMake-sourced version to the console when fired; no
// startup print. Channel-qualified FOREVER-STABLE id; label carries the channel prefix