8331df2e91
The millisecond floor lives in the custom-bounds field, not the engine, so RENDER_BOUNDSFLAG=2 is now the only bounds mode: the two-position type, the console verdict and the STARTPOS/ENDPOS drift probe all go. capture.cpp 697 -> 622.
523 lines
27 KiB
C++
523 lines
27 KiB
C++
// See capture_orchestrator.h. FxBypassGuard lives here as a stack RAII object —
|
|
// it must restore on every exit path of exactly one render call.
|
|
//
|
|
// 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_orchestrator.h"
|
|
|
|
#include <optional>
|
|
#include <vector>
|
|
|
|
#include "shell/panel/panel_input.h" // bankPanelTailSetting / bankPanelRefresh
|
|
#include "core/capture/tail_control.h" // TailSetting
|
|
#include "core/model/provenance.h" // model::Provenance
|
|
#include "shell/actions/ingest.h" // ingestAssignActiveInstance
|
|
#include "shell/persist/session.h" // ReaSamplerSession
|
|
#include "shell/capture/insert.h" // runInsert / InsertRequest
|
|
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
|
|
#include "shell/capture/render_selection.h" // RenderTrackSelection
|
|
#include "shell/capture/render_isolation.h" // UpstreamIsolation
|
|
|
|
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
|
|
|
|
#define REAPERAPI_MINIMAL
|
|
#define REAPERAPI_WANT_EnumProjects
|
|
#define REAPERAPI_WANT_GetParentTrack
|
|
#define REAPERAPI_WANT_GetMasterTrack
|
|
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
|
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
|
#define REAPERAPI_WANT_ShowConsoleMsg
|
|
#define REAPERAPI_WANT_Undo_BeginBlock2
|
|
#define REAPERAPI_WANT_Undo_EndBlock2
|
|
#include "reaper_plugin_functions.h"
|
|
|
|
namespace reasampler::capture {
|
|
|
|
namespace {
|
|
|
|
// --- FX-bypass + full parent-chain neutralize around render (RAII, non-destr.) --
|
|
// For every track a scope must NOT hear the FX of, this ALSO neutralizes that
|
|
// track's fader gain AND its full pan chain (pan/width/law/mode) for the render —
|
|
// because a Track/Item capture renders via master and would otherwise sum through
|
|
// the parent/folder/master FADERS and PAN/WIDTH/LAW, printing their gain and pan
|
|
// coloring into the file (Daniel: the capture is likely re-routed through that
|
|
// same chain later, so parent/master level and pan must not be baked in). The
|
|
// neutralize set is IDENTICAL to the FX-bypass set:
|
|
// Item -> own track + all ancestors + master (take vol/pan kept: item content).
|
|
// Track -> all ancestors + master (selected track's OWN vol/pan kept).
|
|
// (Master is a bypass TARGET for both scopes — never a scope of its own.)
|
|
//
|
|
// Per track in that set we snapshot & set the full parent-chain-independence set,
|
|
// so a Track/Item capture is uncolored by the parent/folder/master it renders
|
|
// through — no FX, no fader, and no pan/width/law/mode coloring:
|
|
// I_FXEN -> 0 (FX bypassed; SDK ~2194)
|
|
// D_VOL -> 1.0 (unity trim volume; SDK ~2226 "1=+0dB")
|
|
// D_PAN -> 0.0 (center; SDK ~2227 "trim pan of track, -1..1")
|
|
// D_WIDTH -> 1.0 (full/neutral stereo width; SDK ~2228 "width, -1..1",
|
|
// 1.0 = full width = no narrowing/collapse)
|
|
// D_PANLAW -> 1.0 (no coloring; SDK ~2232 "1=+0dB" — pan-law applies no gain)
|
|
// I_PANMODE -> 5 (stereo pan; SDK ~2231 "0=classic,3=balance,5=stereo,6=dual")
|
|
// All are restored to their ORIGINAL values on EVERY exit path (RAII).
|
|
//
|
|
// Why also force I_PANMODE (pan mode). D_PAN's effect is mode-dependent. In modes
|
|
// 0/3/5, D_PAN=0 + D_WIDTH=1 is a provable pass-through. But in mode 6 (dual pan)
|
|
// D_PAN/D_WIDTH are ignored — routing is governed instead by D_DUALPANL/D_DUALPANR
|
|
// (SDK ~2229-2230, live only when I_PANMODE==6), whose neutral pass-through the
|
|
// header does not state as such. Rather than snapshot two more mode-conditional
|
|
// params and infer their neutral values, we force I_PANMODE=5 (stereo pan) for the
|
|
// render, where D_PAN=0 + D_WIDTH=1 is unambiguously uncolored, then restore the
|
|
// original mode. This fully neutralizes pan for every original mode with no
|
|
// residual — the "handle it fully" the brief requires. (See Snap dual-pan note.)
|
|
//
|
|
// Structurally non-destructive: no takes, no items, no project restructuring —
|
|
// only transient FX-enable + trim-volume toggles, always restored.
|
|
class FxBypassGuard
|
|
{
|
|
public:
|
|
// scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose
|
|
// ancestor chains (walked via GetParentTrack) + the master are bypassed per the
|
|
// plan. proj is the active project (for GetMasterTrack).
|
|
FxBypassGuard(CaptureScope scope,
|
|
const std::vector<MediaTrack*>& sourceTracks,
|
|
ReaProject* proj)
|
|
{
|
|
const FxBypassPlan plan = fxBypassPlanFor(scope);
|
|
|
|
for (MediaTrack* tr : sourceTracks)
|
|
{
|
|
if (!tr) continue;
|
|
if (plan.bypassSelfFx) bypass(tr);
|
|
if (plan.bypassAncestorFx)
|
|
{
|
|
// Walk parents to the top: GetParentTrack returns the immediate
|
|
// parent (folder) track, nullptr at the outermost level (SDK
|
|
// header ~2407). The master is NOT returned here — handled below.
|
|
for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p))
|
|
bypass(p);
|
|
}
|
|
}
|
|
if (plan.bypassMaster)
|
|
{
|
|
// GetMasterTrack(proj) -> the master track (SDK header ~1925). bypass()
|
|
// neutralizes its FX (I_FXEN), gain (D_VOL) AND pan/width/law/mode on it
|
|
// just like any other in-scope track; only the master's summing/routing
|
|
// topology (the mix bus itself) remains — that is not a per-track param.
|
|
if (MediaTrack* master = GetMasterTrack(proj)) bypass(master);
|
|
}
|
|
}
|
|
|
|
~FxBypassGuard()
|
|
{
|
|
// Restore in reverse for symmetry (order is not load-bearing — each track
|
|
// appears once, snapshots are independent). EVERY snapshotted param is
|
|
// restored to its ORIGINAL value on this (every) exit path. Restore
|
|
// I_PANMODE before the pan values so any mode-conditional params (e.g. dual
|
|
// pan) settle under the original mode.
|
|
for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it)
|
|
{
|
|
SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen);
|
|
SetMediaTrackInfo_Value(it->track, "D_VOL", it->vol);
|
|
SetMediaTrackInfo_Value(it->track, "I_PANMODE", it->panmode);
|
|
SetMediaTrackInfo_Value(it->track, "D_PAN", it->pan);
|
|
SetMediaTrackInfo_Value(it->track, "D_WIDTH", it->width);
|
|
SetMediaTrackInfo_Value(it->track, "D_PANLAW", it->panlaw);
|
|
}
|
|
}
|
|
|
|
FxBypassGuard(const FxBypassGuard&) = delete;
|
|
FxBypassGuard& operator=(const FxBypassGuard&) = delete;
|
|
|
|
private:
|
|
// One snapshot per bypassed track: all params we neutralize, at their originals.
|
|
// panmode captures I_PANMODE so we can force stereo-pan for the render and put
|
|
// the original mode back — which also makes D_DUALPANL/D_DUALPANR (live only when
|
|
// I_PANMODE==6, SDK ~2229-2230) irrelevant during the render without us having to
|
|
// touch or guess neutral values for them.
|
|
struct Snap
|
|
{
|
|
MediaTrack* track;
|
|
double fxen;
|
|
double vol;
|
|
double pan;
|
|
double width;
|
|
double panlaw;
|
|
double panmode;
|
|
};
|
|
std::vector<Snap> snapshots_;
|
|
|
|
// Snapshot every neutralized param once per track (dedup: an ancestor shared by
|
|
// two selected tracks must be restored to its ORIGINAL values, not to a
|
|
// re-snapshot of the already-neutralized state), then read ALL originals, push
|
|
// one Snap, and set all to neutral — bypass FX, unity gain, uncolored pan chain.
|
|
void bypass(MediaTrack* tr)
|
|
{
|
|
for (const Snap& s : snapshots_) if (s.track == tr) return; // already done
|
|
// Read ALL originals first (atomic snapshot), then push, then neutralize.
|
|
const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN");
|
|
const double vol = GetMediaTrackInfo_Value(tr, "D_VOL");
|
|
const double pan = GetMediaTrackInfo_Value(tr, "D_PAN");
|
|
const double width = GetMediaTrackInfo_Value(tr, "D_WIDTH");
|
|
const double panlaw = GetMediaTrackInfo_Value(tr, "D_PANLAW");
|
|
const double panmode = GetMediaTrackInfo_Value(tr, "I_PANMODE");
|
|
snapshots_.push_back({tr, fxen, vol, pan, width, panlaw, panmode});
|
|
SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194)
|
|
SetMediaTrackInfo_Value(tr, "D_VOL", 1.0); // 1.0 = unity gain (SDK ~2226)
|
|
SetMediaTrackInfo_Value(tr, "I_PANMODE", 5.0); // 5 = stereo pan (SDK ~2231)
|
|
SetMediaTrackInfo_Value(tr, "D_PAN", 0.0); // 0.0 = center (SDK ~2227)
|
|
SetMediaTrackInfo_Value(tr, "D_WIDTH", 1.0); // 1.0 = full width (SDK ~2228)
|
|
SetMediaTrackInfo_Value(tr, "D_PANLAW", 1.0); // 1.0 = +0dB, no law (SDK ~2232)
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// 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.
|
|
CaptureResult renderOffline(CaptureScope scope,
|
|
const std::vector<MediaTrack*>& sourceTracks,
|
|
const CaptureRequest& req)
|
|
{
|
|
// Refused BEFORE anything is touched, so the refusal path has nothing to
|
|
// restore. This is the seam BOTH a fresh capture and a recipe replay cross, so
|
|
// neither can land the multi-stem render the predicate names — and both scopes
|
|
// reach it, so a track capture and a ranged item capture refuse alike.
|
|
if (isMultiTrackStemRender(req.sourceMode,
|
|
static_cast<int>(sourceTracks.size())))
|
|
{
|
|
CaptureResult refused;
|
|
refused.status = CaptureStatus::MultiTrackSelection;
|
|
refused.message = multiTrackRefusalMessage(scope);
|
|
return refused;
|
|
}
|
|
|
|
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
|
// A selected-tracks render prints the DAW's track selection, so the request's
|
|
// own tracks must BE that selection for the render — the plain track scope
|
|
// already resolved them from the live selection (identity), but a ranged item
|
|
// capture and a re-capture-from-source both name tracks the user has not
|
|
// selected. Every guard below outlives the render call and restores on every path.
|
|
std::optional<RenderTrackSelection> selection;
|
|
std::optional<UpstreamIsolation> isolation;
|
|
if (req.sourceMode == SourceMode::SelectedTracks)
|
|
{
|
|
selection.emplace(sourceTracks);
|
|
// An ITEM capture routed through the tracks source would otherwise print
|
|
// everything upstream of the track — folder children, receives — which the
|
|
// selected-items source excluded. The refusal above is what makes the single
|
|
// source track here the whole set. Track scope is left alone: a folder
|
|
// parent's own output IS its children summed.
|
|
if (scope == CaptureScope::Item && !sourceTracks.empty())
|
|
isolation.emplace(sourceTracks.front());
|
|
}
|
|
FxBypassGuard fxGuard(scope, sourceTracks, proj);
|
|
OfflineRenderBackend backend;
|
|
return backend.capture(req);
|
|
}
|
|
|
|
// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance,
|
|
// and adds the resulting Sample to the ACTIVE bank + records the created file in the
|
|
// tracking ledger — WITHOUT persisting. The caller persists once (single-capture:
|
|
// right after; batch: once at the end) so a batch does not write ext state N times.
|
|
//
|
|
// Provenance is read from the LIVE selection here, so a batch that transiently
|
|
// selects exactly one item per unit gets per-unit-correct provenance. `src` supplies
|
|
// the source tracks (FX bypass + Sample GUIDs); `scope` drives the bypass plan and
|
|
// provenance scope. Returns the backend's CaptureResult (status + message) so the
|
|
// caller can report success/failure. Load-bearing principle holds: writes a file +
|
|
// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the
|
|
// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard),
|
|
// and the backend restores every RENDER_* setting it changed plus the project time
|
|
// selection it borrowed to carry the render window.
|
|
//
|
|
// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id
|
|
// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8
|
|
// capture+assign path can target the sample actually in the bank. Batch callers ignore
|
|
// it; the plain capture actions are unaffected.
|
|
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
|
CaptureScope scope,
|
|
const ResolvedSource& src,
|
|
const CaptureName& name,
|
|
double startSeconds,
|
|
double endSeconds)
|
|
{
|
|
// The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action
|
|
// variant: the capture actions apply whatever the panel is set to. Default is None
|
|
// (exact bounds / byte-identical to today) until the user opts in via the toggle.
|
|
const TailSetting tail = bankPanelTailSetting();
|
|
|
|
CaptureRequest req;
|
|
req.sourceMode = sourceModeForScope(scope, src.itemExtentIsWindow);
|
|
req.startSeconds = startSeconds; // exact bounds — no rounding
|
|
req.endSeconds = endSeconds;
|
|
req.wetDry = 1.0; // wet post the FX left enabled by the scope
|
|
req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle
|
|
req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto
|
|
req.sampleRate = 0; // follow project rate
|
|
req.channelCount = 2;
|
|
req.bitDepth = WavBitDepth::Float32; // deterministic, no dither
|
|
req.baseName = name.stemBase;
|
|
req.displayName = name.label;
|
|
req.trackGuids = src.trackGuids; // recorded on the Sample (provenance)
|
|
|
|
// 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<model::Provenance> prov =
|
|
buildCaptureProvenance(session.book(), req, scope, src);
|
|
|
|
// Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain
|
|
// neutralized for the render, fully restored on every path). Writes a file only.
|
|
CaptureResult res = renderOffline(scope, src.sourceTracks, req);
|
|
if (res.status != CaptureStatus::Ok)
|
|
return res;
|
|
|
|
// 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: session.bank() resolves to book.activeIndex() (B2). The
|
|
// AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can
|
|
// target the sample actually in the bank (the existing entry on a collapse).
|
|
const model::AddResult addResult = session.bank().add(res.sample);
|
|
session.recordCreated(res.sample, tracking::OriginKind::Capture);
|
|
|
|
// Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new
|
|
// id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a
|
|
// Collapsed (the file we just rendered deduped onto an already-present sample — assign
|
|
// THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged.
|
|
if (addResult == model::AddResult::Collapsed && !res.sample.contentHash.empty())
|
|
{
|
|
if (const model::Sample* existing =
|
|
session.bank().findByHash(res.sample.contentHash))
|
|
res.sample.id = existing->id;
|
|
}
|
|
return res;
|
|
}
|
|
|
|
// Runs one capture-action-table row: resolve its scope source + range, render + add +
|
|
// record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle
|
|
// holds structurally — this path writes a file + a bank index entry ONLY; it never
|
|
// calls InsertMedia or touches the arrange/timeline.
|
|
// Returns the bank-index id of the sample the capture landed on: the newly-added id on a
|
|
// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign
|
|
// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange
|
|
// capture+assign path reads this to write an assignment request; the plain capture actions
|
|
// ignore it (their behaviour is unchanged — capture still writes a file + index entry only).
|
|
std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def)
|
|
{
|
|
ResolvedSource src;
|
|
std::string why;
|
|
if (!ResolveScopeSource(def.scope, src, why))
|
|
{
|
|
ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
|
|
return {};
|
|
}
|
|
|
|
// The scope literal survives only as the fallback for a source whose name could not
|
|
// be read at all — the source track names the capture on every reachable path.
|
|
const CaptureName name =
|
|
captureNameFor(src.trackNames, /*ordinal=*/0, def.baseName);
|
|
|
|
CaptureResult res = captureAndIndexOne(session, def.scope, src, name,
|
|
src.startSeconds, src.endSeconds);
|
|
if (res.status != CaptureStatus::Ok)
|
|
{
|
|
ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str());
|
|
return {};
|
|
}
|
|
|
|
// captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE
|
|
// bank, and recorded the created file in the tracking ledger (WITHOUT persisting).
|
|
// Persist the updated book AND ledger into the active project's ext state (the
|
|
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
|
|
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
|
|
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
|
|
// S9: a capture add is a bank-content change -> bump before the persist so an assigned
|
|
// live instance refreshes hands-free (the S8 capture+assign path builds on this).
|
|
session.bumpBankGeneration();
|
|
session.saveToActiveProject();
|
|
|
|
// Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne
|
|
// resolved res.sample.id to the fresh id on a new add or the existing entry's id on a
|
|
// hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above).
|
|
return res.sample.id;
|
|
}
|
|
|
|
// S8 arrange ingest: capture the selected item / time-selection into the active bank
|
|
// (reusing the Item-scope capture path verbatim) and, on success, write an assignment
|
|
// request so the active sampler instance plays the new sample on its next reload. The
|
|
// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER
|
|
// inserts a timeline item (load-bearing principle); the only addition here is the
|
|
// bank-index-id -> assignment-request write after the sample lands. If the capture
|
|
// failed / no-op'd (empty id), no assignment is written (nothing to assign).
|
|
//
|
|
// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the
|
|
// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are
|
|
// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically.
|
|
// An undo that removes the captured sample also clears the assign_request that named it,
|
|
// preventing a stale request from pointing at a removed sample. The block uses the house
|
|
// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero
|
|
// flag) matching the bank-op family (persistBankOp, panel_bank_ops).
|
|
void RunCaptureItemAssign(ReaSamplerSession& session)
|
|
{
|
|
// Reuse the Item-scope def from the capture table (index 0) — same range logic, same
|
|
// FX-scope neutralize, same bank/persist landing as the plain "capture item" action.
|
|
Undo_BeginBlock2(nullptr);
|
|
|
|
const std::string sampleId = RunCapture(session, captureActionTable()[0]);
|
|
if (sampleId.empty())
|
|
{
|
|
// Capture failed or no-op'd — RunCapture already reported. Discard the empty point.
|
|
Undo_EndBlock2(nullptr, "", 0);
|
|
return;
|
|
}
|
|
|
|
// Assign inside the same block so undo clears both keys together.
|
|
ingestAssignActiveInstance(session.book().activeBankId(), sampleId);
|
|
Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance",
|
|
UNDO_STATE_MISCCFG);
|
|
|
|
bankPanelRefresh();
|
|
ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active "
|
|
"instance.\n");
|
|
}
|
|
|
|
// STARTS the REALTIME track capture and returns immediately — the record runs across
|
|
// timer ticks (DriveRealtimeCapture in realtime_lifecycle), so REAPER's UI stays
|
|
// responsive. Resolves the selected tracks + the range (razor-else-time, the same
|
|
// orthogonal range logic as the offline scopes) and starts recording each selected
|
|
// track's OWN output into a hidden temp track via RealtimeRecordBackend::begin (a
|
|
// send FROM each source track INTO the temp — see capture_realtime_shell.cpp §TAP);
|
|
// OnTimer drives it to completion, then adds the Sample and persists. TRACK scope
|
|
// only this increment (item realtime is deferred). Dialog-free. Non-bit-identical
|
|
// by nature (it is realtime) — offline stays the deterministic default.
|
|
// FxBypassGuard is NOT used here — the track-output tap is PRE-parent by
|
|
// construction (§TAP), so there is no live chain to neutralize. The load-bearing
|
|
// principle holds structurally — this writes a file + a bank entry ONLY; the temp
|
|
// track is a transient sink removed by the backend, nothing lands in arrange.
|
|
//
|
|
// A SECOND realtime capture requested while one is in progress is REJECTED — the
|
|
// first keeps running (we own the transport for its window; starting a second would
|
|
// collide on the transport and the temp-track/arm snapshot).
|
|
void RunCaptureRealtimeTrack(ReaSamplerSession& session)
|
|
{
|
|
(void)session; // start path persists nothing — commit happens on the terminal tick
|
|
if (g_rtCapture)
|
|
{
|
|
ShowConsoleMsg("ReaSampler realtime capture: a capture is already in "
|
|
"progress -- let it finish (or stop the transport) first.\n");
|
|
return;
|
|
}
|
|
|
|
// Resolve the selected tracks + range exactly as the offline Track scope does.
|
|
// No track selected -> refuse (same no-op as offline track scope).
|
|
ResolvedSource src;
|
|
std::string why;
|
|
if (!ResolveScopeSource(CaptureScope::Track, src, why))
|
|
{
|
|
ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str());
|
|
return;
|
|
}
|
|
|
|
// The tail mode is the SAME panel setting the offline capture actions read (the
|
|
// docked bank panel's toggle). Realtime honors it via a parallel path: the backend
|
|
// records a generous window past the range end, then trims by PCM decay-scan (T2 /
|
|
// capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None
|
|
// keeps realtime exact-bounds / byte-identical to today.
|
|
const TailSetting tail = bankPanelTailSetting();
|
|
|
|
CaptureRequest req;
|
|
req.sourceMode = SourceMode::SelectedTracks; // realtime track scope
|
|
req.startSeconds = src.startSeconds; // exact bounds — no rounding
|
|
req.endSeconds = src.endSeconds;
|
|
req.wetDry = 1.0; // fully wet (post-fader tap)
|
|
req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle
|
|
req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto
|
|
req.sampleRate = 0; // follow project rate
|
|
req.channelCount = 2;
|
|
req.bitDepth = WavBitDepth::Float32;
|
|
const CaptureName name =
|
|
captureNameFor(src.trackNames, /*ordinal=*/0, "realtime");
|
|
req.baseName = name.stemBase;
|
|
req.displayName = name.label;
|
|
req.trackGuids = src.trackGuids; // provenance on the Sample
|
|
|
|
CaptureResult failure;
|
|
RealtimeCaptureHandle st = g_rtBackend.begin(req, src.sourceTracks, failure);
|
|
if (!st)
|
|
{
|
|
// begin() validated/failed and already restored anything it touched.
|
|
ShowConsoleMsg(("ReaSampler realtime capture failed: " + failure.message + "\n").c_str());
|
|
return;
|
|
}
|
|
|
|
// Started. Store the in-flight state + its project; OnTimer drives it to
|
|
// completion across ticks (UI stays responsive).
|
|
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
|
|
g_rtCapture = std::move(st);
|
|
}
|
|
|
|
// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates
|
|
// via abort() — stop the transport + restore ALL snapshotted state (non-destructive),
|
|
// committing whatever audio was already captured (best effort) so a cancel near the end
|
|
// still keeps the take. Runs only against the record's OWN project (abort() self-guards
|
|
// the closed-project case, review §1). No-op with a note when nothing is in flight.
|
|
void RunCancelRealtime(ReaSamplerSession& session)
|
|
{
|
|
if (!g_rtCapture)
|
|
{
|
|
ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n");
|
|
return;
|
|
}
|
|
RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
|
|
if (r.status == RealtimeTickStatus::Done)
|
|
CommitRealtimeResult(session, r.result); // Ok: keep what was captured up to the cancel
|
|
else
|
|
ShowConsoleMsg(("ReaSampler realtime capture cancelled -- " +
|
|
r.result.message + "\n").c_str());
|
|
g_rtCapture.reset();
|
|
g_rtCaptureProject = nullptr;
|
|
}
|
|
|
|
// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor
|
|
// via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match
|
|
// variant (never silent — it fires only from the distinct "conform" action). This
|
|
// is the INTENDED placement path: it adds items to the arrange on purpose
|
|
// (CLAUDE.md §The load-bearing principle) and runs only from a user-invoked action.
|
|
void RunInsertSelected(ReaSamplerSession& session, bool conform)
|
|
{
|
|
InsertRequest req;
|
|
// target defaults to CurrentTrack (InsertOptions::target) — inserts onto the
|
|
// user's currently-selected track(s) at the edit cursor.
|
|
req.options.conform = conform ? TempoConform::Ratio1x : TempoConform::None;
|
|
// preservePitch stays true: a tempo conform matches tempo without varispeeding
|
|
// pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.)
|
|
|
|
InsertResult res = runInsert(&session, req);
|
|
|
|
switch (res.status)
|
|
{
|
|
case InsertStatus::Ok:
|
|
break; // success — no console chatter
|
|
case InsertStatus::NoSelection:
|
|
// "select a track first" is printed by runInsert when no track is
|
|
// selected; this branch covers the no-panel-selection case.
|
|
ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n");
|
|
break;
|
|
case InsertStatus::NoProject:
|
|
ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n");
|
|
break;
|
|
case InsertStatus::NothingResolved:
|
|
ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n");
|
|
break;
|
|
}
|
|
}
|
|
|
|
} // namespace reasampler::capture
|