5e3ea6c851
sourceModeForScope now takes the item extent vs. the requested window. Full-extent item captures and the batch keep the old path unchanged.
492 lines
20 KiB
C++
492 lines
20 KiB
C++
// 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 <cstddef>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#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/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/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_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's baseName carries its ordinal ("item-1", "item-2", ...) so two units
|
|
// in one batch never share a stem, and makeUniqueTag's per-session monotonic
|
|
// counter keeps same-second units across batches from colliding too.
|
|
|
|
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<MediaItem*> selected_;
|
|
};
|
|
|
|
// 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<std::pair<MediaTrack*, RazorRange>> collectRazorAreas()
|
|
{
|
|
std::vector<std::pair<MediaTrack*, RazorRange>> areas;
|
|
const int n = CountTracks(nullptr);
|
|
for (int i = 0; i < n; ++i)
|
|
{
|
|
MediaTrack* tr = GetTrack(nullptr, i);
|
|
if (!tr) continue;
|
|
std::vector<char> 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<MediaTrack*> 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<ItemUnit> 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<BatchRange> ranges;
|
|
ranges.reserve(itemUnits.size());
|
|
for (const ItemUnit& u : itemUnits)
|
|
ranges.push_back({u.start, u.end});
|
|
const std::vector<CaptureUnit> plan = planCaptureUnits(ranges);
|
|
|
|
BatchOutcome outcome;
|
|
bool anyAdded = false;
|
|
{
|
|
// 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;
|
|
if (std::string g = guidString(u.track); !g.empty())
|
|
src.trackGuids.push_back(std::move(g));
|
|
|
|
const std::string baseName = "item-" + std::to_string(unit.ordinal);
|
|
CaptureResult res = captureAndIndexOne(
|
|
session, CaptureScope::Item, src, baseName,
|
|
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;
|
|
}
|
|
} // 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((outcome.summaryLine("item") + "\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<std::pair<MediaTrack*, RazorRange>> areas = collectRazorAreas();
|
|
if (areas.empty())
|
|
{
|
|
ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n");
|
|
return;
|
|
}
|
|
|
|
std::vector<BatchRange> ranges;
|
|
ranges.reserve(areas.size());
|
|
for (const auto& a : areas)
|
|
ranges.push_back({a.second.startSeconds, a.second.endSeconds});
|
|
const std::vector<CaptureUnit> plan = planCaptureUnits(ranges);
|
|
|
|
BatchOutcome outcome;
|
|
bool anyAdded = false;
|
|
{
|
|
// 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);
|
|
if (std::string g = guidString(tr); !g.empty())
|
|
src.trackGuids.push_back(std::move(g));
|
|
|
|
const std::string baseName = "razor-" + std::to_string(unit.ordinal);
|
|
CaptureResult res = captureAndIndexOne(
|
|
session, CaptureScope::Track, src, baseName,
|
|
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;
|
|
}
|
|
} // 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((outcome.summaryLine("razor area") + "\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<std::string> 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<model::CaptureRecipe> 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<MediaTrack*> 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<SourceMode>(recipe->sourceMode);
|
|
req.startSeconds = recipe->startSeconds;
|
|
req.endSeconds = recipe->endSeconds;
|
|
req.wetDry = 1.0;
|
|
req.tailMode = static_cast<TailMode>(recipe->tailMode);
|
|
req.tailMs = recipe->tailMs;
|
|
req.sampleRate = recipe->sampleRate;
|
|
req.channelCount = recipe->channelCount;
|
|
req.bitDepth = WavBitDepth::Float32;
|
|
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<MediaItem*> items;
|
|
items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
|
|
for (int i = 0; i < n; ++i) {
|
|
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
|
if (it) items.push_back(it);
|
|
}
|
|
currentIdentity = fxChainIdentityForItems(items);
|
|
} else {
|
|
std::vector<std::string> 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
|