diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e3ba1e..b177f2c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -253,6 +253,19 @@ target_link_libraries(wav_trim PUBLIC peaks) add_library(app_version STATIC src/app_version.cpp) target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) +# --------------------------------------------------------------------------- +# 2j) Pure provenance library — NO REAPER, NO SWELL. The Milestone 10 core: the +# recorded capture recipe (CaptureRecipe) + the thin drift-fingerprint that +# rides in Provenance.fxChainSnapshot (P1=a), its build/parse round-trip, the +# FX-chain identity fold, and the pure parent-detection decision (resample-from- +# sample by resolved file path). Split out so the encoding + decision logic are +# unit-tested outside the DAW; the FX-chain query, capture re-run, and action +# registration stay in the shell (main.cpp / actions.cpp). No dependency on +# bank_model — it takes plain strings/values at its boundary. +# --------------------------------------------------------------------------- +add_library(provenance STATIC src/provenance.cpp) +target_include_directories(provenance PUBLIC src) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -329,6 +342,12 @@ add_executable(app_version_tests tests/test_app_version.cpp) target_link_libraries(app_version_tests PRIVATE app_version) add_test(NAME app_version_tests COMMAND app_version_tests) +# The provenance test links bank_model too — it proves the recorded recipe survives +# the Sample-JSON round-trip (Provenance.fxChainSnapshot), the M1 seam M10 rides on. +add_executable(provenance_tests tests/test_provenance.cpp) +target_link_libraries(provenance_tests PRIVATE provenance bank_model) +add_test(NAME provenance_tests COMMAND provenance_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -359,6 +378,7 @@ add_library(reaper_reasampler MODULE src/view_tree.cpp src/view.cpp src/track_guid.cpp + src/provenance_shell.cpp src/guid_diff.cpp src/lane_keys.cpp src/item_read.cpp @@ -366,7 +386,7 @@ add_library(reaper_reasampler MODULE src/bank_book.cpp src/owned_manifest.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest app_version) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest app_version provenance) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/src/bank_book.cpp b/src/bank_book.cpp index 44544f6..bc0a1c3 100644 --- a/src/bank_book.cpp +++ b/src/bank_book.cpp @@ -304,6 +304,13 @@ RemoveResult BankBook::removeSample(const std::string& sampleId, : RemoveResult::RejectedSampleAbsent; } +bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) { + for (auto& b : banks_) + if (b.index.query(sampleId) != nullptr) + return b.index.updateInPlace(sampleId, updated); + return false; // no bank holds the id +} + bool BankBook::hashReferencedElsewhere(const std::string& hash, const std::string& exceptBankId) const { if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash) diff --git a/src/bank_book.h b/src/bank_book.h index 6cc7cd8..68e1268 100644 --- a/src/bank_book.h +++ b/src/bank_book.h @@ -197,6 +197,16 @@ public: const std::string& fromBankId, RemoveScope scope = RemoveScope::ThisBank); + // Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture): + // finds the bank holding `sampleId` and replaces its entry with `updated` + // (order-preserving, no dedup — see BankIndex::updateInPlace). Scans banks in + // ordinal order and updates the FIRST holder (a sample id is unique within a + // bank; the same id living in two banks via copy would update the earliest, which + // is acceptable — re-capture operates on the panel's focused single selection). + // Returns false (no mutation) if no bank holds the id or the replacement's path + // is absolute. Index-only and non-destructive to the timeline. + bool updateSampleInPlace(const std::string& sampleId, const Sample& updated); + // Reference-count query backing the confirm-on-last-reference guardrail: does any // bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`? // diff --git a/src/bank_model.cpp b/src/bank_model.cpp index d207dc9..3c2b768 100644 --- a/src/bank_model.cpp +++ b/src/bank_model.cpp @@ -93,6 +93,17 @@ bool BankIndex::remove(const std::string& id) { return false; } +bool BankIndex::updateInPlace(const std::string& id, const Sample& updated) { + if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds + for (auto& s : samples_) { + if (s.id == id) { + s = updated; // replace in place — position (insertion order) preserved + return true; + } + } + return false; +} + const Sample* BankIndex::query(const std::string& id) const { for (const auto& s : samples_) if (s.id == id) return &s; diff --git a/src/bank_model.h b/src/bank_model.h index 9be017a..2bb59ad 100644 --- a/src/bank_model.h +++ b/src/bank_model.h @@ -44,8 +44,9 @@ struct SourceRange { }; // 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 -// null-test / re-capture-from-source action (M10) can reconstruct the chain. +// parent's id and the FX-chain snapshot string (a thin drift fingerprint, NOT a +// 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 { std::string parentSampleId; std::string fxChainSnapshot; @@ -131,6 +132,19 @@ public: // Removes the sample with `id`. Returns true if one was removed. bool remove(const std::string& id); + // Replaces the sample carrying `id` IN PLACE (preserving its position in + // insertion order), with `updated`. Used by M10 re-capture-from-source: a + // provenanced sample's file is regenerated and its metadata (relativePath, + // contentHash, levels, timestamp, ...) refreshed while its identity (id) and + // slot are kept, so the bank panel shows the same tile updated rather than a + // reordered new entry. `updated.id` should equal `id` (the caller keeps the id + // stable); a differing id is written through as given (the caller's contract). + // Does NOT dedup — an in-place refresh of one entry is not a new insert, so the + // collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false + // (no mutation) if `id` is absent or `updated.relativePath` is absolute + // (the relative-paths-only invariant still holds for the replacement). + bool updateInPlace(const std::string& id, const Sample& updated); + // Returns the sample with `id`, or nullptr if absent. The pointer is // invalidated by any mutating call. const Sample* query(const std::string& id) const; diff --git a/src/capture_paths.cpp b/src/capture_paths.cpp index f328962..c512ded 100644 --- a/src/capture_paths.cpp +++ b/src/capture_paths.cpp @@ -1,6 +1,7 @@ #include "capture_paths.h" #include +#include #include #include #include // std::memcmp @@ -124,6 +125,13 @@ std::string normalizeSlashes(const std::string& path) { if (out.size() > 1 && out.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(std::tolower(static_cast(c))); +#endif return out; } diff --git a/src/capture_paths.h b/src/capture_paths.h index 3286287..dee685d 100644 --- a/src/capture_paths.h +++ b/src/capture_paths.h @@ -68,6 +68,10 @@ std::string hashWavContent(const std::vector& bytes); // Normalizes a path to forward slashes and strips any trailing slash. Empty in // -> 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); // Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps diff --git a/src/main.cpp b/src/main.cpp index d0041d8..16c2793 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,10 +31,14 @@ #include "capture.h" #include "insert.h" #include "persist.h" +#include "provenance.h" +#include "provenance_shell.h" #include "render_settings.h" #include "track_guid.h" #include "view.h" +#include // project-dir derivation for provenance parent resolution + // Persistent action-id family (Phase V, V4 — channel-qualified). Every bindable action // mints its command id from commandIdPrefix() + a per-action SUFFIX, and its Actions-list // name from actionDisplayPrefix() + a phrase, both derived from the ONE channel bit in the @@ -128,6 +132,14 @@ static int g_cmdInsertSelectedConform = 0; // backend. Dialog-free. (Replaces the removed CAPTURE_MASTER_REALTIME action.) static int g_cmdCaptureTrackRealtime = 0; +// Command id for the M10 "re-capture from source" action. NEW FOREVER-STABLE string +// (suffix RECAPTURE_FROM_SOURCE). Regenerates the bank panel's selected PROVENANCED +// sample from its recorded source's current state and updates the Sample in place — +// BANK-ONLY, never places on the timeline (load-bearing principle). Reports the no- +// provenance / vanished-source / drift cases to the console (a direct response to an +// explicit action, allowed by the console policy). +static int g_cmdRecaptureFromSource = 0; + // Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string. // Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user // who started a long capture can bail without waiting for the range end or hunting for @@ -392,6 +404,102 @@ static bool resolveRange(double& start, double& end, std::string& why) return false; } +// Current project's directory (parent of its .rpp), forward-slashed, no trailing +// slash — the same derivation capture.cpp does internally, needed here so M10 can +// resolve the bank's relative paths to absolute for parent detection. Empty for an +// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank +// file resolve empty -> no false parentage. Read-only; mutates nothing. +static std::string currentProjectDir() +{ + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(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 buildCaptureProvenance( + const reasampler::CaptureRequest& req, + reasampler::CaptureScope scope, + const ResolvedSource& src) +{ + const std::string projectDir = currentProjectDir(); + const std::vector 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 sourceFiles = + scope == reasampler::CaptureScope::Item + ? reasampler::selectedItemSourceFiles() + : reasampler::trackItemSourceFiles(src.sourceTracks, req.startSeconds, + req.endSeconds); + + const std::optional 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(req.sourceMode); + recipe.startSeconds = req.startSeconds; + recipe.endSeconds = req.endSeconds; + recipe.tailMode = static_cast(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 items; + items.reserve(static_cast(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 perTrack; + perTrack.reserve(src.sourceTracks.size()); + for (MediaTrack* tr : src.sourceTracks) + perTrack.push_back(reasampler::fxChainIdentityForTrack(tr)); + recipe.fxChainIdentity = reasampler::combineChainIdentities(perTrack); + } + + reasampler::Provenance prov; + prov.parentSampleId = *parentId; + prov.fxChainSnapshot = reasampler::buildFingerprint(recipe); + return prov; +} + // Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. static bool collectSelectedTracks(ResolvedSource& out) { @@ -589,6 +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& sourceTracks, + const reasampler::CaptureRequest& req) +{ + ReaProject* proj = EnumProjects(-1, nullptr, 0); + FxBypassGuard fxGuard(scope, sourceTracks, proj); + reasampler::OfflineRenderBackend backend; + return backend.capture(req); +} + // Runs one capture-action-table row: resolve its scope source + range, snapshot & // clear the out-of-scope FX AND neutralize their fader gain + pan chain (RAII), // render via the offline backend, add the Sample to the bank, persist + mark dirty. @@ -625,17 +750,17 @@ static void RunCapture(const reasampler::CaptureActionDef& def) req.baseName = def.baseName; req.trackGuids = src.trackGuids; // recorded on the Sample (provenance) - // Bypass the out-of-scope FX and neutralize their fader gain (D_VOL -> unity) - // AND full pan chain (D_PAN/D_WIDTH/D_PANLAW/I_PANMODE -> uncolored) for the - // duration of the render — so parent/master fader level AND pan/width/law/mode - // are not baked into the file (see the FxBypassGuard header comment for the - // authoritative neutralize set). Restored on EVERY exit path below (RAII), - // including backend failures. proj = active project. - ReaProject* proj = EnumProjects(-1, nullptr, 0); - FxBypassGuard fxGuard(def.scope, src.sourceTracks, proj); + // M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain — + // the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and + // the source selection is still live here. Returns nullopt unless this capture + // genuinely resamples from a bank sample (detectParent). Read-only. + const std::optional prov = + buildCaptureProvenance(req, def.scope, src); - reasampler::OfflineRenderBackend backend; - reasampler::CaptureResult res = backend.capture(req); + // Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain + // neutralized for the render, fully restored on every path — see renderOffline + // and the FxBypassGuard header comment). Non-destructive; writes a file only. + reasampler::CaptureResult res = renderOffline(def.scope, src.sourceTracks, req); if (res.status != reasampler::CaptureStatus::Ok) { @@ -643,6 +768,10 @@ static void RunCapture(const reasampler::CaptureActionDef& def) return; } + // Stamp provenance onto the captured Sample (only set when this was a genuine + // resample-from-sample; otherwise the optional stays empty, per M1's contract). + res.sample.provenance = prov; + // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). g_session.bank().add(res.sample); // B-cap: record the created file in the owned-file manifest, at the same point the @@ -657,6 +786,200 @@ static void RunCapture(const reasampler::CaptureActionDef& def) g_session.saveToActiveProject(); } +// --- M10: re-capture from source -------------------------------------------- +// +// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT +// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and +// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the +// load-bearing capture-never-places line, structurally visible: this function has no +// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore +// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places +// manually if they want the new version on the timeline. +// +// Failure modes are handled explicitly and reported to the user (a direct response +// to an explicit action is allowed by the console policy): +// * the selected sample has no provenance (not a resample) -> reported, no-op. +// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op. +// * the recorded source track(s) no longer exist -> reported, no-op. +// * the render itself fails to satisfy the recorded request -> reported, no-op. +// On success, if the source FX chain drifted since capture (recorded vs current +// identity differ) the user is told — the re-capture still reflects the source AS IT +// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source). +static void RunRecaptureFromSource() +{ + const std::vector 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 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 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(recipe->sourceMode); + req.startSeconds = recipe->startSeconds; + req.endSeconds = recipe->endSeconds; + req.wetDry = 1.0; + req.tailMode = static_cast(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 items; + items.reserve(static_cast(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 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 // timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Resolves the // selected tracks + the range (razor-else-time, the same orthogonal range logic as the @@ -803,6 +1126,7 @@ static bool OnHookCommand(int command, int /*flag*/) if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; } if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; } + if (command == g_cmdRecaptureFromSource) { RunRecaptureFromSource(); return true; } if (command == g_cmdShowVersion) { // On-demand version readout — the ONLY version output on any path. @@ -833,6 +1157,7 @@ static gaccel_register_t g_accelInsertSelected{}; static gaccel_register_t g_accelInsertSelectedConform{}; static gaccel_register_t g_accelCaptureTrackRealtime{}; static gaccel_register_t g_accelCancelRealtime{}; +static gaccel_register_t g_accelRecaptureFromSource{}; static gaccel_register_t g_accelShowVersion{}; // gaccel desc storage. The Actions-list label is channel-qualified at runtime @@ -843,6 +1168,7 @@ static std::string g_descInsertSelected; static std::string g_descInsertSelectedConform; static std::string g_descCaptureTrackRealtime; static std::string g_descCancelRealtime; +static std::string g_descRecaptureFromSource; static std::string g_descShowVersion; // Composed command-id strings (channel-qualified), interned so register and the mirroring @@ -852,6 +1178,7 @@ static const char* g_idInsertSelected = nullptr; static const char* g_idInsertSelectedConform = nullptr; static const char* g_idCaptureTrackRealtime = nullptr; static const char* g_idCancelRealtime = nullptr; +static const char* g_idRecaptureFromSource = nullptr; static const char* g_idShowVersion = nullptr; extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( @@ -888,6 +1215,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // used at register (g_id*), so the mirror-unregister matches exactly. g_rec->Register("-gaccel", (void*)&g_accelShowVersion); g_rec->Register("-command_id", (void*)g_idShowVersion); + g_rec->Register("-gaccel", (void*)&g_accelRecaptureFromSource); + g_rec->Register("-command_id", (void*)g_idRecaptureFromSource); g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime); g_rec->Register("-command_id", (void*)g_idCancelRealtime); g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime); @@ -1038,6 +1367,20 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( rec->Register("gaccel", (void*)&g_accelCancelRealtime); } + // Register the M10 "re-capture from source" action (command_id -> gaccel -> + // hookcommand). Regenerates the selected provenanced sample from its recorded + // source's current state; bank-only, never places on the timeline. Channel- + // qualified FOREVER-STABLE id (suffix RECAPTURE_FROM_SOURCE). + g_idRecaptureFromSource = internCmdId("RECAPTURE_FROM_SOURCE"); + g_cmdRecaptureFromSource = rec->Register("command_id", (void*)g_idRecaptureFromSource); + if (g_cmdRecaptureFromSource) + { + g_descRecaptureFromSource = reasampler::channelActionName("re-capture from source"); + g_accelRecaptureFromSource.accel.cmd = g_cmdRecaptureFromSource; + g_accelRecaptureFromSource.desc = g_descRecaptureFromSource.c_str(); + rec->Register("gaccel", (void*)&g_accelRecaptureFromSource); + } + // Register the Phase V "show version" action (command_id -> gaccel -> hookcommand). // On-demand only — prints the CMake-sourced version to the console when fired; no // startup print. Channel-qualified FOREVER-STABLE id; label carries the channel prefix diff --git a/src/provenance.cpp b/src/provenance.cpp new file mode 100644 index 0000000..6054924 --- /dev/null +++ b/src/provenance.cpp @@ -0,0 +1,241 @@ +#include "provenance.h" + +#include +#include + +// 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 ':' +// +// 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: ':' +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(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(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(v); + return true; + } + + const std::string& s_; + std::size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace + +std::string fxChainIdentity(const std::vector& 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& 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(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 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(ProvenanceScope::Item) && + scopeInt != static_cast(ProvenanceScope::Track)) + return std::nullopt; + r.scope = static_cast(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 detectParent( + const std::vector& sourceItemFiles, + const std::vector& bankFiles) { + if (sourceItemFiles.empty()) return std::nullopt; + + std::optional 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 diff --git a/src/provenance.h b/src/provenance.h new file mode 100644 index 0000000..d8f5a66 --- /dev/null +++ b/src/provenance.h @@ -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 +#include +#include + +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 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& 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& 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 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 detectParent( + const std::vector& sourceItemFiles, + const std::vector& bankFiles); + +} // namespace reasampler diff --git a/src/provenance_shell.cpp b/src/provenance_shell.cpp new file mode 100644 index 0000000..111ae6c --- /dev/null +++ b/src/provenance_shell.cpp @@ -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 + +#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 rows; + const int n = TrackFX_GetCount(tr); + rows.reserve(static_cast(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(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& 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 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 rows; + const int n = TakeFX_GetCount(take); + rows.reserve(static_cast(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(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(sizeof(buf))); + return normalizeSlashes(std::string(buf)); +} +} // namespace + +std::vector selectedItemSourceFiles() { + std::vector 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 trackItemSourceFiles(const std::vector& tracks, + double startSeconds, double endSeconds) { + std::vector 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 bankFileRefs(const BankBook& book, const std::string& projectDir) { + std::vector 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 diff --git a/src/provenance_shell.h b/src/provenance_shell.h new file mode 100644 index 0000000..e53888b --- /dev/null +++ b/src/provenance_shell.h @@ -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 +#include +#include + +#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& 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 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 trackItemSourceFiles(const std::vector& 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 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 diff --git a/tests/test_bank_book.cpp b/tests/test_bank_book.cpp index 479550e..8893187 100644 --- a/tests/test_bank_book.cpp +++ b/tests/test_bank_book.cpp @@ -756,6 +756,35 @@ static void testRemoveAllBanksLatentScope() { RemoveResult::RejectedSampleAbsent); } +// M10: updateSampleInPlace refreshes a sample wherever it lives, order-preserving, +// no dedup, and reports no-op honestly for an absent id. +static void testUpdateSampleInPlace() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + CHECK(book.pool().index.add(sampleWith("kick")) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("snare")) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("hat")) == AddResult::Added); + + // Refresh the middle entry of the NAMED bank: new path/hash, same id + slot. + Sample updated = sampleWith("snare"); + updated.relativePath = "bank/snare-recaptured.wav"; + updated.contentHash = "recaptured-hash"; + CHECK(book.updateSampleInPlace("id-snare", updated)); + CHECK(book.bank("drums")->index.all()[0].id == "id-snare"); // slot preserved + CHECK(book.bank("drums")->index.query("id-snare")->relativePath == + "bank/snare-recaptured.wav"); + CHECK(book.bank("drums")->index.size() == 2); // no new entry + + // A sample in the POOL is found and updated too. + Sample pk = sampleWith("kick"); + pk.contentHash = "kick-recaptured"; + CHECK(book.updateSampleInPlace("id-kick", pk)); + CHECK(book.pool().index.query("id-kick")->contentHash == "kick-recaptured"); + + // An absent id is an honest no-op. + CHECK(!book.updateSampleInPlace("id-nope", sampleWith("nope"))); +} + int main() { testPoolSeededAndDefaults(); testPoolPrivileges(); @@ -791,6 +820,7 @@ int main() { testRemoveRejectionsNoMutation(); testHashReferencedElsewhere(); testRemoveAllBanksLatentScope(); + testUpdateSampleInPlace(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index 0786587..9a77c56 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -164,6 +164,46 @@ static void testRelativePathInvariant() { CHECK(idx.add(noId) == AddResult::RejectedEmptyId); } +// M10: updateInPlace refreshes an entry while preserving its slot + identity, and +// bypasses dedup (an in-place refresh is not a new insert). The relative-paths-only +// invariant still guards the replacement. +static void testUpdateInPlace() { + BankIndex idx; + CHECK(idx.add(minimalSample("a")) == AddResult::Added); // id "min-a" + CHECK(idx.add(minimalSample("b")) == AddResult::Added); // id "min-b" + CHECK(idx.add(minimalSample("c")) == AddResult::Added); // id "min-c" + + // Refresh the MIDDLE entry: new file/hash/name, same id — position must be kept. + Sample updated = minimalSample("b"); + updated.relativePath = "reasampler_bank/regenerated.wav"; + updated.contentHash = "new-hash-b"; + updated.displayName = "regenerated"; + CHECK(idx.updateInPlace("min-b", updated)); + + CHECK(idx.size() == 3); // no new entry, no removal + CHECK(idx.all()[1].id == "min-b"); // slot preserved (still middle) + CHECK(idx.all()[1].relativePath == "reasampler_bank/regenerated.wav"); + CHECK(idx.all()[1].contentHash == "new-hash-b"); + CHECK(idx.query("min-b")->displayName == "regenerated"); + + // An updated hash colliding with ANOTHER entry does NOT collapse (updateInPlace + // is not an insert): the refreshed entry keeps its slot even sharing a hash. + Sample collide = minimalSample("b"); + collide.contentHash = idx.query("min-a")->contentHash; // same as entry "min-a" + CHECK(idx.updateInPlace("min-b", collide)); + CHECK(idx.size() == 3); // still three; no collapse + + // Updating an absent id fails without mutation. + CHECK(!idx.updateInPlace("nope", minimalSample("x"))); + CHECK(idx.size() == 3); + + // An absolute replacement path is rejected (invariant preserved). + Sample bad = minimalSample("b"); + bad.relativePath = "C:/evil.wav"; + CHECK(!idx.updateInPlace("min-b", bad)); + CHECK(idx.query("min-b")->relativePath != "C:/evil.wav"); +} + static void testEmptyIndexRoundTrip() { BankIndex idx; CHECK(idx.empty()); @@ -375,6 +415,7 @@ int main() { testDedupByHash(); testTierFilterAndMove(); testRelativePathInvariant(); + testUpdateInPlace(); testEmptyIndexRoundTrip(); testMalformedJson(); testRemoveAndQuery(); diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index 301e8eb..4796893 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -18,12 +18,40 @@ static int g_fail = 0; std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) 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("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 + CHECK(normalizeSlashes("a/b/") == "a/b"); + CHECK(normalizeSlashes("a\\b\\") == "a/b"); + CHECK(normalizeSlashes("/") == "/"); + CHECK(normalizeSlashes("") == ""); +#endif +} + +// Windows case-folding: paths differing only in casing must compare equal after +// normalizeSlashes, since Windows paths are case-insensitive. On non-Windows the +// function is case-preserving (filesystem is case-sensitive). +static void testNormalizeSlashesCaseFolding() { +#ifdef _WIN32 + // Drive letter and component casing differences are neutralized. + CHECK(normalizeSlashes("C:/Foo/BAR.wav") == normalizeSlashes("c:/foo/bar.wav")); + CHECK(normalizeSlashes("C:/Foo/BAR.wav") == "c:/foo/bar.wav"); + // Mixed-case input produces consistently lowercase output. + CHECK(normalizeSlashes("C:\\Users\\Daniel\\Proj\\File.WAV") + == "c:/users/daniel/proj/file.wav"); +#else + // Non-Windows: case is preserved exactly (case-sensitive filesystem). + CHECK(normalizeSlashes("C:/Foo/BAR.wav") != normalizeSlashes("c:/foo/bar.wav")); + CHECK(normalizeSlashes("C:/Foo/BAR.wav") == "C:/Foo/BAR.wav"); +#endif } static void testSanitizeStem() { @@ -57,7 +85,12 @@ static void testDeriveRelativePathIsProjectRelative() { static void testDeriveAbsoluteDirJoinsProjectDir() { BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "kick", ""); // 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"); +#endif // No unique tag -> stem has no trailing "_". CHECK(p.fileName == "kick.wav"); CHECK(p.relativePath == "reasampler_bank/kick.wav"); @@ -116,7 +149,15 @@ static void testFileStem() { static void testResolveBankFileAgainstProjectDir() { // A relative index entry resolves to /, 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") == "C:/Users/d/proj/reasampler_bank/kick.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. CHECK(resolveBankFile("/p", "reasampler_bank\\a.wav") == "/p/reasampler_bank/a.wav"); +#endif } static void testResolveBankFileRejectsEmptyInputs() { @@ -159,10 +201,16 @@ static void testResolveAgainstNewProjectDirAfterSaveAs() { static void testRelocationPlanForSaveAs() { // Save-As to a different directory: relocation is needed; both bank dirs are // /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"); 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.newBankDir == "C:/new/proj/reasampler_bank"); +#endif } static void testRelocationPlanNotNeededForSaveInPlace() { @@ -501,6 +549,7 @@ static void testHashWavContentDomainSeparationFromWholeFile() { int main() { testNormalizeSlashes(); + testNormalizeSlashesCaseFolding(); testSanitizeStem(); testDeriveRelativePathIsProjectRelative(); testDeriveAbsoluteDirJoinsProjectDir(); diff --git a/tests/test_provenance.cpp b/tests/test_provenance.cpp new file mode 100644 index 0000000..3671f93 --- /dev/null +++ b/tests/test_provenance.cpp @@ -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 +#include +#include + +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 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 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; +}