Merge m10-provenance: provenance + re-capture from source (bank-only)

This commit is contained in:
2026-07-26 18:11:54 -04:00
16 changed files with 1514 additions and 18 deletions
+21 -1
View File
@@ -253,6 +253,19 @@ target_link_libraries(wav_trim PUBLIC peaks)
add_library(app_version STATIC src/app_version.cpp) add_library(app_version STATIC src/app_version.cpp)
target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) 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). # 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) target_link_libraries(app_version_tests PRIVATE app_version)
add_test(NAME app_version_tests COMMAND app_version_tests) 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). # 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_tree.cpp
src/view.cpp src/view.cpp
src/track_guid.cpp src/track_guid.cpp
src/provenance_shell.cpp
src/guid_diff.cpp src/guid_diff.cpp
src/lane_keys.cpp src/lane_keys.cpp
src/item_read.cpp src/item_read.cpp
@@ -366,7 +386,7 @@ add_library(reaper_reasampler MODULE
src/bank_book.cpp src/bank_book.cpp
src/owned_manifest.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}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # 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' # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
+7
View File
@@ -304,6 +304,13 @@ RemoveResult BankBook::removeSample(const std::string& sampleId,
: RemoveResult::RejectedSampleAbsent; : 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, bool BankBook::hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const { const std::string& exceptBankId) const {
if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash) if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash)
+10
View File
@@ -197,6 +197,16 @@ public:
const std::string& fromBankId, const std::string& fromBankId,
RemoveScope scope = RemoveScope::ThisBank); 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 // Reference-count query backing the confirm-on-last-reference guardrail: does any
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`? // bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
// //
+11
View File
@@ -93,6 +93,17 @@ bool BankIndex::remove(const std::string& id) {
return false; 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 { const Sample* BankIndex::query(const std::string& id) const {
for (const auto& s : samples_) for (const auto& s : samples_)
if (s.id == id) return &s; if (s.id == id) return &s;
+16 -2
View File
@@ -44,8 +44,9 @@ struct SourceRange {
}; };
// Present only when a sample was resampled FROM another sample. Carries the // Present only when a sample was resampled FROM another sample. Carries the
// parent's id and the FX-chain snapshot string captured at resample time, so the // parent's id and the FX-chain snapshot string (a thin drift fingerprint, NOT a
// null-test / re-capture-from-source action (M10) can reconstruct the chain. // restorable chunk) captured at resample time; the re-capture-from-source action
// (M10) uses it to detect chain drift and replay the original capture request.
struct Provenance { struct Provenance {
std::string parentSampleId; std::string parentSampleId;
std::string fxChainSnapshot; std::string fxChainSnapshot;
@@ -131,6 +132,19 @@ public:
// Removes the sample with `id`. Returns true if one was removed. // Removes the sample with `id`. Returns true if one was removed.
bool remove(const std::string& id); 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 // Returns the sample with `id`, or nullptr if absent. The pointer is
// invalidated by any mutating call. // invalidated by any mutating call.
const Sample* query(const std::string& id) const; const Sample* query(const std::string& id) const;
+8
View File
@@ -1,6 +1,7 @@
#include "capture_paths.h" #include "capture_paths.h"
#include <cassert> #include <cassert>
#include <cctype>
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstring> // std::memcmp #include <cstring> // std::memcmp
@@ -124,6 +125,13 @@ std::string normalizeSlashes(const std::string& path) {
if (out.size() > 1 && out.back() == '/') { if (out.size() > 1 && out.back() == '/') {
out.pop_back(); out.pop_back();
} }
#ifdef _WIN32
// Windows paths are case-insensitive. Fold to lowercase so that two paths
// that differ only in drive-letter or component casing compare equal (e.g.
// "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is
// preserved (the filesystem is case-sensitive; folding would be wrong).
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
#endif
return out; return out;
} }
+4
View File
@@ -68,6 +68,10 @@ std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
// Normalizes a path to forward slashes and strips any trailing slash. Empty in // Normalizes a path to forward slashes and strips any trailing slash. Empty in
// -> empty out. Pure string transform (does not consult the filesystem). // -> empty out. Pure string transform (does not consult the filesystem).
// Platform case rule: on Windows (_WIN32) the result is also lowercased so that
// paths differing only in drive-letter or component casing compare equal (Windows
// paths are case-insensitive). On macOS/Linux the case is preserved exactly (those
// filesystems are case-sensitive).
std::string normalizeSlashes(const std::string& path); std::string normalizeSlashes(const std::string& path);
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps // Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
+353 -10
View File
@@ -31,10 +31,14 @@
#include "capture.h" #include "capture.h"
#include "insert.h" #include "insert.h"
#include "persist.h" #include "persist.h"
#include "provenance.h"
#include "provenance_shell.h"
#include "render_settings.h" #include "render_settings.h"
#include "track_guid.h" #include "track_guid.h"
#include "view.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 // 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 // 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 // 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.) // backend. Dialog-free. (Replaces the removed CAPTURE_MASTER_REALTIME action.)
static int g_cmdCaptureTrackRealtime = 0; 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. // 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 // 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 // who started a long capture can bail without waiting for the range end or hunting for
@@ -392,6 +404,102 @@ static bool resolveRange(double& start, double& end, std::string& why)
return false; 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 reads the active take's TakeFX chain (via TakeFX_*) per
// selected item, combined in item order; Track scope reads the track FX chain.
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:
// Track scope — per-track chains combined in track order (TrackFX_*).
// Item scope — per-item active-take chains combined in item order (TakeFX_*);
// the owning track's FX chain is OUT OF SCOPE for an item capture and must
// not be fingerprinted here (it is bypassed during render, not heard).
if (scope == reasampler::CaptureScope::Item) {
const int n = CountSelectedMediaItems(nullptr);
std::vector<MediaItem*> items;
items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
for (int i = 0; i < n; ++i) {
MediaItem* it = GetSelectedMediaItem(nullptr, i);
if (it) items.push_back(it);
}
recipe.fxChainIdentity = reasampler::fxChainIdentityForItems(items);
} else {
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. // Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
static bool collectSelectedTracks(ResolvedSource& out) static bool collectSelectedTracks(ResolvedSource& out)
{ {
@@ -589,6 +697,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 & // 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), // 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. // render via the offline backend, add the Sample to the bank, persist + mark dirty.
@@ -625,17 +750,17 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
req.baseName = def.baseName; req.baseName = def.baseName;
req.trackGuids = src.trackGuids; // recorded on the Sample (provenance) req.trackGuids = src.trackGuids; // recorded on the Sample (provenance)
// Bypass the out-of-scope FX and neutralize their fader gain (D_VOL -> unity) // M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain —
// AND full pan chain (D_PAN/D_WIDTH/D_PANLAW/I_PANMODE -> uncolored) for the // the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and
// duration of the render — so parent/master fader level AND pan/width/law/mode // the source selection is still live here. Returns nullopt unless this capture
// are not baked into the file (see the FxBypassGuard header comment for the // genuinely resamples from a bank sample (detectParent). Read-only.
// authoritative neutralize set). Restored on EVERY exit path below (RAII), const std::optional<reasampler::Provenance> prov =
// including backend failures. proj = active project. buildCaptureProvenance(req, def.scope, src);
ReaProject* proj = EnumProjects(-1, nullptr, 0);
FxBypassGuard fxGuard(def.scope, src.sourceTracks, proj);
reasampler::OfflineRenderBackend backend; // Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain
reasampler::CaptureResult res = backend.capture(req); // 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) if (res.status != reasampler::CaptureStatus::Ok)
{ {
@@ -643,6 +768,10 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
return; 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). // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
g_session.bank().add(res.sample); g_session.bank().add(res.sample);
// B-cap: record the created file in the owned-file manifest, at the same point the // B-cap: record the created file in the owned-file manifest, at the same point the
@@ -657,6 +786,200 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
g_session.saveToActiveProject(); 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. Mirror the same
// scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*;
// track scope reads the track FX chain via TrackFX_*.
std::string currentIdentity;
if (scope == reasampler::CaptureScope::Item) {
const int n = CountSelectedMediaItems(nullptr);
std::vector<MediaItem*> items;
items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
for (int i = 0; i < n; ++i) {
MediaItem* it = GetSelectedMediaItem(nullptr, i);
if (it) items.push_back(it);
}
currentIdentity = reasampler::fxChainIdentityForItems(items);
} else {
std::vector<std::string> perTrackNow;
perTrackNow.reserve(sourceTracks.size());
for (MediaTrack* tr : sourceTracks)
perTrackNow.push_back(reasampler::fxChainIdentityForTrack(tr));
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;
// NOTE: levels, clipped, and lengthBeats are carried from the original (via the
// *orig copy above) because the offline backend does not populate them today
// (res.sample leaves them at defaults). If a later milestone populates these
// fields at capture time, refresh them here from res.sample instead.
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 // STARTS the REALTIME track capture and returns immediately — the record runs across
// timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Resolves the // 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 // selected tracks + the range (razor-else-time, the same orthogonal range logic as the
@@ -803,6 +1126,7 @@ static bool OnHookCommand(int command, int /*flag*/)
if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; }
if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; } if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; }
if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; } if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; }
if (command == g_cmdRecaptureFromSource) { RunRecaptureFromSource(); return true; }
if (command == g_cmdShowVersion) if (command == g_cmdShowVersion)
{ {
// On-demand version readout — the ONLY version output on any path. // On-demand version readout — the ONLY version output on any path.
@@ -833,6 +1157,7 @@ static gaccel_register_t g_accelInsertSelected{};
static gaccel_register_t g_accelInsertSelectedConform{}; static gaccel_register_t g_accelInsertSelectedConform{};
static gaccel_register_t g_accelCaptureTrackRealtime{}; static gaccel_register_t g_accelCaptureTrackRealtime{};
static gaccel_register_t g_accelCancelRealtime{}; static gaccel_register_t g_accelCancelRealtime{};
static gaccel_register_t g_accelRecaptureFromSource{};
static gaccel_register_t g_accelShowVersion{}; static gaccel_register_t g_accelShowVersion{};
// gaccel desc storage. The Actions-list label is channel-qualified at runtime // gaccel desc storage. The Actions-list label is channel-qualified at runtime
@@ -843,6 +1168,7 @@ static std::string g_descInsertSelected;
static std::string g_descInsertSelectedConform; static std::string g_descInsertSelectedConform;
static std::string g_descCaptureTrackRealtime; static std::string g_descCaptureTrackRealtime;
static std::string g_descCancelRealtime; static std::string g_descCancelRealtime;
static std::string g_descRecaptureFromSource;
static std::string g_descShowVersion; static std::string g_descShowVersion;
// Composed command-id strings (channel-qualified), interned so register and the mirroring // Composed command-id strings (channel-qualified), interned so register and the mirroring
@@ -852,6 +1178,7 @@ static const char* g_idInsertSelected = nullptr;
static const char* g_idInsertSelectedConform = nullptr; static const char* g_idInsertSelectedConform = nullptr;
static const char* g_idCaptureTrackRealtime = nullptr; static const char* g_idCaptureTrackRealtime = nullptr;
static const char* g_idCancelRealtime = nullptr; static const char* g_idCancelRealtime = nullptr;
static const char* g_idRecaptureFromSource = nullptr;
static const char* g_idShowVersion = nullptr; static const char* g_idShowVersion = nullptr;
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
@@ -888,6 +1215,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
// used at register (g_id*), so the mirror-unregister matches exactly. // used at register (g_id*), so the mirror-unregister matches exactly.
g_rec->Register("-gaccel", (void*)&g_accelShowVersion); g_rec->Register("-gaccel", (void*)&g_accelShowVersion);
g_rec->Register("-command_id", (void*)g_idShowVersion); 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("-gaccel", (void*)&g_accelCancelRealtime);
g_rec->Register("-command_id", (void*)g_idCancelRealtime); g_rec->Register("-command_id", (void*)g_idCancelRealtime);
g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime); g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime);
@@ -1038,6 +1367,20 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
rec->Register("gaccel", (void*)&g_accelCancelRealtime); 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). // 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 // 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 // startup print. Channel-qualified FOREVER-STABLE id; label carries the channel prefix
+241
View File
@@ -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
+149
View File
@@ -0,0 +1,149 @@
#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. For Track scope the shell
// uses TrackFX_GetFXName/GetFXGUID/GetEnabled; for Item scope it uses the TakeFX_*
// equivalents over the active take's FX chain. Plain data — the shell fills it, the
// pure fold turns the vector into the identity string.
struct FxIdentityEntry {
std::string name; // TrackFX_GetFXName / TakeFX_GetFXName
std::string guid; // TrackFX_GetFXGUID / TakeFX_GetFXGUID -> guidToString
bool enabled; // TrackFX_GetEnabled / TakeFX_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 via normalizeSlashes (which lowercases on Windows) so a
// slash/case difference never spuriously matches or misses. On Windows both sides
// are lowercased before they reach here; on macOS/Linux they are case-exact.
std::optional<std::string> detectParent(
const std::vector<std::string>& sourceItemFiles,
const std::vector<BankFileRef>& bankFiles);
} // namespace reasampler
+182
View File
@@ -0,0 +1,182 @@
// 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)
// * TakeFX_GetCount(MediaItem_Take*) (~6710)
// * TakeFX_GetFXName(MediaItem_Take*, int, char*, int) -> bool (~6758)
// * TakeFX_GetFXGUID(MediaItem_Take*, int) -> GUID* (~6750)
// * TakeFX_GetEnabled(MediaItem_Take*, int) -> bool (~6718)
// * 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_TakeFX_GetCount
#define REAPERAPI_WANT_TakeFX_GetFXName
#define REAPERAPI_WANT_TakeFX_GetFXGUID
#define REAPERAPI_WANT_TakeFX_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);
}
std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items) {
// For Item scope the in-scope chain is each item's active take's FX chain, NOT
// the owning track's FX chain (the track chain is out-of-scope and is bypassed
// during render). TakeFX_* is the correct family here.
std::vector<std::string> perItem;
perItem.reserve(items.size());
for (MediaItem* it : items) {
if (!it) { perItem.push_back(fxChainIdentity({})); continue; }
MediaItem_Take* take = GetActiveTake(it);
if (!take) { perItem.push_back(fxChainIdentity({})); continue; }
std::vector<FxIdentityEntry> rows;
const int n = TakeFX_GetCount(take);
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 (TakeFX_GetFXName(take, i, nameBuf, static_cast<int>(sizeof(nameBuf))))
e.name = nameBuf;
if (GUID* g = TakeFX_GetFXGUID(take, i)) {
char gb[64] = {0};
guidToString(g, gb);
e.guid = gb;
}
e.enabled = TakeFX_GetEnabled(take, i);
rows.push_back(std::move(e));
}
perItem.push_back(fxChainIdentity(rows));
}
return combineChainIdentities(perItem);
}
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
+77
View File
@@ -0,0 +1,77 @@
#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;
class MediaItem;
namespace reasampler {
class BankBook;
// The in-scope FX-chain identity of a source track (Track scope), folded to the
// pure provenance string. Reads the track's own FX chain via TrackFX_GetCount /
// TrackFX_GetFXName / TrackFX_GetFXGUID / TrackFX_GetEnabled in chain order.
std::string fxChainIdentityForTrack(MediaTrack* tr);
// The in-scope FX-chain identity for Item scope: enumerates each item's active
// take FX chain via TakeFX_GetCount / TakeFX_GetFXName / TakeFX_GetFXGUID /
// TakeFX_GetEnabled, in item order then FX order, combined with
// combineChainIdentities so distinct per-item partitions never collide. Returns
// the combined identity string (empty combined identity for a no-FX or no-item
// set). The items vector is the same source-item set the shell collected for the
// item-scope capture (selected items whose owning tracks were also collected).
std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items);
// 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
+30
View File
@@ -756,6 +756,35 @@ static void testRemoveAllBanksLatentScope() {
RemoveResult::RejectedSampleAbsent); 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() { int main() {
testPoolSeededAndDefaults(); testPoolSeededAndDefaults();
testPoolPrivileges(); testPoolPrivileges();
@@ -791,6 +820,7 @@ int main() {
testRemoveRejectionsNoMutation(); testRemoveRejectionsNoMutation();
testHashReferencedElsewhere(); testHashReferencedElsewhere();
testRemoveAllBanksLatentScope(); testRemoveAllBanksLatentScope();
testUpdateSampleInPlace();
if (g_fail == 0) std::printf("All tests passed.\n"); if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
+41
View File
@@ -164,6 +164,46 @@ static void testRelativePathInvariant() {
CHECK(idx.add(noId) == AddResult::RejectedEmptyId); 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() { static void testEmptyIndexRoundTrip() {
BankIndex idx; BankIndex idx;
CHECK(idx.empty()); CHECK(idx.empty());
@@ -375,6 +415,7 @@ int main() {
testDedupByHash(); testDedupByHash();
testTierFilterAndMove(); testTierFilterAndMove();
testRelativePathInvariant(); testRelativePathInvariant();
testUpdateInPlace();
testEmptyIndexRoundTrip(); testEmptyIndexRoundTrip();
testMalformedJson(); testMalformedJson();
testRemoveAndQuery(); testRemoveAndQuery();
+51 -2
View File
@@ -18,12 +18,40 @@ static int g_fail = 0;
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static void testNormalizeSlashes() { static void testNormalizeSlashes() {
CHECK(normalizeSlashes("C:\\a\\b") == "C:/a/b"); #ifdef _WIN32
// On Windows paths are lowercased for case-insensitive comparison.
CHECK(normalizeSlashes("C:\\a\\b") == "c:/a/b");
CHECK(normalizeSlashes("a/b/c") == "a/b/c"); CHECK(normalizeSlashes("a/b/c") == "a/b/c");
CHECK(normalizeSlashes("a/b/") == "a/b"); // trailing slash stripped CHECK(normalizeSlashes("a/b/") == "a/b"); // trailing slash stripped
CHECK(normalizeSlashes("a\\b\\") == "a/b"); // backslash + trailing CHECK(normalizeSlashes("a\\b\\") == "a/b"); // backslash + trailing
CHECK(normalizeSlashes("/") == "/"); // lone root preserved CHECK(normalizeSlashes("/") == "/"); // lone root preserved
CHECK(normalizeSlashes("") == ""); // empty stays empty CHECK(normalizeSlashes("") == ""); // empty stays empty
#else
CHECK(normalizeSlashes("C:\\a\\b") == "C:/a/b");
CHECK(normalizeSlashes("a/b/c") == "a/b/c");
CHECK(normalizeSlashes("a/b/") == "a/b");
CHECK(normalizeSlashes("a\\b\\") == "a/b");
CHECK(normalizeSlashes("/") == "/");
CHECK(normalizeSlashes("") == "");
#endif
}
// Windows case-folding: paths differing only in casing must compare equal after
// normalizeSlashes, since Windows paths are case-insensitive. On non-Windows the
// function is case-preserving (filesystem is case-sensitive).
static void testNormalizeSlashesCaseFolding() {
#ifdef _WIN32
// Drive letter and component casing differences are neutralized.
CHECK(normalizeSlashes("C:/Foo/BAR.wav") == normalizeSlashes("c:/foo/bar.wav"));
CHECK(normalizeSlashes("C:/Foo/BAR.wav") == "c:/foo/bar.wav");
// Mixed-case input produces consistently lowercase output.
CHECK(normalizeSlashes("C:\\Users\\Daniel\\Proj\\File.WAV")
== "c:/users/daniel/proj/file.wav");
#else
// Non-Windows: case is preserved exactly (case-sensitive filesystem).
CHECK(normalizeSlashes("C:/Foo/BAR.wav") != normalizeSlashes("c:/foo/bar.wav"));
CHECK(normalizeSlashes("C:/Foo/BAR.wav") == "C:/Foo/BAR.wav");
#endif
} }
static void testSanitizeStem() { static void testSanitizeStem() {
@@ -57,7 +85,12 @@ static void testDeriveRelativePathIsProjectRelative() {
static void testDeriveAbsoluteDirJoinsProjectDir() { static void testDeriveAbsoluteDirJoinsProjectDir() {
BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "kick", ""); BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "kick", "");
// Backslashes normalized; bank subfolder appended; no trailing slash. // Backslashes normalized; bank subfolder appended; no trailing slash.
// On Windows the drive-letter + components are lowercased by normalizeSlashes.
#ifdef _WIN32
CHECK(p.absoluteDir == "c:/users/d/proj/reasampler_bank");
#else
CHECK(p.absoluteDir == "C:/Users/d/proj/reasampler_bank"); CHECK(p.absoluteDir == "C:/Users/d/proj/reasampler_bank");
#endif
// No unique tag -> stem has no trailing "_". // No unique tag -> stem has no trailing "_".
CHECK(p.fileName == "kick.wav"); CHECK(p.fileName == "kick.wav");
CHECK(p.relativePath == "reasampler_bank/kick.wav"); CHECK(p.relativePath == "reasampler_bank/kick.wav");
@@ -116,7 +149,15 @@ static void testFileStem() {
static void testResolveBankFileAgainstProjectDir() { static void testResolveBankFileAgainstProjectDir() {
// A relative index entry resolves to <projectDir>/<relativePath>, forward- // A relative index entry resolves to <projectDir>/<relativePath>, forward-
// slashed, regardless of the input slash style. // slashed, regardless of the input slash style. On Windows the result is also
// lowercased (Windows paths are case-insensitive; normalizeSlashes folds them).
#ifdef _WIN32
CHECK(resolveBankFile("C:\\Users\\d\\proj", "reasampler_bank/kick.wav")
== "c:/users/d/proj/reasampler_bank/kick.wav");
// Backslashes in the stored relative path are normalized on resolution.
CHECK(resolveBankFile("C:/p", "reasampler_bank\\a.wav")
== "c:/p/reasampler_bank/a.wav");
#else
CHECK(resolveBankFile("C:\\Users\\d\\proj", "reasampler_bank/kick.wav") CHECK(resolveBankFile("C:\\Users\\d\\proj", "reasampler_bank/kick.wav")
== "C:/Users/d/proj/reasampler_bank/kick.wav"); == "C:/Users/d/proj/reasampler_bank/kick.wav");
CHECK(resolveBankFile("/home/d/proj", "reasampler_bank/mix.wav") CHECK(resolveBankFile("/home/d/proj", "reasampler_bank/mix.wav")
@@ -127,6 +168,7 @@ static void testResolveBankFileAgainstProjectDir() {
// Backslashes in the stored relative path are normalized on resolution. // Backslashes in the stored relative path are normalized on resolution.
CHECK(resolveBankFile("/p", "reasampler_bank\\a.wav") CHECK(resolveBankFile("/p", "reasampler_bank\\a.wav")
== "/p/reasampler_bank/a.wav"); == "/p/reasampler_bank/a.wav");
#endif
} }
static void testResolveBankFileRejectsEmptyInputs() { static void testResolveBankFileRejectsEmptyInputs() {
@@ -159,10 +201,16 @@ static void testResolveAgainstNewProjectDirAfterSaveAs() {
static void testRelocationPlanForSaveAs() { static void testRelocationPlanForSaveAs() {
// Save-As to a different directory: relocation is needed; both bank dirs are // Save-As to a different directory: relocation is needed; both bank dirs are
// <projectDir>/reasampler_bank, forward-slashed, no trailing slash. // <projectDir>/reasampler_bank, forward-slashed, no trailing slash.
// On Windows the drive-letter and path components are lowercased.
BankRelocation r = deriveRelocationPlan("C:\\old\\proj", "C:/new/proj"); BankRelocation r = deriveRelocationPlan("C:\\old\\proj", "C:/new/proj");
CHECK(r.needed); CHECK(r.needed);
#ifdef _WIN32
CHECK(r.oldBankDir == "c:/old/proj/reasampler_bank");
CHECK(r.newBankDir == "c:/new/proj/reasampler_bank");
#else
CHECK(r.oldBankDir == "C:/old/proj/reasampler_bank"); CHECK(r.oldBankDir == "C:/old/proj/reasampler_bank");
CHECK(r.newBankDir == "C:/new/proj/reasampler_bank"); CHECK(r.newBankDir == "C:/new/proj/reasampler_bank");
#endif
} }
static void testRelocationPlanNotNeededForSaveInPlace() { static void testRelocationPlanNotNeededForSaveInPlace() {
@@ -501,6 +549,7 @@ static void testHashWavContentDomainSeparationFromWholeFile() {
int main() { int main() {
testNormalizeSlashes(); testNormalizeSlashes();
testNormalizeSlashesCaseFolding();
testSanitizeStem(); testSanitizeStem();
testDeriveRelativePathIsProjectRelative(); testDeriveRelativePathIsProjectRelative();
testDeriveAbsoluteDirJoinsProjectDir(); testDeriveAbsoluteDirJoinsProjectDir();
+310
View File
@@ -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;
}