Merge m10-provenance: provenance + re-capture from source (bank-only)
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)
|
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'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`?
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
#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/") == "a/b"); // trailing slash stripped
|
||||||
|
CHECK(normalizeSlashes("a\\b\\") == "a/b"); // backslash + trailing
|
||||||
|
CHECK(normalizeSlashes("/") == "/"); // lone root preserved
|
||||||
|
CHECK(normalizeSlashes("") == ""); // empty stays empty
|
||||||
|
#else
|
||||||
CHECK(normalizeSlashes("C:\\a\\b") == "C:/a/b");
|
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");
|
||||||
CHECK(normalizeSlashes("a\\b\\") == "a/b"); // backslash + trailing
|
CHECK(normalizeSlashes("a\\b\\") == "a/b");
|
||||||
CHECK(normalizeSlashes("/") == "/"); // lone root preserved
|
CHECK(normalizeSlashes("/") == "/");
|
||||||
CHECK(normalizeSlashes("") == ""); // empty stays empty
|
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();
|
||||||
|
|||||||
@@ -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