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
+147
View File
@@ -0,0 +1,147 @@
#pragma once
// provenance — the REAPER-free core behind Milestone 10 (re-capture from source).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The shell (main.cpp / actions.cpp)
// gathers the raw inputs from REAPER — the source item media-file names, the
// source track FX-chain identity (names / GUIDs / enabled flags), the exact
// capture range, scope, tail — and hands plain strings/values here. This module
// owns:
//
// * CaptureRecipe — the recorded capture request PLUS the source FX-chain
// identity at capture time. Everything "re-capture from
// source" needs to re-run the SAME request against the
// source's CURRENT state, and to tell whether the source
// drifted since capture.
// * the ENCODING of a recipe into the single `Provenance.fxChainSnapshot`
// string (M1's field already JSON-round-trips one string,
// so the whole thin fingerprint rides in it — no schema
// change to Sample).
// * fxChainIdentity — folds the shell-gathered FX-chain rows into one identity
// string (the drift-detection component of the fingerprint).
// * detectParent — the pure parent-detection decision: given the resolved
// absolute media-file path(s) of the capture's source item(s)
// and the bank's path->sampleId map, decide whether this
// capture genuinely derives from a bank sample (P1: identity
// by resolved file path only — no fuzzy match, no false
// parentage).
//
// Fork picks (docs/product/provenance.md, settled 2026-07-23): P1 = a THIN
// reproducibility fingerprint (drift-detect + re-run the same request), NOT a
// serialized FX chunk to restore. P2 = bank-only re-capture. So nothing here
// stores a restorable chain, and nothing here reaches into view_mode_model.
#include <optional>
#include <string>
#include <vector>
namespace reasampler {
// Capture scope, mirrored from render_settings' CaptureScope but kept independent
// here so the pure provenance module does not pull the whole render_settings graph
// in. The shell maps its CaptureScope onto this two-value enum. Item = item/take FX
// only; Track = item FX + the track's own FX (CLAUDE.md §Capture FX scope).
enum class ProvenanceScope {
Item,
Track,
};
// One FX-chain entry as the shell reads it from REAPER (TrackFX_GetFXName /
// TrackFX_GetFXGUID / TrackFX_GetEnabled). Plain data — the shell fills it, the
// pure fold turns the vector into the identity string.
struct FxIdentityEntry {
std::string name; // TrackFX_GetFXName
std::string guid; // TrackFX_GetFXGUID -> guidToString (per-instance identity)
bool enabled; // TrackFX_GetEnabled
};
// The recorded capture recipe + source FX-chain identity — the thin fingerprint.
// Re-capture replays the request fields verbatim against the source's CURRENT
// state; fxChainIdentity is compared post-hoc to report drift. Ordinary equality
// (via ==) is a full recipe match; fxChainIdentity difference alone is "the source
// drifted but the recipe is the same" (the re-run still succeeds, the user is told).
struct CaptureRecipe {
ProvenanceScope scope = ProvenanceScope::Track;
int sourceMode = 0; // reasampler::SourceMode as int (bank_model)
double startSeconds = 0.0; // exact bounds — no rounding (invariant)
double endSeconds = 0.0;
int tailMode = 0; // reasampler::TailMode as int (render_settings)
double tailMs = 0.0;
int sampleRate = 0; // 0 = follow project rate
int channelCount = 2;
// Canonical GUID strings of the source track(s) the capture came from
// (guidString form). Re-capture resolves these back to live tracks.
std::vector<std::string> trackGuids;
// The source FX-chain identity at capture time — the drift component. A folded
// string (fxChainIdentity) of the in-scope FX rows. Not a restorable chunk.
std::string fxChainIdentity;
bool operator==(const CaptureRecipe& o) const;
bool operator!=(const CaptureRecipe& o) const { return !(*this == o); }
};
// Folds the shell-gathered FX rows into ONE identity string. Order-sensitive
// (chain order is part of identity), delimited so a name containing the delimiter
// cannot forge a different chain (the fields are length-prefixed). Empty vector ->
// empty string (a no-FX source has an empty, stable identity). Pure + deterministic.
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries);
// Combines several per-track FX-chain identity strings (one per source track, in
// track order) into ONE identity, length-prefixing each so two different per-track
// partitions can never collide by concatenation (e.g. {"X",""} != {"","X"}). Used
// for a multi-track Track-scope capture. A single-track capture combines to a
// stable, unambiguous wrapping of its one identity. Pure + deterministic.
std::string combineChainIdentities(const std::vector<std::string>& perTrack);
// Encodes a CaptureRecipe into the single string stored in
// Provenance.fxChainSnapshot. Self-describing, versioned, and escape-safe so it
// round-trips losslessly through the Sample JSON (which treats the whole thing as
// one opaque string value). buildFingerprint(x) then parseFingerprint(...) == x.
std::string buildFingerprint(const CaptureRecipe& recipe);
// Parses a fingerprint produced by buildFingerprint. Returns nullopt on any
// malformed / unrecognized-version input (never throws, never UB) so a legacy or
// corrupt provenance string degrades to "no recipe" gracefully rather than
// mis-driving a re-capture.
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint);
// --- Parent detection (P1: identity by resolved file path) -------------------
// One bank sample as the detector sees it: its stable id and the ABSOLUTE,
// normalized path its file resolves to (the shell resolves relativePath against
// the current project dir via resolveBankFile + normalizeSlashes before handing
// it here). Plain data so the decision is pure and testable.
struct BankFileRef {
std::string sampleId;
std::string absolutePath; // normalized (forward-slash, no trailing slash)
};
// Decides whether a capture derives from a bank sample.
//
// RULE (stated for the handoff, honest — no false parentage): a capture derives
// from a bank sample iff EVERY source item whose media file could be resolved
// points at the SAME bank sample's file (by exact normalized absolute path). If
// the source items resolve to files not in the bank, or to MORE THAN ONE distinct
// bank sample (ambiguous parentage), no parent is recorded. An empty source-file
// set (nothing resolvable) yields no parent.
//
// sourceItemFiles : normalized absolute paths of the capture's source items'
// take media files (the shell gathers + normalizes them). A
// file that could not be resolved is simply omitted by the
// shell — it never becomes an empty string here.
// bankFiles : the active book's samples as BankFileRefs (path -> id).
//
// Returns the parent sample id, or nullopt when the capture is not a genuine
// resample-from-sample. Comparison is exact path identity; the caller normalizes
// both sides identically so a slash/case difference never spuriously matches or
// misses (case handling is the caller's normalization contract, not decided here).
std::optional<std::string> detectParent(
const std::vector<std::string>& sourceItemFiles,
const std::vector<BankFileRef>& bankFiles);
} // namespace reasampler