// capture_batch.cpp — the batch-capture family + re-capture-from-source. See the // header. // // 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; here they are extern. #include "shell/capture/capture_batch.h" #include #include #include #include #include #include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId #include "shell/panel/panel_input.h" // bankPanelRefresh #include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome #include "core/capture/capture_paths.h" // projectDirOfRpp #include "core/model/bank_book.h" // BankBook / Bank #include "core/model/provenance.h" // recipe parse/build, fingerprint #include "shell/persist/session.h" // ReaSamplerSession #include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline #include "shell/capture/provenance_shell.h" // fxChainIdentity* / trackByGuid #include "shell/capture/render_bounds_gate.h" // refusedRenderFolder #include "shell/capture/scope_resolve.h" // ResolvedSource #include "shell/capture/track_guid.h" // guidString #include "reaper_plugin.h" // UNDO_STATE_MISCCFG #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedMediaItems #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetSelectedMediaItem #define REAPERAPI_WANT_GetMediaItem_Track #define REAPERAPI_WANT_GetMediaItemInfo_Value #define REAPERAPI_WANT_CountMediaItems #define REAPERAPI_WANT_GetMediaItem #define REAPERAPI_WANT_SetMediaItemSelected #define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_CountTracks #define REAPERAPI_WANT_GetTrack #define REAPERAPI_WANT_GetSetMediaTrackInfo_String #define REAPERAPI_WANT_CountSelectedTracks #define REAPERAPI_WANT_GetSelectedTrack #define REAPERAPI_WANT_SetTrackSelected #define REAPERAPI_WANT_SetOnlyTrackSelected #define REAPERAPI_WANT_ShowConsoleMsg #define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler::capture { // One action fires N captures — one sample per selected item (item scope) or per // razor area (track scope, each area's own range). Each unit routes through // captureAndIndexOne so every precision invariant holds; nothing lands in the // arrange (load-bearing principle). // // Each unit is named after its own source track and carries its batch ordinal, so two // units in one batch read apart even when they came off the same track; makeUniqueTag's // per-session monotonic counter keeps same-second units across batches from colliding. namespace { // RAII snapshot/restore of the item selection. Batch item capture must transiently // select exactly one item per render (RENDER_SETTINGS &32 renders whatever is // selected); the original selection is restored on every exit path — including a // mid-batch failure — as part of the non-destructive invariant. class ItemSelectionGuard { public: ItemSelectionGuard() { const int n = CountSelectedMediaItems(nullptr); for (int i = 0; i < n; ++i) if (MediaItem* it = GetSelectedMediaItem(nullptr, i)) selected_.push_back(it); } ~ItemSelectionGuard() { // Deselect everything first (not just currently-selected) so any transient // selection is cleared, then re-select exactly the snapshot. const int total = CountMediaItems(nullptr); for (int i = 0; i < total; ++i) if (MediaItem* it = GetMediaItem(nullptr, i)) SetMediaItemSelected(it, false); for (MediaItem* it : selected_) SetMediaItemSelected(it, true); UpdateArrange(); // reflect the restored selection in the arrange view } ItemSelectionGuard(const ItemSelectionGuard&) = delete; ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete; private: std::vector selected_; }; // Names where refused renders were retained, once, when the batch quarantined at // least one (BoundsMismatch failures only -- a render that never produced a file has // nothing to retain). Without this, a batch's per-unit failure detail (which DOES name // the destination, same as a single capture's console line) never reaches the console // at all -- the batch summary reports ordinals only. std::string withQuarantineNote(std::string line, int quarantinedCount) { if (quarantinedCount <= 0) return line; std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); const std::string dir = projectDirOfRpp(std::string(buf.data())); if (dir.empty()) return line; // unreachable: a quarantine implies a saved project line += " " + std::to_string(quarantinedCount) + " refused render" + (quarantinedCount == 1 ? " was" : "s were") + " retained for diagnosis, " "normally at " + refusedRenderFolder(dir) + " (delete when done) -- one " "whose move there failed instead stays in the bank folder, unindexed."; return line; } // Deselect-all then select-one so the offline render's &32 bit captures exactly // this item. Called inside ItemSelectionGuard, which restores the original selection. void selectOnlyItem(MediaItem* item) { const int total = CountMediaItems(nullptr); for (int i = 0; i < total; ++i) if (MediaItem* it = GetMediaItem(nullptr, i)) SetMediaItemSelected(it, it == item); } // Collects every track's razor areas as (owning track, range) pairs, track order // then area order. Read-only — never clears the razor selection. std::vector> collectRazorAreas() { std::vector> areas; const int n = CountTracks(nullptr); for (int i = 0; i < n; ++i) { MediaTrack* tr = GetTrack(nullptr, i); if (!tr) continue; std::vector buf(8192, '\0'); if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) continue; for (const RazorRange& r : parseRazorEdits(std::string(buf.data()))) areas.push_back({tr, r}); } return areas; } // Mirror of ItemSelectionGuard for track selection: batch razor capture transiently // selects the area's owning track per render (&128 renders selected tracks), restoring // the original selection on every exit path (non-destructive invariant). class TrackSelectionGuard { public: TrackSelectionGuard() { const int n = CountSelectedTracks(nullptr); for (int i = 0; i < n; ++i) if (MediaTrack* tr = GetSelectedTrack(nullptr, i)) selected_.push_back(tr); } ~TrackSelectionGuard() { // Deselect every track, then re-select the snapshot — the exact original set. const int total = CountTracks(nullptr); for (int i = 0; i < total; ++i) if (MediaTrack* tr = GetTrack(nullptr, i)) SetTrackSelected(tr, false); for (MediaTrack* tr : selected_) SetTrackSelected(tr, true); } TrackSelectionGuard(const TrackSelectionGuard&) = delete; TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete; private: std::vector selected_; }; } // namespace // One sample per selected item. Snapshots the selection (RAII-restored), transiently // selects each item in turn, renders its exact range under item-scope FX neutralize, // and persists once at the end for the whole batch. void RunBatchCaptureItems(ReaSamplerSession& session) { // Read bounds + owning track now, while the full selection is live and before any // transient re-selection (batch only mutates selection flags, never adds/removes items). struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; }; std::vector itemUnits; { const int n = CountSelectedMediaItems(nullptr); for (int i = 0; i < n; ++i) { MediaItem* it = GetSelectedMediaItem(nullptr, i); if (!it) continue; MediaTrack* tr = GetMediaItem_Track(it); if (!tr) continue; const double pos = GetMediaItemInfo_Value(it, "D_POSITION"); const double len = GetMediaItemInfo_Value(it, "D_LENGTH"); itemUnits.push_back({it, tr, pos, pos + len}); } } if (itemUnits.empty()) { ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n"); return; } // Plan exact ranges -> validated, ordinal-assigned units; zero-length items are // dropped here so no stray render runs. std::vector ranges; ranges.reserve(itemUnits.size()); for (const ItemUnit& u : itemUnits) ranges.push_back({u.start, u.end}); const std::vector plan = planCaptureUnits(ranges); BatchOutcome outcome; bool anyAdded = false; int quarantined = 0; // BoundsMismatch failures, each of which retained a file { // selGuard restores the original item selection on every exit path. ItemSelectionGuard selGuard; // plan and itemUnits are parallel over kept units; skip dropped ranges in lockstep. std::size_t planIdx = 0; for (const ItemUnit& u : itemUnits) { if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep const CaptureUnit& unit = plan[planIdx++]; // select only this item so the item-scope render captures exactly it. selectOnlyItem(u.item); ResolvedSource src; src.startSeconds = unit.startSeconds; src.endSeconds = unit.endSeconds; src.sourceTracks.push_back(u.track); // The unit's range IS this item's own extent (read above from // D_POSITION/D_LENGTH) and it is the only selected item, so the // selected-items render source prints exactly it — batch keeps the // one-sample-per-item-at-item-extent semantics, unchanged. src.itemExtentIsWindow = true; src.trackNames.push_back(trackName(u.track)); if (std::string g = guidString(u.track); !g.empty()) src.trackGuids.push_back(std::move(g)); const CaptureName name = captureNameFor(src.trackNames, unit.ordinal, "item"); CaptureResult res = captureAndIndexOne( session, CaptureScope::Item, src, name, unit.startSeconds, unit.endSeconds); const bool ok = (res.status == CaptureStatus::Ok); outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); if (ok) anyAdded = true; else if (res.status == CaptureStatus::BoundsMismatch) ++quarantined; } } // selGuard restores the original selection here, on every path // One ext-state write for the whole batch, only if something landed. The generation // bump is monotonic, so one increment past the last-seen value triggers reload in // any listening instance. if (anyAdded) { session.bumpBankGeneration(); session.saveToActiveProject(); } ShowConsoleMsg((withQuarantineNote(outcome.summaryLine("item"), quarantined) + "\n").c_str()); } // One sample per razor area, track scope over that area's own range. Track scope // renders selected tracks via master (&128), so each unit selects only its owning // track under TrackSelectionGuard; the razor selection itself is read-only. Persists // once at the end. void RunBatchCaptureRazor(ReaSamplerSession& session) { const std::vector> areas = collectRazorAreas(); if (areas.empty()) { ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n"); return; } std::vector ranges; ranges.reserve(areas.size()); for (const auto& a : areas) ranges.push_back({a.second.startSeconds, a.second.endSeconds}); const std::vector plan = planCaptureUnits(ranges); BatchOutcome outcome; bool anyAdded = false; int quarantined = 0; // BoundsMismatch failures, each of which retained a file { // selGuard restores the original track selection on every exit path. TrackSelectionGuard selGuard; std::size_t planIdx = 0; for (const auto& a : areas) { if (!(a.second.endSeconds > a.second.startSeconds)) continue; // dropped — lockstep const CaptureUnit& unit = plan[planIdx++]; MediaTrack* tr = a.first; // select only this track so track-scope render (&128) captures it via master. SetOnlyTrackSelected(tr); ResolvedSource src; src.startSeconds = unit.startSeconds; src.endSeconds = unit.endSeconds; src.sourceTracks.push_back(tr); src.trackNames.push_back(trackName(tr)); if (std::string g = guidString(tr); !g.empty()) src.trackGuids.push_back(std::move(g)); const CaptureName name = captureNameFor(src.trackNames, unit.ordinal, "razor"); CaptureResult res = captureAndIndexOne( session, CaptureScope::Track, src, name, unit.startSeconds, unit.endSeconds); const bool ok = (res.status == CaptureStatus::Ok); outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); if (ok) anyAdded = true; else if (res.status == CaptureStatus::BoundsMismatch) ++quarantined; } } // selGuard restores the original track selection here, on every path // One coalesced generation bump for the whole batch (see the item-batch note above). if (anyAdded) { session.bumpBankGeneration(); session.saveToActiveProject(); } ShowConsoleMsg((withQuarantineNote(outcome.summaryLine("razor area"), quarantined) + "\n").c_str()); } // Regenerates a provenanced sample's file from its recorded source's current state // and updates the bank Sample in place. Bank-only — never calls InsertMedia; the // user re-places manually if they want the new version on the timeline. // Non-destructive to the source (FxBypassGuard snapshot/restore via renderOffline). // // Failure modes are explicit and reported, each a no-op: no provenance, unparseable // fingerprint, a missing recorded source track, or a failed render. On success, if // the source FX chain drifted since capture, the user is told — the re-capture still // reflects the source as it is now (drift is detected, not frozen against). void RunRecaptureFromSource(ReaSamplerSession& session) { const std::vector selected = 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 = bankPanelSelectedSourceBankId(); const Bank* bank = session.book().bank(srcBankId); const model::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 recipe; legacy/corrupt fingerprints fail gracefully, never // a partial re-capture. const std::string recordedParentId = orig->provenance->parentSampleId; const std::string recordedFingerprint = orig->provenance->fxChainSnapshot; const std::optional recipe = model::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; } // Missing recorded track = hard failure; never silently re-capture a different source. std::vector sourceTracks; for (const std::string& g : recipe->trackGuids) { MediaTrack* tr = 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()) { // e.g. an item-scope capture with no track-scoped source — nothing to resolve. ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this " "sample; cannot re-capture from source.\n"); return; } const CaptureScope scope = recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item : CaptureScope::Track; // Rebuild the request verbatim from the recorded recipe, re-run against the // source's current state: an unchanged source reproduces a byte-identical file // (bit-identical-repeats invariant). 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 = WavBitDepth::Float32; // orig->displayName is the ORIGINAL capture's label (recapture preserves identity, it // does not re-mint it — see this function's header comment), so the regenerated file's // stem carries the original capture's stamp, not this render's — do not read it as a // render timestamp. req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName; req.trackGuids = recipe->trackGuids; // Read the current FX-chain identity BEFORE the render bypasses it, for drift // comparison against the recorded identity (item scope via TakeFX_*, track scope // via TrackFX_*). std::string currentIdentity; if (scope == 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 = fxChainIdentityForItems(items); } else { std::vector perTrackNow; perTrackNow.reserve(sourceTracks.size()); for (MediaTrack* tr : sourceTracks) perTrackNow.push_back(fxChainIdentityForTrack(tr)); currentIdentity = model::combineChainIdentities(perTrackNow); } const bool drifted = (currentIdentity != recipe->fxChainIdentity); // Render (bank-only; renderOffline never touches the timeline). CaptureResult res = renderOffline(scope, sourceTracks, req); if (res.status != CaptureStatus::Ok) { ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str()); return; } // Update in place: keep identity (id) + provenance parent, adopt the regenerated // file's path/hash/length/rate/timestamp, and rebuild the fingerprint with the // current FX identity so the next re-capture measures drift from here, not the // original. model::CaptureRecipe refreshed = *recipe; refreshed.fxChainIdentity = currentIdentity; model::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.captureTimeSigNum = res.sample.captureTimeSigNum; // refresh meter stamp updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter updated.trackGuids = res.sample.trackGuids; updated.createdTimestamp = res.sample.createdTimestamp; // levels/clipped/lengthBeats carry from *orig — the offline backend doesn't // populate them; refresh from res.sample here if that ever changes. model::Provenance prov; prov.parentSampleId = recordedParentId; prov.fxChainSnapshot = model::buildFingerprint(refreshed); updated.provenance = prov; // One batched undo point around the in-place mutation; index-only ext-state, // nothing placed on the timeline. Undo_BeginBlock2(nullptr); // Before the index outcome is even known: the render above already wrote the file, // and makeUniqueTag guarantees a NEW path, so this is a second record carrying the // same sampleId as the original. The superseded file becomes an orphan for prune. session.recordCreated(updated, tracking::OriginKind::Recapture); const bool changed = session.book().updateSampleInPlace(sampleId, updated); if (changed) { // Regenerating the same id's audio is exactly why instances need the generation // bump — they'd otherwise keep playing stale audio until reload. Bumped inside // the undo block so undo rolls back the generation with the rest of the blob. session.bumpBankGeneration(); const bool persisted = session.saveToActiveProject(); // book + ledger + generation + 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 } 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"); } } // namespace reasampler::capture