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:
+21
-1
@@ -253,6 +253,19 @@ target_link_libraries(wav_trim PUBLIC peaks)
|
||||
add_library(app_version STATIC src/app_version.cpp)
|
||||
target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2j) Pure provenance library — NO REAPER, NO SWELL. The Milestone 10 core: the
|
||||
# recorded capture recipe (CaptureRecipe) + the thin drift-fingerprint that
|
||||
# rides in Provenance.fxChainSnapshot (P1=a), its build/parse round-trip, the
|
||||
# FX-chain identity fold, and the pure parent-detection decision (resample-from-
|
||||
# sample by resolved file path). Split out so the encoding + decision logic are
|
||||
# unit-tested outside the DAW; the FX-chain query, capture re-run, and action
|
||||
# registration stay in the shell (main.cpp / actions.cpp). No dependency on
|
||||
# bank_model — it takes plain strings/values at its boundary.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(provenance STATIC src/provenance.cpp)
|
||||
target_include_directories(provenance PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -329,6 +342,12 @@ add_executable(app_version_tests tests/test_app_version.cpp)
|
||||
target_link_libraries(app_version_tests PRIVATE app_version)
|
||||
add_test(NAME app_version_tests COMMAND app_version_tests)
|
||||
|
||||
# The provenance test links bank_model too — it proves the recorded recipe survives
|
||||
# the Sample-JSON round-trip (Provenance.fxChainSnapshot), the M1 seam M10 rides on.
|
||||
add_executable(provenance_tests tests/test_provenance.cpp)
|
||||
target_link_libraries(provenance_tests PRIVATE provenance bank_model)
|
||||
add_test(NAME provenance_tests COMMAND provenance_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -359,6 +378,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/view_tree.cpp
|
||||
src/view.cpp
|
||||
src/track_guid.cpp
|
||||
src/provenance_shell.cpp
|
||||
src/guid_diff.cpp
|
||||
src/lane_keys.cpp
|
||||
src/item_read.cpp
|
||||
@@ -366,7 +386,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/bank_book.cpp
|
||||
src/owned_manifest.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest app_version)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest app_version provenance)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
|
||||
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
|
||||
|
||||
@@ -304,6 +304,13 @@ RemoveResult BankBook::removeSample(const std::string& sampleId,
|
||||
: RemoveResult::RejectedSampleAbsent;
|
||||
}
|
||||
|
||||
bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) {
|
||||
for (auto& b : banks_)
|
||||
if (b.index.query(sampleId) != nullptr)
|
||||
return b.index.updateInPlace(sampleId, updated);
|
||||
return false; // no bank holds the id
|
||||
}
|
||||
|
||||
bool BankBook::hashReferencedElsewhere(const std::string& hash,
|
||||
const std::string& exceptBankId) const {
|
||||
if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash)
|
||||
|
||||
@@ -197,6 +197,16 @@ public:
|
||||
const std::string& fromBankId,
|
||||
RemoveScope scope = RemoveScope::ThisBank);
|
||||
|
||||
// Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture):
|
||||
// finds the bank holding `sampleId` and replaces its entry with `updated`
|
||||
// (order-preserving, no dedup — see BankIndex::updateInPlace). Scans banks in
|
||||
// ordinal order and updates the FIRST holder (a sample id is unique within a
|
||||
// bank; the same id living in two banks via copy would update the earliest, which
|
||||
// is acceptable — re-capture operates on the panel's focused single selection).
|
||||
// Returns false (no mutation) if no bank holds the id or the replacement's path
|
||||
// is absolute. Index-only and non-destructive to the timeline.
|
||||
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
|
||||
|
||||
// Reference-count query backing the confirm-on-last-reference guardrail: does any
|
||||
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
|
||||
//
|
||||
|
||||
@@ -93,6 +93,17 @@ bool BankIndex::remove(const std::string& id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool BankIndex::updateInPlace(const std::string& id, const Sample& updated) {
|
||||
if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds
|
||||
for (auto& s : samples_) {
|
||||
if (s.id == id) {
|
||||
s = updated; // replace in place — position (insertion order) preserved
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const Sample* BankIndex::query(const std::string& id) const {
|
||||
for (const auto& s : samples_)
|
||||
if (s.id == id) return &s;
|
||||
|
||||
@@ -131,6 +131,19 @@ public:
|
||||
// Removes the sample with `id`. Returns true if one was removed.
|
||||
bool remove(const std::string& id);
|
||||
|
||||
// Replaces the sample carrying `id` IN PLACE (preserving its position in
|
||||
// insertion order), with `updated`. Used by M10 re-capture-from-source: a
|
||||
// provenanced sample's file is regenerated and its metadata (relativePath,
|
||||
// contentHash, levels, timestamp, ...) refreshed while its identity (id) and
|
||||
// slot are kept, so the bank panel shows the same tile updated rather than a
|
||||
// reordered new entry. `updated.id` should equal `id` (the caller keeps the id
|
||||
// stable); a differing id is written through as given (the caller's contract).
|
||||
// Does NOT dedup — an in-place refresh of one entry is not a new insert, so the
|
||||
// collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false
|
||||
// (no mutation) if `id` is absent or `updated.relativePath` is absolute
|
||||
// (the relative-paths-only invariant still holds for the replacement).
|
||||
bool updateInPlace(const std::string& id, const Sample& updated);
|
||||
|
||||
// Returns the sample with `id`, or nullptr if absent. The pointer is
|
||||
// invalidated by any mutating call.
|
||||
const Sample* query(const std::string& id) const;
|
||||
|
||||
+323
-10
@@ -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
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#include "provenance.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
// provenance implementation — pure, self-contained (no third-party lib, mirror of
|
||||
// bank_model's hand-rolled encoding discipline).
|
||||
//
|
||||
// ENCODING (the fingerprint string): a length-prefixed, field-ordered format so it
|
||||
// is unambiguous and forge-proof (a value containing the separator cannot shift
|
||||
// the parse). Grammar:
|
||||
//
|
||||
// "rsprov1" -- magic + version tag
|
||||
// then, in fixed order, each field as <len>':'<bytes>
|
||||
//
|
||||
// Every field — including numbers — is emitted as its decimal / %.17g text then
|
||||
// length-prefixed, so the parser never has to guess a field boundary. A trailing
|
||||
// field is the track-GUID count followed by that many length-prefixed GUIDs, then
|
||||
// the folded fxChainIdentity. Numbers use the SAME %.17g the bank model uses so a
|
||||
// double round-trips bit-for-bit. Any deviation (wrong magic, short read, bad
|
||||
// number) -> parseFingerprint returns nullopt.
|
||||
//
|
||||
// The fxChainIdentity fold is itself length-prefixed per entry field, so it is
|
||||
// injection-proof on its own and can be embedded whole as one more length-prefixed
|
||||
// field of the fingerprint.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
bool CaptureRecipe::operator==(const CaptureRecipe& o) const {
|
||||
return scope == o.scope && sourceMode == o.sourceMode &&
|
||||
startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
|
||||
tailMode == o.tailMode && tailMs == o.tailMs &&
|
||||
sampleRate == o.sampleRate && channelCount == o.channelCount &&
|
||||
trackGuids == o.trackGuids && fxChainIdentity == o.fxChainIdentity;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kMagic = "rsprov1";
|
||||
|
||||
// Append one length-prefixed field: <decimal-len> ':' <bytes>
|
||||
void putField(std::string& out, const std::string& field) {
|
||||
out += std::to_string(field.size());
|
||||
out += ':';
|
||||
out += field;
|
||||
}
|
||||
|
||||
std::string dblToStr(double v) {
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", v);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Cursor over the encoded string. All reads are bounds-checked; any short read
|
||||
// fails the whole parse (ok_ latches false).
|
||||
class Cursor {
|
||||
public:
|
||||
explicit Cursor(const std::string& s) : s_(s) {}
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
bool atEnd() const { return pos_ >= s_.size(); }
|
||||
|
||||
// Reads one length-prefixed field into `out`. Fails on a missing ':',
|
||||
// non-numeric length, or a length that runs past the end.
|
||||
bool field(std::string& out) {
|
||||
if (!ok_) return false;
|
||||
std::size_t colon = s_.find(':', pos_);
|
||||
if (colon == std::string::npos) return fail();
|
||||
// Parse the length digits [pos_, colon).
|
||||
std::size_t len = 0;
|
||||
if (colon == pos_) return fail(); // empty length token
|
||||
for (std::size_t i = pos_; i < colon; ++i) {
|
||||
char c = s_[i];
|
||||
if (c < '0' || c > '9') return fail();
|
||||
len = len * 10 + static_cast<std::size_t>(c - '0');
|
||||
}
|
||||
const std::size_t start = colon + 1;
|
||||
if (start + len > s_.size()) return fail();
|
||||
out.assign(s_, start, len);
|
||||
pos_ = start + len;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool fieldInt(int& out) {
|
||||
std::string f;
|
||||
if (!field(f)) return false;
|
||||
return toInt(f, out);
|
||||
}
|
||||
|
||||
bool fieldSizeT(std::size_t& out) {
|
||||
std::string f;
|
||||
if (!field(f)) return false;
|
||||
if (f.empty()) return fail();
|
||||
std::size_t v = 0;
|
||||
for (char c : f) {
|
||||
if (c < '0' || c > '9') return fail();
|
||||
v = v * 10 + static_cast<std::size_t>(c - '0');
|
||||
}
|
||||
out = v;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool fieldDouble(double& out) {
|
||||
std::string f;
|
||||
if (!field(f)) return false;
|
||||
const char* b = f.c_str();
|
||||
char* end = nullptr;
|
||||
double v = std::strtod(b, &end);
|
||||
if (end != b + f.size()) return fail();
|
||||
out = v;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Consumes an exact literal at the cursor (the magic tag). Fails if absent.
|
||||
bool literal(const char* lit) {
|
||||
if (!ok_) return false;
|
||||
const std::string l(lit);
|
||||
if (s_.compare(pos_, l.size(), l) != 0) return fail();
|
||||
pos_ += l.size();
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
bool fail() { ok_ = false; return false; }
|
||||
static bool toInt(const std::string& f, int& out) {
|
||||
const char* b = f.c_str();
|
||||
char* end = nullptr;
|
||||
long v = std::strtol(b, &end, 10);
|
||||
if (end != b + f.size() || f.empty()) return false;
|
||||
out = static_cast<int>(v);
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::string& s_;
|
||||
std::size_t pos_ = 0;
|
||||
bool ok_ = true;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries) {
|
||||
std::string out;
|
||||
// Count first, then each entry's three fields length-prefixed. Order is part of
|
||||
// identity (chain order matters), so we emit in the given vector order.
|
||||
putField(out, std::to_string(entries.size()));
|
||||
for (const FxIdentityEntry& e : entries) {
|
||||
putField(out, e.name);
|
||||
putField(out, e.guid);
|
||||
putField(out, e.enabled ? "1" : "0");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string combineChainIdentities(const std::vector<std::string>& perTrack) {
|
||||
std::string out;
|
||||
putField(out, std::to_string(perTrack.size()));
|
||||
for (const std::string& id : perTrack) putField(out, id);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string buildFingerprint(const CaptureRecipe& r) {
|
||||
std::string out(kMagic);
|
||||
putField(out, std::to_string(static_cast<int>(r.scope)));
|
||||
putField(out, std::to_string(r.sourceMode));
|
||||
putField(out, dblToStr(r.startSeconds));
|
||||
putField(out, dblToStr(r.endSeconds));
|
||||
putField(out, std::to_string(r.tailMode));
|
||||
putField(out, dblToStr(r.tailMs));
|
||||
putField(out, std::to_string(r.sampleRate));
|
||||
putField(out, std::to_string(r.channelCount));
|
||||
putField(out, std::to_string(r.trackGuids.size()));
|
||||
for (const std::string& g : r.trackGuids) putField(out, g);
|
||||
putField(out, r.fxChainIdentity);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
|
||||
Cursor c(fingerprint);
|
||||
if (!c.literal(kMagic)) return std::nullopt;
|
||||
|
||||
CaptureRecipe r;
|
||||
int scopeInt = 0;
|
||||
if (!c.fieldInt(scopeInt)) return std::nullopt;
|
||||
if (scopeInt != static_cast<int>(ProvenanceScope::Item) &&
|
||||
scopeInt != static_cast<int>(ProvenanceScope::Track))
|
||||
return std::nullopt;
|
||||
r.scope = static_cast<ProvenanceScope>(scopeInt);
|
||||
|
||||
if (!c.fieldInt(r.sourceMode)) return std::nullopt;
|
||||
if (!c.fieldDouble(r.startSeconds)) return std::nullopt;
|
||||
if (!c.fieldDouble(r.endSeconds)) return std::nullopt;
|
||||
if (!c.fieldInt(r.tailMode)) return std::nullopt;
|
||||
if (!c.fieldDouble(r.tailMs)) return std::nullopt;
|
||||
if (!c.fieldInt(r.sampleRate)) return std::nullopt;
|
||||
if (!c.fieldInt(r.channelCount)) return std::nullopt;
|
||||
|
||||
std::size_t guidCount = 0;
|
||||
if (!c.fieldSizeT(guidCount)) return std::nullopt;
|
||||
r.trackGuids.reserve(guidCount);
|
||||
for (std::size_t i = 0; i < guidCount; ++i) {
|
||||
std::string g;
|
||||
if (!c.field(g)) return std::nullopt;
|
||||
r.trackGuids.push_back(std::move(g));
|
||||
}
|
||||
|
||||
if (!c.field(r.fxChainIdentity)) return std::nullopt;
|
||||
|
||||
// Trailing garbage means the string was not produced by our writer -> reject,
|
||||
// so a corrupt/extended blob never silently drives a partial re-capture.
|
||||
if (!c.ok() || !c.atEnd()) return std::nullopt;
|
||||
return r;
|
||||
}
|
||||
|
||||
std::optional<std::string> detectParent(
|
||||
const std::vector<std::string>& sourceItemFiles,
|
||||
const std::vector<BankFileRef>& bankFiles) {
|
||||
if (sourceItemFiles.empty()) return std::nullopt;
|
||||
|
||||
std::optional<std::string> parent; // the single bank sample all sources point at
|
||||
for (const std::string& src : sourceItemFiles) {
|
||||
// Resolve this source file against the bank by exact normalized path.
|
||||
const std::string* matchedId = nullptr;
|
||||
for (const BankFileRef& ref : bankFiles) {
|
||||
if (!ref.absolutePath.empty() && ref.absolutePath == src) {
|
||||
matchedId = &ref.sampleId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchedId == nullptr)
|
||||
return std::nullopt; // a source item is NOT a bank file -> not a resample
|
||||
|
||||
if (!parent) {
|
||||
parent = *matchedId;
|
||||
} else if (*parent != *matchedId) {
|
||||
return std::nullopt; // sources span >1 bank sample -> ambiguous, no parent
|
||||
}
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -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
|
||||
@@ -0,0 +1,143 @@
|
||||
// provenance_shell.cpp — the REAPER reads behind Milestone 10. See provenance_shell.h.
|
||||
//
|
||||
// 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
|
||||
// (CLAUDE.md §contract). Every REAPER symbol used here is verified against
|
||||
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||
// * TrackFX_GetCount(MediaTrack*) (~7283)
|
||||
// * TrackFX_GetFXName(MediaTrack*, int, char*, int) -> bool (~7356)
|
||||
// * TrackFX_GetFXGUID(MediaTrack*, int) -> GUID* (~7348)
|
||||
// * TrackFX_GetEnabled(MediaTrack*, int) -> bool (~7291)
|
||||
// * CountSelectedMediaItems / GetSelectedMediaItem (selection reads)
|
||||
// * GetActiveTake(MediaItem*) -> MediaItem_Take* (active take)
|
||||
// * GetMediaItemTake_Source(MediaItem_Take*) -> PCM_source* (~2053)
|
||||
// * GetMediaSourceFileName(PCM_source*, char*, int) (~2141)
|
||||
// * CountTracks / GetTrack (track scan)
|
||||
// * guidToString (via track_guid)
|
||||
|
||||
#include "provenance_shell.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "bank_book.h" // BankBook, Bank, BankIndex::all
|
||||
#include "capture_paths.h" // resolveBankFile, normalizeSlashes
|
||||
#include "track_guid.h" // guidString — the ONE canonical GUID key formatter
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||
#define REAPERAPI_WANT_TrackFX_GetFXName
|
||||
#define REAPERAPI_WANT_TrackFX_GetFXGUID
|
||||
#define REAPERAPI_WANT_TrackFX_GetEnabled
|
||||
#define REAPERAPI_WANT_CountSelectedMediaItems
|
||||
#define REAPERAPI_WANT_GetSelectedMediaItem
|
||||
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
#define REAPERAPI_WANT_GetActiveTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string fxChainIdentityForTrack(MediaTrack* tr) {
|
||||
if (!tr) return fxChainIdentity({});
|
||||
std::vector<FxIdentityEntry> rows;
|
||||
const int n = TrackFX_GetCount(tr);
|
||||
rows.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
FxIdentityEntry e;
|
||||
char nameBuf[512] = {0};
|
||||
if (TrackFX_GetFXName(tr, i, nameBuf, static_cast<int>(sizeof(nameBuf))))
|
||||
e.name = nameBuf;
|
||||
// Per-instance GUID: the stable identity of THIS FX in the chain, so swapping
|
||||
// one FX for another of the same name registers as drift. guidToString needs a
|
||||
// >=64-char destination (SDK contract).
|
||||
if (GUID* g = TrackFX_GetFXGUID(tr, i)) {
|
||||
char gb[64] = {0};
|
||||
guidToString(g, gb);
|
||||
e.guid = gb;
|
||||
}
|
||||
e.enabled = TrackFX_GetEnabled(tr, i);
|
||||
rows.push_back(std::move(e));
|
||||
}
|
||||
return fxChainIdentity(rows);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The active take source file of one item, normalized. Empty if unresolvable.
|
||||
std::string itemSourceFile(MediaItem* it) {
|
||||
if (!it) return {};
|
||||
MediaItem_Take* take = GetActiveTake(it);
|
||||
if (!take) return {}; // empty (MIDI-less?) / no active take -> unresolvable
|
||||
PCM_source* src = GetMediaItemTake_Source(take);
|
||||
if (!src) return {};
|
||||
char buf[4096] = {0};
|
||||
GetMediaSourceFileName(src, buf, static_cast<int>(sizeof(buf)));
|
||||
return normalizeSlashes(std::string(buf));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> selectedItemSourceFiles() {
|
||||
std::vector<std::string> files;
|
||||
const int n = CountSelectedMediaItems(nullptr); // nullptr = active project
|
||||
for (int i = 0; i < n; ++i) {
|
||||
std::string f = itemSourceFile(GetSelectedMediaItem(nullptr, i));
|
||||
if (!f.empty()) files.push_back(std::move(f)); // omit unresolvable (never empty)
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
std::vector<std::string> trackItemSourceFiles(const std::vector<MediaTrack*>& tracks,
|
||||
double startSeconds, double endSeconds) {
|
||||
std::vector<std::string> files;
|
||||
for (MediaTrack* tr : tracks) {
|
||||
if (!tr) continue;
|
||||
const int n = CountTrackMediaItems(tr);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaItem* it = GetTrackMediaItem(tr, i);
|
||||
if (!it) continue;
|
||||
const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
|
||||
const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
|
||||
// Positive overlap with the capture range (a zero-length touch is not an
|
||||
// overlap): item [pos, pos+len) intersects [startSeconds, endSeconds).
|
||||
if (pos < endSeconds && (pos + len) > startSeconds) {
|
||||
std::string f = itemSourceFile(it);
|
||||
if (!f.empty()) files.push_back(std::move(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
std::vector<BankFileRef> bankFileRefs(const BankBook& book, const std::string& projectDir) {
|
||||
std::vector<BankFileRef> refs;
|
||||
for (const Bank& b : book.banks()) {
|
||||
for (const Sample& s : b.index.all()) {
|
||||
BankFileRef ref;
|
||||
ref.sampleId = s.id;
|
||||
// Resolve to the same normalized absolute form selectedItemSourceFiles
|
||||
// produces, so detectParent compares like-for-like. Empty projectDir /
|
||||
// relativePath -> empty absolutePath (never a false match).
|
||||
ref.absolutePath = normalizeSlashes(resolveBankFile(projectDir, s.relativePath));
|
||||
refs.push_back(std::move(ref));
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
MediaTrack* trackByGuid(const std::string& guid) {
|
||||
if (guid.empty()) return nullptr;
|
||||
const int n = CountTracks(nullptr); // nullptr = active project; excludes master
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaTrack* tr = GetTrack(nullptr, i);
|
||||
if (!tr) continue;
|
||||
if (guidString(tr) == guid) return tr;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
// provenance_shell — the REAPER-facing reads Milestone 10 needs, in one place.
|
||||
//
|
||||
// The PURE provenance module (provenance.h) owns the fingerprint encoding, the
|
||||
// recipe model, the FX-identity fold, and the parent-detection DECISION — all over
|
||||
// plain strings/values. This shell gathers those strings/values FROM REAPER:
|
||||
// * the in-scope FX-chain identity of a source track (name/GUID/enabled rows),
|
||||
// * the media-file paths of a resolved capture's source items,
|
||||
// * the active book's bank samples resolved to absolute file paths,
|
||||
// * a canonical track-GUID string back to a live MediaTrack*.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
|
||||
// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays
|
||||
// SDK-lite. It depends on the pure provenance module (FxIdentityEntry / recipe /
|
||||
// BankFileRef) and bank_book (to enumerate the active book's samples).
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "provenance.h"
|
||||
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class BankBook;
|
||||
|
||||
// The in-scope FX-chain identity of a source track, folded to the pure
|
||||
// provenance string. For TRACK scope this is the track's own FX chain; for ITEM
|
||||
// scope the take/item FX are the in-scope chain — but item/take FX are not
|
||||
// enumerable via the TrackFX_* family, so an item-scope capture folds an EMPTY
|
||||
// chain identity (the drift signal then keys on scope + range only, which is
|
||||
// honest: we do not claim to fingerprint take FX we cannot read). TRACK scope reads
|
||||
// TrackFX_GetCount / GetFXName / GetFXGUID / GetEnabled in chain order.
|
||||
std::string fxChainIdentityForTrack(MediaTrack* tr);
|
||||
|
||||
// Reads the media-file path of every SELECTED media item's active take source
|
||||
// (GetMediaItemTake_Source -> GetMediaSourceFileName), normalized to forward-slash.
|
||||
// Unresolvable items (no take / no source / empty name) are omitted — never an
|
||||
// empty string in the result, so detectParent's "not in bank" branch is honest.
|
||||
// The active-project selection is read directly (mirrors main.cpp's collectors).
|
||||
// This is the ITEM-scope source set (the user selected the items being resampled).
|
||||
std::vector<std::string> selectedItemSourceFiles();
|
||||
|
||||
// The TRACK-scope source set: the media-file paths of the items ON `tracks` that
|
||||
// OVERLAP the capture range [startSeconds, endSeconds). For a track capture the user
|
||||
// selects the track, not the item, so the "what audio is being captured" set is the
|
||||
// range-overlapping items on the source tracks. Same normalize + omit-unresolvable
|
||||
// contract as selectedItemSourceFiles. An item overlaps iff its [pos, pos+len)
|
||||
// intersects the range with positive overlap (a zero-length touch does not count).
|
||||
std::vector<std::string> trackItemSourceFiles(const std::vector<MediaTrack*>& tracks,
|
||||
double startSeconds, double endSeconds);
|
||||
|
||||
// Enumerates the ACTIVE book's samples across every bank (pool + named) as pure
|
||||
// BankFileRefs — each sample id paired with its file resolved to a normalized
|
||||
// ABSOLUTE path against `projectDir` (resolveBankFile + normalizeSlashes). A sample
|
||||
// whose path cannot be resolved (empty projectDir / empty relativePath) is emitted
|
||||
// with an empty absolutePath, which detectParent never matches. `projectDir` is the
|
||||
// current .rpp parent (the shell resolves it; empty -> all refs unresolved).
|
||||
std::vector<BankFileRef> bankFileRefs(const BankBook& book, const std::string& projectDir);
|
||||
|
||||
// Resolves a canonical track-GUID string (guidString form) to a live MediaTrack*
|
||||
// in the active project by scanning tracks and comparing guidString(tr). Returns
|
||||
// nullptr when no live track carries that GUID (the source track was deleted since
|
||||
// capture — a re-capture failure mode the caller reports). The master track is not
|
||||
// scanned (it has no membership GUID and is never a capture source).
|
||||
MediaTrack* trackByGuid(const std::string& guid);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -756,6 +756,35 @@ static void testRemoveAllBanksLatentScope() {
|
||||
RemoveResult::RejectedSampleAbsent);
|
||||
}
|
||||
|
||||
// M10: updateSampleInPlace refreshes a sample wherever it lives, order-preserving,
|
||||
// no dedup, and reports no-op honestly for an absent id.
|
||||
static void testUpdateSampleInPlace() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums"));
|
||||
CHECK(book.pool().index.add(sampleWith("kick")) == AddResult::Added);
|
||||
CHECK(book.bank("drums")->index.add(sampleWith("snare")) == AddResult::Added);
|
||||
CHECK(book.bank("drums")->index.add(sampleWith("hat")) == AddResult::Added);
|
||||
|
||||
// Refresh the middle entry of the NAMED bank: new path/hash, same id + slot.
|
||||
Sample updated = sampleWith("snare");
|
||||
updated.relativePath = "bank/snare-recaptured.wav";
|
||||
updated.contentHash = "recaptured-hash";
|
||||
CHECK(book.updateSampleInPlace("id-snare", updated));
|
||||
CHECK(book.bank("drums")->index.all()[0].id == "id-snare"); // slot preserved
|
||||
CHECK(book.bank("drums")->index.query("id-snare")->relativePath ==
|
||||
"bank/snare-recaptured.wav");
|
||||
CHECK(book.bank("drums")->index.size() == 2); // no new entry
|
||||
|
||||
// A sample in the POOL is found and updated too.
|
||||
Sample pk = sampleWith("kick");
|
||||
pk.contentHash = "kick-recaptured";
|
||||
CHECK(book.updateSampleInPlace("id-kick", pk));
|
||||
CHECK(book.pool().index.query("id-kick")->contentHash == "kick-recaptured");
|
||||
|
||||
// An absent id is an honest no-op.
|
||||
CHECK(!book.updateSampleInPlace("id-nope", sampleWith("nope")));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPoolSeededAndDefaults();
|
||||
testPoolPrivileges();
|
||||
@@ -791,6 +820,7 @@ int main() {
|
||||
testRemoveRejectionsNoMutation();
|
||||
testHashReferencedElsewhere();
|
||||
testRemoveAllBanksLatentScope();
|
||||
testUpdateSampleInPlace();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
|
||||
@@ -164,6 +164,46 @@ static void testRelativePathInvariant() {
|
||||
CHECK(idx.add(noId) == AddResult::RejectedEmptyId);
|
||||
}
|
||||
|
||||
// M10: updateInPlace refreshes an entry while preserving its slot + identity, and
|
||||
// bypasses dedup (an in-place refresh is not a new insert). The relative-paths-only
|
||||
// invariant still guards the replacement.
|
||||
static void testUpdateInPlace() {
|
||||
BankIndex idx;
|
||||
CHECK(idx.add(minimalSample("a")) == AddResult::Added); // id "min-a"
|
||||
CHECK(idx.add(minimalSample("b")) == AddResult::Added); // id "min-b"
|
||||
CHECK(idx.add(minimalSample("c")) == AddResult::Added); // id "min-c"
|
||||
|
||||
// Refresh the MIDDLE entry: new file/hash/name, same id — position must be kept.
|
||||
Sample updated = minimalSample("b");
|
||||
updated.relativePath = "reasampler_bank/regenerated.wav";
|
||||
updated.contentHash = "new-hash-b";
|
||||
updated.displayName = "regenerated";
|
||||
CHECK(idx.updateInPlace("min-b", updated));
|
||||
|
||||
CHECK(idx.size() == 3); // no new entry, no removal
|
||||
CHECK(idx.all()[1].id == "min-b"); // slot preserved (still middle)
|
||||
CHECK(idx.all()[1].relativePath == "reasampler_bank/regenerated.wav");
|
||||
CHECK(idx.all()[1].contentHash == "new-hash-b");
|
||||
CHECK(idx.query("min-b")->displayName == "regenerated");
|
||||
|
||||
// An updated hash colliding with ANOTHER entry does NOT collapse (updateInPlace
|
||||
// is not an insert): the refreshed entry keeps its slot even sharing a hash.
|
||||
Sample collide = minimalSample("b");
|
||||
collide.contentHash = idx.query("min-a")->contentHash; // same as entry "min-a"
|
||||
CHECK(idx.updateInPlace("min-b", collide));
|
||||
CHECK(idx.size() == 3); // still three; no collapse
|
||||
|
||||
// Updating an absent id fails without mutation.
|
||||
CHECK(!idx.updateInPlace("nope", minimalSample("x")));
|
||||
CHECK(idx.size() == 3);
|
||||
|
||||
// An absolute replacement path is rejected (invariant preserved).
|
||||
Sample bad = minimalSample("b");
|
||||
bad.relativePath = "C:/evil.wav";
|
||||
CHECK(!idx.updateInPlace("min-b", bad));
|
||||
CHECK(idx.query("min-b")->relativePath != "C:/evil.wav");
|
||||
}
|
||||
|
||||
static void testEmptyIndexRoundTrip() {
|
||||
BankIndex idx;
|
||||
CHECK(idx.empty());
|
||||
@@ -375,6 +415,7 @@ int main() {
|
||||
testDedupByHash();
|
||||
testTierFilterAndMove();
|
||||
testRelativePathInvariant();
|
||||
testUpdateInPlace();
|
||||
testEmptyIndexRoundTrip();
|
||||
testMalformedJson();
|
||||
testRemoveAndQuery();
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// Standalone tests for the pure provenance core (M10) — no REAPER, no framework.
|
||||
//
|
||||
// Covers (brief-named test categories):
|
||||
// * fingerprint build -> encode -> parse round-trip (lossless).
|
||||
// * identical inputs -> equal fingerprints (byte-identical string).
|
||||
// * any single component change (scope, sourceMode, range, tail, rate, channels,
|
||||
// track GUIDs, FX-chain identity) -> a MISMATCH (different string / recipe).
|
||||
// * fxChainIdentity fold: order-sensitive, field-injection-proof, empty-stable.
|
||||
// * parse of malformed / wrong-version / truncated input -> nullopt (graceful).
|
||||
// * parent-detection decision: positive, negative, ambiguous, empty, and the
|
||||
// edge where a source file is not in the bank (missing-from-bank).
|
||||
//
|
||||
// The Sample-JSON round-trip of the fingerprint (leveraging M1's existing provenance
|
||||
// round-trip) is exercised in test_bank_model.cpp — see the fingerprint case there.
|
||||
|
||||
#include "../src/provenance.h"
|
||||
|
||||
#include "../src/bank_model.h" // recipe-through-Sample-JSON round-trip (M1 seam)
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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)
|
||||
|
||||
// A representative, fully-populated recipe used as the baseline for mutation tests.
|
||||
static CaptureRecipe baseRecipe() {
|
||||
CaptureRecipe r;
|
||||
r.scope = ProvenanceScope::Track;
|
||||
r.sourceMode = 1; // SelectedTracks
|
||||
r.startSeconds = 12.3456789012345; // non-trivial doubles to exercise %.17g
|
||||
r.endSeconds = 45.6789012345678;
|
||||
r.tailMode = 2; // Manual
|
||||
r.tailMs = 1234.5;
|
||||
r.sampleRate = 48000;
|
||||
r.channelCount = 2;
|
||||
r.trackGuids = {"{11111111-1111-1111-1111-111111111111}",
|
||||
"{22222222-2222-2222-2222-222222222222}"};
|
||||
r.fxChainIdentity = fxChainIdentity({
|
||||
{"ReaEQ", "{AAAA-1}", true},
|
||||
{"ReaComp", "{BBBB-2}", false},
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
// --- fingerprint round-trip --------------------------------------------------
|
||||
|
||||
static void testFingerprintRoundTrip() {
|
||||
const CaptureRecipe r = baseRecipe();
|
||||
const std::string fp = buildFingerprint(r);
|
||||
auto back = parseFingerprint(fp);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == r);
|
||||
// Re-encode is byte-stable.
|
||||
CHECK(buildFingerprint(*back) == fp);
|
||||
}
|
||||
|
||||
// A recipe with empty GUID list + empty FX identity (a no-FX, no-track-guid capture)
|
||||
// still round-trips — the degenerate case must not corrupt the parse.
|
||||
static void testFingerprintRoundTripEmptyFields() {
|
||||
CaptureRecipe r;
|
||||
r.scope = ProvenanceScope::Item;
|
||||
r.trackGuids.clear();
|
||||
r.fxChainIdentity = fxChainIdentity({});
|
||||
const std::string fp = buildFingerprint(r);
|
||||
auto back = parseFingerprint(fp);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == r);
|
||||
CHECK(back->trackGuids.empty());
|
||||
}
|
||||
|
||||
// A GUID or FX-name carrying the field separators (':' and digits) must survive —
|
||||
// length-prefixing makes the encoding injection-proof.
|
||||
static void testFingerprintRoundTripHostileStrings() {
|
||||
CaptureRecipe r = baseRecipe();
|
||||
r.trackGuids = {"7:not-a-real-guid", "12:another:evil:one"};
|
||||
r.fxChainIdentity = fxChainIdentity({
|
||||
{"FX with 3:colons: and stuff", "{gu:id}", true},
|
||||
});
|
||||
auto back = parseFingerprint(buildFingerprint(r));
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == r);
|
||||
}
|
||||
|
||||
// --- identical inputs -> equal fingerprints ----------------------------------
|
||||
|
||||
static void testIdenticalInputsEqualFingerprints() {
|
||||
CHECK(buildFingerprint(baseRecipe()) == buildFingerprint(baseRecipe()));
|
||||
CHECK(baseRecipe() == baseRecipe());
|
||||
}
|
||||
|
||||
// --- any single component change -> mismatch ---------------------------------
|
||||
|
||||
static void testSingleComponentChangesMismatch() {
|
||||
const std::string base = buildFingerprint(baseRecipe());
|
||||
|
||||
{ auto r = baseRecipe(); r.scope = ProvenanceScope::Item;
|
||||
CHECK(buildFingerprint(r) != base); CHECK(r != baseRecipe()); }
|
||||
{ auto r = baseRecipe(); r.sourceMode = 3;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.startSeconds += 0.0000001;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.endSeconds += 0.0000001;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.tailMode = 0;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.tailMs += 1.0;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.sampleRate = 44100;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.channelCount = 1;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.trackGuids.pop_back();
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.trackGuids[0] = "{99999999-9999-9999-9999-999999999999}";
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
// The drift component: a changed FX chain identity mismatches (this is exactly
|
||||
// the "source changed since capture" signal re-capture reports).
|
||||
{ auto r = baseRecipe();
|
||||
r.fxChainIdentity = fxChainIdentity({{"ReaEQ", "{AAAA-1}", true}});
|
||||
CHECK(buildFingerprint(r) != base); CHECK(r != baseRecipe()); }
|
||||
}
|
||||
|
||||
// --- fxChainIdentity fold ----------------------------------------------------
|
||||
|
||||
static void testFxChainIdentityOrderSensitive() {
|
||||
const std::string a = fxChainIdentity({
|
||||
{"ReaEQ", "{A}", true}, {"ReaComp", "{B}", true}});
|
||||
const std::string b = fxChainIdentity({
|
||||
{"ReaComp", "{B}", true}, {"ReaEQ", "{A}", true}});
|
||||
CHECK(a != b); // chain order is part of identity
|
||||
}
|
||||
|
||||
static void testFxChainIdentityFieldsMatter() {
|
||||
const std::string base = fxChainIdentity({{"ReaEQ", "{A}", true}});
|
||||
CHECK(fxChainIdentity({{"ReaEQ2", "{A}", true}}) != base); // name
|
||||
CHECK(fxChainIdentity({{"ReaEQ", "{B}", true}}) != base); // guid (instance)
|
||||
CHECK(fxChainIdentity({{"ReaEQ", "{A}", false}}) != base); // enabled flag
|
||||
}
|
||||
|
||||
static void testFxChainIdentityEmptyStable() {
|
||||
CHECK(fxChainIdentity({}) == fxChainIdentity({}));
|
||||
// Empty chain differs from a one-FX chain.
|
||||
CHECK(fxChainIdentity({}) != fxChainIdentity({{"X", "{Y}", true}}));
|
||||
}
|
||||
|
||||
// Concatenation cannot forge equality: {"AB",""} vs {"A","B"} must differ despite
|
||||
// sharing raw bytes — length-prefixing keeps boundaries honest.
|
||||
static void testFxChainIdentityInjectionProof() {
|
||||
const std::string x = fxChainIdentity({{"AB", "", true}});
|
||||
const std::string y = fxChainIdentity({{"A", "B", true}});
|
||||
CHECK(x != y);
|
||||
}
|
||||
|
||||
// --- combineChainIdentities (multi-track Track-scope fold) -------------------
|
||||
|
||||
static void testCombineChainIdentities() {
|
||||
const std::string idA = fxChainIdentity({{"ReaEQ", "{A}", true}});
|
||||
const std::string idB = fxChainIdentity({{"ReaComp", "{B}", true}});
|
||||
|
||||
// Order of tracks matters, and distinct partitions cannot collide by concatenation.
|
||||
CHECK(combineChainIdentities({idA, idB}) != combineChainIdentities({idB, idA}));
|
||||
CHECK(combineChainIdentities({idA, ""}) != combineChainIdentities({"", idA}));
|
||||
// Empty vs single-track vs two-track are all distinct.
|
||||
CHECK(combineChainIdentities({}) != combineChainIdentities({idA}));
|
||||
CHECK(combineChainIdentities({idA}) != combineChainIdentities({idA, idB}));
|
||||
// Deterministic.
|
||||
CHECK(combineChainIdentities({idA, idB}) == combineChainIdentities({idA, idB}));
|
||||
}
|
||||
|
||||
// --- malformed parse ---------------------------------------------------------
|
||||
|
||||
static void testMalformedFingerprint() {
|
||||
CHECK(!parseFingerprint("").has_value()); // empty
|
||||
CHECK(!parseFingerprint("garbage").has_value()); // wrong magic
|
||||
CHECK(!parseFingerprint("rsprov0...").has_value()); // wrong version tag
|
||||
// Right magic, truncated body (no fields).
|
||||
CHECK(!parseFingerprint("rsprov1").has_value());
|
||||
// A length prefix that runs past the end.
|
||||
CHECK(!parseFingerprint("rsprov199:short").has_value());
|
||||
// A valid fingerprint with trailing garbage appended is rejected.
|
||||
const std::string good = buildFingerprint(baseRecipe());
|
||||
CHECK(!parseFingerprint(good + "TRAILING").has_value());
|
||||
// An out-of-range scope value is rejected.
|
||||
CHECK(!parseFingerprint("rsprov11:9" "1:0" "1:0" "1:0" "1:0" "1:0" "1:0" "1:0"
|
||||
"1:0" "0:").has_value());
|
||||
}
|
||||
|
||||
// --- recorded-recipe model round-trips through the Sample JSON ----------------
|
||||
// The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's
|
||||
// BankIndex JSON already round-trips. Prove a real recipe survives that path intact.
|
||||
|
||||
static void testRecipeThroughSampleJson() {
|
||||
const CaptureRecipe r = baseRecipe();
|
||||
|
||||
Sample s;
|
||||
s.id = "child-1";
|
||||
s.relativePath = "reasampler_bank/child.wav";
|
||||
s.contentHash = "hash-child";
|
||||
Provenance prov;
|
||||
prov.parentSampleId = "sample-A";
|
||||
prov.fxChainSnapshot = buildFingerprint(r);
|
||||
s.provenance = prov;
|
||||
|
||||
BankIndex idx;
|
||||
CHECK(idx.add(s) == AddResult::Added);
|
||||
|
||||
auto back = BankIndex::deserialize(idx.serialize());
|
||||
CHECK(back.has_value());
|
||||
const Sample* child = back ? back->query("child-1") : nullptr;
|
||||
CHECK(child != nullptr);
|
||||
CHECK(child && child->provenance.has_value());
|
||||
CHECK(child && child->provenance->parentSampleId == "sample-A");
|
||||
|
||||
// The fingerprint string survived byte-for-byte AND re-parses to the recipe.
|
||||
if (child && child->provenance) {
|
||||
auto recovered = parseFingerprint(child->provenance->fxChainSnapshot);
|
||||
CHECK(recovered.has_value());
|
||||
CHECK(recovered && *recovered == r);
|
||||
}
|
||||
}
|
||||
|
||||
// --- parent detection --------------------------------------------------------
|
||||
|
||||
static std::vector<BankFileRef> bank() {
|
||||
return {
|
||||
{"sample-A", "c:/proj/reasampler_bank/a.wav"},
|
||||
{"sample-B", "c:/proj/reasampler_bank/b.wav"},
|
||||
};
|
||||
}
|
||||
|
||||
static void testDetectParentPositive() {
|
||||
// A single source item resolving to a bank file -> that sample is the parent.
|
||||
auto p = detectParent({"c:/proj/reasampler_bank/a.wav"}, bank());
|
||||
CHECK(p.has_value());
|
||||
CHECK(*p == "sample-A");
|
||||
}
|
||||
|
||||
static void testDetectParentMultipleSameParent() {
|
||||
// Two source items both from the SAME bank sample -> still that parent (a track
|
||||
// capture whose items all came from one bank file).
|
||||
auto p = detectParent(
|
||||
{"c:/proj/reasampler_bank/b.wav", "c:/proj/reasampler_bank/b.wav"}, bank());
|
||||
CHECK(p.has_value());
|
||||
CHECK(*p == "sample-B");
|
||||
}
|
||||
|
||||
static void testDetectParentNegativeNotInBank() {
|
||||
// A source file that is not a bank file -> no parent (a fresh, non-resample capture).
|
||||
auto p = detectParent({"c:/proj/audio/live-recording.wav"}, bank());
|
||||
CHECK(!p.has_value());
|
||||
}
|
||||
|
||||
static void testDetectParentAmbiguous() {
|
||||
// Sources spanning two DIFFERENT bank samples -> ambiguous, record no parent
|
||||
// (honest: we will not guess which one is "the" parent).
|
||||
auto p = detectParent(
|
||||
{"c:/proj/reasampler_bank/a.wav", "c:/proj/reasampler_bank/b.wav"}, bank());
|
||||
CHECK(!p.has_value());
|
||||
}
|
||||
|
||||
static void testDetectParentMixedBankAndNonBank() {
|
||||
// One source is a bank file, another is not -> not a clean resample -> no parent.
|
||||
auto p = detectParent(
|
||||
{"c:/proj/reasampler_bank/a.wav", "c:/proj/audio/other.wav"}, bank());
|
||||
CHECK(!p.has_value());
|
||||
}
|
||||
|
||||
static void testDetectParentEmptySources() {
|
||||
CHECK(!detectParent({}, bank()).has_value());
|
||||
}
|
||||
|
||||
static void testDetectParentEmptyBank() {
|
||||
// Edge: the bank has no files (e.g. the sample's file record is missing / bank
|
||||
// empty) -> nothing matches -> no parent.
|
||||
CHECK(!detectParent({"c:/proj/reasampler_bank/a.wav"}, {}).has_value());
|
||||
// A bank ref with an empty path never matches (guards against a null resolve).
|
||||
std::vector<BankFileRef> holey = {{"sample-X", ""}};
|
||||
CHECK(!detectParent({""}, holey).has_value());
|
||||
CHECK(!detectParent({"c:/proj/reasampler_bank/a.wav"}, holey).has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFingerprintRoundTrip();
|
||||
testFingerprintRoundTripEmptyFields();
|
||||
testFingerprintRoundTripHostileStrings();
|
||||
testIdenticalInputsEqualFingerprints();
|
||||
testSingleComponentChangesMismatch();
|
||||
testFxChainIdentityOrderSensitive();
|
||||
testFxChainIdentityFieldsMatter();
|
||||
testFxChainIdentityEmptyStable();
|
||||
testFxChainIdentityInjectionProof();
|
||||
testCombineChainIdentities();
|
||||
testMalformedFingerprint();
|
||||
testRecipeThroughSampleJson();
|
||||
testDetectParentPositive();
|
||||
testDetectParentMultipleSameParent();
|
||||
testDetectParentNegativeNotInBank();
|
||||
testDetectParentAmbiguous();
|
||||
testDetectParentMixedBankAndNonBank();
|
||||
testDetectParentEmptySources();
|
||||
testDetectParentEmptyBank();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user