Files
reasampler/src/main.cpp
T

936 lines
46 KiB
C++

// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
//
// This file is the entire contract between REAPER and the extension:
// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and
// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry
// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro).
// * REAPER calls it, handing over `rec` — a small dispatch struct.
// - rec->GetFunc(name) resolves any REAPER API function to a pointer
// - rec->Register(what,ptr) plugs OUR callbacks into REAPER
// * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and
// fills in every global function pointer (ShowConsoleMsg, InsertMedia...).
//
// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates
// storage for those global pointers. Every other .cpp includes
// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations.
#define REAPERAPI_IMPLEMENT
#include "reaper_plugin.h"
#include "reaper_plugin_functions.h"
#include <memory>
#include <string>
#include <vector>
#include "actions.h"
#include "bank_model.h"
#include "bank_panel.h"
#include "capture.h"
#include "insert.h"
#include "persist.h"
#include "render_settings.h"
#include "track_guid.h"
#include "view.h"
// Persistent action-id prefix for the ReaSampler action family.
// Every bindable action (capture / insert / slot / verify) mints its command id
// from a string beginning with this prefix, e.g. "CEREBELLUM_REASAMPLER_CAPTURE_TRACK".
// FOREVER-STABLE once shipped: user keybindings key off these strings, so the
// prefix and any minted id must never change after release.
#define REASAMPLER_ACTION_PREFIX "CEREBELLUM_REASAMPLER_"
// Globals other files reference via `extern`.
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
// ---- Capture action family (two FX scopes) ---------------------------------
// Two bindable SCOPE actions from captureActionTable() (render_settings, pure):
// capture item / track. Each infers its range (razor-else-time) and enforces the
// FX-scope invariant via FX-bypass-around-render (FxBypassGuard):
// Item -> take/item FX only (bypass the item's track + ancestors + master).
// Track -> item FX + track's own FX (bypass ancestors + master).
// There is NO master scope — to capture the master you render a track. (The master
// track's FX/gain/pan are STILL neutralized for both scopes as the out-of-scope
// chain — master is a bypass target, not a capture scope.) The retired M7
// CAPTURE_TRACKS_WET / CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids AND the removed
// CAPTURE_MASTER / CAPTURE_MASTER_REALTIME ids are mirror-unregistered on unload so
// old keybindings clear cleanly.
//
// The minted command ids parallel the table rows 1:1 (same index). gaccel storage
// must outlive registration (REAPER holds each pointer), so both vectors are file-
// scope and sized to the table. FOREVER-STABLE id strings live in the table.
static std::vector<int> g_captureCmdIds;
static std::vector<gaccel_register_t> g_captureAccels;
// Retired capture-action command-id strings. Kept ONLY to mirror-unregister them on
// unload so a user's stale keybindings are cleaned up. Never re-register these.
// * The M7 four-mode ids (tracks/items/razor WET).
// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the
// master realtime action are REMOVED (capture is now item + track only; realtime
// taps the selected track). Their shipped ids are retired so old keybindings clear.
// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants
// are REMOVED; tail is now a panel-setting toggle, not a paired action. Retired so
// old keybindings clear.
static const char* const kRetiredCaptureCmdStrings[] = {
"CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET",
"CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET",
"CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET",
"CEREBELLUM_REASAMPLER_CAPTURE_MASTER",
"CEREBELLUM_REASAMPLER_CAPTURE_MASTER_REALTIME",
"CEREBELLUM_REASAMPLER_CAPTURE_ITEM_TAIL",
"CEREBELLUM_REASAMPLER_CAPTURE_TRACK_TAIL",
};
// Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string.
// The docked grid window is display-only this wave (Wave A) — the action just
// shows/hides it; it never captures, inserts, or mutates the bank.
static int g_cmdToggleBankPanel = 0;
// Command ids for the M6 insert actions. FOREVER-STABLE strings. Two variants that
// differ ONLY in the InsertOptions they build: the default inserts at native length
// (no stretch, no conform); the "conform" variant is the EXPLICIT opt-in to REAPER's
// try-to-match-project-tempo path (CONTEXT.md §insert: conform is opt-in, never
// silent). Both read the bank panel's current selection and place at the edit cursor.
static int g_cmdInsertSelected = 0;
static int g_cmdInsertSelectedConform = 0;
// Command id for the "capture selected track (realtime)" action. NEW FOREVER-STABLE
// string. Records the selected track's OWN output in realtime (transport-driven) into
// a hidden temp track via RealtimeRecordBackend, then moves the recorded file into the
// bank. The realtime SIBLING of the offline CAPTURE_TRACK scope action: same range
// logic (razor-else-time), same track selection, same bank/persist path, different
// backend. Dialog-free. (Replaces the removed CAPTURE_MASTER_REALTIME action.)
static int g_cmdCaptureTrackRealtime = 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
// the transport-stop. No-op (with a note) when nothing is in flight.
static int g_cmdCancelRealtime = 0;
// The persistence session (M4): owns the in-memory BankIndex and bridges it to
// project ext state. A timer tick drives g_session.poll() to detect project
// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to
// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize
// the book back into the active project's ext state (the `banks` key) so it travels
// with the .rpp. Replaces the M3 session-only g_bank.
static reasampler::ReaSamplerSession g_session;
// --- M8 in-flight realtime capture (async, timer-driven) --------------------
// A realtime record spans many timer ticks (it takes end-start wall-clock seconds
// and must NOT block REAPER's UI). The action STARTS it (g_rtBackend.begin), which
// returns immediately with the in-flight state owned here; OnTimer drives it
// (g_rtBackend.tick) each tick until a terminal verdict; then this pointer is
// cleared. Non-null == a capture is in progress (used to reject a second one, and to
// abort on project switch / unload).
static reasampler::RealtimeRecordBackend g_rtBackend;
static reasampler::RealtimeCaptureHandle g_rtCapture;
// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets
// OnTimer detect a project switch mid-capture and abort+restore rather than leak the
// temp track/arm/transport into or across projects. Only meaningful when
// g_rtCapture != nullptr.
static ReaProject* g_rtCaptureProject = nullptr;
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
// Sample to the ACTIVE bank (g_session.bank() resolves to book.activeIndex() — B2),
// persist + MarkProjectDirty, log. Shared by the tick-completion path and the abort
// paths. On a non-Ok result, logs the failure only.
static void CommitRealtimeResult(const reasampler::CaptureResult& res)
{
if (res.status != reasampler::CaptureStatus::Ok)
{
ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str());
return;
}
reasampler::AddResult added = g_session.bank().add(res.sample);
g_session.saveToActiveProject(); // persist + MarkProjectDirty (travels with .rpp)
std::string log = "ReaSampler: " + res.message + "\n";
log += " bank size now " + std::to_string(g_session.bank().size()) +
(added == reasampler::AddResult::Added ? " (added)\n"
: added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n"
: " (rejected)\n");
ShowConsoleMsg(log.c_str());
}
// Advance any in-flight realtime capture one tick. Cheap when none is running (a
// null check) and fast even mid-record (tick() only reads the transport until the
// terminal tick). Detects a project switch mid-capture and aborts+restores so the
// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so
// poll's project-switch handling sees a cleaned-up project.
static void DriveRealtimeCapture()
{
if (!g_rtCapture) return;
// Project switch guard: if the active project is no longer the one the capture
// belongs to, a new/other project became active mid-record — abort + restore
// (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize
// into the new project.
ReaProject* active = EnumProjects(-1, nullptr, 0);
if (active != g_rtCaptureProject)
{
reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
// Only commit if the ORIGINAL project is still open and active would be it —
// on a switch we restored into the original but must not persist into the
// now-active foreign project. Log the outcome without persisting. On a Failed
// abort surface abort()'s own message — it distinguishes a clean tab-switch
// abort from the closed-project DROP (the captured project was closed mid-record,
// review §1: nothing restored because the pointers were already freed).
if (r.status == reasampler::RealtimeTickStatus::Done)
ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record — "
"captured audio restored into the original project; not "
"persisted to avoid crossing projects.\n");
else
ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record — " +
r.result.message + "\n").c_str());
g_rtCapture.reset();
g_rtCaptureProject = nullptr;
return;
}
reasampler::RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture);
if (r.status == reasampler::RealtimeTickStatus::InProgress) return;
// Terminal (Done or Failed): commit/log and drop the in-flight state.
CommitRealtimeResult(r.result);
g_rtCapture.reset();
g_rtCaptureProject = nullptr;
}
// The timer callback REAPER runs periodically (registered via "timer"). It only
// forwards to the session poll — cheap per tick (reads the active project id and
// its .rpp path, acts only on a change).
static void OnTimer()
{
// Advance any in-flight realtime capture FIRST, so a project switch is caught and
// the capture torn down/restored before session.poll() reacts to that switch.
DriveRealtimeCapture();
g_session.poll();
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view
// model but deliberately does NOT apply visibility — that would couple persist
// to the view shell). Instead poll() raises a one-shot load signal; here — the
// integration layer that already drives both persist and the view shell — we
// drain it and reapply the SAVED active mode's visibility/processing so opening a
// project saved in Design mode parks the Arrange tracks automatically, no manual
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
if (g_session.consumeLoadSignal())
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
// Reflect a live bank change (capture / project load) in the docked grid.
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
// an actual change. No-op when the panel is closed.
reasampler::bankPanelRefresh();
}
// --- Scope-action source resolution -----------------------------------------
// The three scope actions (item / track / master) each resolve to (1) an exact
// render range in project seconds — razor-else-time, inferred here — and (2) the
// set of source TRACKS whose ancestor chains drive the FX-bypass plan. All reads
// are non-destructive: selection, razor, and time selection are read, never
// mutated. Returning false means "nothing to capture" (empty selection / no
// range); the caller reports it and writes nothing.
// The resolved source: exact bounds + the source tracks (for FX-bypass + Sample
// provenance GUIDs). `sourceTracks` holds the item-owning tracks (Item scope) or the
// selected tracks (Track scope).
struct ResolvedSource
{
double startSeconds = 0.0;
double endSeconds = 0.0;
std::vector<MediaTrack*> sourceTracks; // item-owning tracks / selected tracks
std::vector<std::string> trackGuids; // canonical GUIDs of sourceTracks
};
// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false,
// isLoop=false) reads the current time selection.
static bool resolveTimeSelection(double& start, double& end)
{
start = 0.0; end = 0.0;
GetSet_LoopTimeRange(false, false, &start, &end, false);
return end > start;
}
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits),
// and returns the union bound. Reads only — never clears the razor selection.
// Returns false when no track-audio razor area exists on any track.
static bool resolveRazorRange(double& start, double& end)
{
std::vector<reasampler::RazorRange> allRanges;
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;
std::vector<reasampler::RazorRange> ranges =
reasampler::parseRazorEdits(std::string(buf.data()));
for (auto& r : ranges) allRanges.push_back(r);
}
if (allRanges.empty()) return false;
reasampler::RazorRange u = reasampler::razorUnionBounds(allRanges);
start = u.startSeconds;
end = u.endSeconds;
return end > start;
}
// Infers the render RANGE for any scope: razor union when a razor area is present,
// else the time selection (pure inferRangeSource decides which). Orthogonal to
// scope. Returns false (with a reason) when neither yields a non-empty range.
static bool resolveRange(double& start, double& end, std::string& why)
{
double rzStart = 0.0, rzEnd = 0.0;
const bool hasRazor = resolveRazorRange(rzStart, rzEnd);
if (reasampler::inferRangeSource(hasRazor) == reasampler::RangeSource::Razor)
{
start = rzStart; end = rzEnd;
return true; // resolveRazorRange already verified end > start
}
if (resolveTimeSelection(start, end)) return true;
why = "make a razor area or a time selection first";
return false;
}
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
static bool collectSelectedTracks(ResolvedSource& out)
{
const int n = CountSelectedTracks(nullptr); // nullptr = active project
if (n <= 0) return false;
for (int i = 0; i < n; ++i)
{
MediaTrack* tr = GetSelectedTrack(nullptr, i);
if (!tr) continue;
out.sourceTracks.push_back(tr);
std::string g = reasampler::guidString(tr);
if (!g.empty()) out.trackGuids.push_back(std::move(g));
}
return !out.sourceTracks.empty();
}
// Collects the tracks that own the selected items (Item scope) into
// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an
// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning
// track (SDK header, verify). GUIDs recorded for provenance.
static bool collectSelectedItemTracks(ResolvedSource& out)
{
const int n = CountSelectedMediaItems(nullptr);
if (n <= 0) return false;
for (int i = 0; i < n; ++i)
{
MediaItem* it = GetSelectedMediaItem(nullptr, i);
if (!it) continue;
MediaTrack* tr = GetMediaItem_Track(it);
if (!tr) continue;
// Dedup: several selected items can share a track.
bool seen = false;
for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; }
if (seen) continue;
out.sourceTracks.push_back(tr);
std::string g = reasampler::guidString(tr);
if (!g.empty()) out.trackGuids.push_back(std::move(g));
}
return !out.sourceTracks.empty();
}
// Resolves the source for a scope: the selection tracks (item/track), plus the
// inferred range. Returns false with a reason on nothing to do.
static bool ResolveScopeSource(reasampler::CaptureScope scope,
ResolvedSource& out, std::string& why)
{
using reasampler::CaptureScope;
switch (scope)
{
case CaptureScope::Item:
if (!collectSelectedItemTracks(out)) {
why = "select at least one media item"; return false;
}
break;
case CaptureScope::Track:
if (!collectSelectedTracks(out)) {
why = "select at least one track"; return false;
}
break;
}
return resolveRange(out.startSeconds, out.endSeconds, why);
}
// --- 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(reasampler::CaptureScope scope,
const std::vector<MediaTrack*>& sourceTracks,
ReaProject* proj)
{
const reasampler::FxBypassPlan plan = reasampler::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)
}
};
// 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.
// 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.
// Non-destructive: FX-enable + fader gain + pan/width/law/mode are fully restored
// on every path by FxBypassGuard, and the backend restores every RENDER_* setting.
static void RunCapture(const reasampler::CaptureActionDef& def)
{
ResolvedSource src;
std::string why;
if (!ResolveScopeSource(def.scope, src, why))
{
ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
return;
}
// The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action
// variant: the plain 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. tailMs is meaningful only for Manual and is pre-clamped by the panel.
const reasampler::TailSetting tail = reasampler::bankPanelTailSetting();
reasampler::CaptureRequest req;
req.sourceMode = reasampler::sourceModeForScope(def.scope);
req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = src.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 = reasampler::WavBitDepth::Float32; // deterministic, no dither
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);
reasampler::OfflineRenderBackend backend;
reasampler::CaptureResult res = backend.capture(req);
if (res.status != reasampler::CaptureStatus::Ok)
{
ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str());
return;
}
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
reasampler::AddResult added = g_session.bank().add(res.sample);
// Persist the updated book into the active project's ext state (the `banks` key)
// 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.
g_session.saveToActiveProject();
std::string log = "ReaSampler: " + res.message + "\n";
log += " bank size now " + std::to_string(g_session.bank().size()) +
(added == reasampler::AddResult::Added ? " (added)\n"
: added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n"
: " (rejected)\n");
ShowConsoleMsg(log.c_str());
}
// 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
// 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.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).
static void RunCaptureRealtimeTrack()
{
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(reasampler::CaptureScope::Track, src, why))
{
ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str());
return;
}
reasampler::CaptureRequest req;
req.sourceMode = reasampler::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 = reasampler::TailMode::None; // realtime tail is T2; exact bounds here
req.tailMs = 0.0;
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
req.bitDepth = reasampler::WavBitDepth::Float32;
req.baseName = "realtime";
req.trackGuids = src.trackGuids; // provenance on the Sample
reasampler::CaptureResult failure;
reasampler::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);
ShowConsoleMsg("ReaSampler: realtime capture started — recording in the "
"background; the bank updates when it reaches the range end.\n");
}
// 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.
static void RunCancelRealtime()
{
if (!g_rtCapture)
{
ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n");
return;
}
reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
if (r.status == reasampler::RealtimeTickStatus::Done)
CommitRealtimeResult(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
// (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action.
static void RunInsertSelected(bool conform)
{
reasampler::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 ? reasampler::TempoConform::Ratio1x : reasampler::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.)
reasampler::InsertResult res = reasampler::runInsert(&g_session, req);
std::string msg;
switch (res.status)
{
case reasampler::InsertStatus::Ok:
msg = "ReaSampler: inserted onto " + std::to_string(res.inserted) +
(res.inserted == 1 ? " track" : " tracks") +
(conform ? " (conformed to tempo)" : " (native length)") + "\n";
break;
case reasampler::InsertStatus::NoSelection:
// "select a track first" is printed by runInsert when no track is
// selected; this branch covers the no-panel-selection case.
msg = "ReaSampler insert: nothing selected in the bank panel.\n";
break;
case reasampler::InsertStatus::NoProject:
msg = "ReaSampler insert: no saved project, so the bank has no location.\n";
break;
case reasampler::InsertStatus::NothingResolved:
msg = "ReaSampler insert: selected sample(s) could not be resolved to a file.\n";
break;
}
ShowConsoleMsg(msg.c_str());
}
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
// return false otherwise so REAPER keeps looking.
static bool OnHookCommand(int command, int /*flag*/)
{
if (command == 0) return false;
// Three-scope capture family: command ids parallel captureActionTable() 1:1 by index.
// Claim the fired id if it is one of ours and route to its table row.
for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i)
if (command == g_captureCmdIds[i])
{
RunCapture(reasampler::captureActionTable()[i]);
return true;
}
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; }
if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; }
if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; }
if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; }
// Design View action family (D4). Claims only its own ids; returns false for the
// rest so this hook keeps looking (per the contract).
if (reasampler::designViewHandleCommand(command)) return true;
// Multi-bank action family (B3). Same contract: claims only its own ids.
if (reasampler::bankHandleCommand(command)) return true;
return false;
}
// REAPER polls this to render each of OUR actions' checked state in menus/toolbars.
// Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract).
static int OnToggleAction(int command)
{
if (command == g_cmdToggleBankPanel)
return reasampler::bankPanelIsOpen() ? 1 : 0;
return -1; // not ours / non-toggling
}
// gaccel storage must outlive registration — REAPER holds the pointer.
// (The capture family's accels live in g_captureAccels, sized to the table.)
static gaccel_register_t g_accelToggleBankPanel{};
static gaccel_register_t g_accelInsertSelected{};
static gaccel_register_t g_accelInsertSelectedConform{};
static gaccel_register_t g_accelCaptureTrackRealtime{};
static gaccel_register_t g_accelCancelRealtime{};
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
{
if (!rec)
{
// rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every
// callback with the same strings prefixed '-' (per the contract).
if (g_rec)
{
// Abort any in-flight realtime capture FIRST, while the API pointers are
// still live — finalize-or-abort + restore so we never leave a temp track,
// an armed track, or an altered transport/cursor in the user's project on
// unload. Commit whatever was captured (best effort) before tearing down.
if (g_rtCapture)
{
reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
CommitRealtimeResult(r.result);
g_rtCapture.reset();
g_rtCaptureProject = nullptr;
}
g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
// Tear down the Design View action family (D4) — mirror-unregisters each
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
reasampler::designViewUnregisterActions(g_rec);
// Tear down the multi-bank action family (B3) — same mirror-unregister.
reasampler::bankUnregisterActions(g_rec);
g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE"));
g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_TRACK_REALTIME"));
g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM"));
g_rec->Register("-gaccel", (void*)&g_accelInsertSelected);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED"));
g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL"));
// Mirror-unregister the capture family: gaccel + command_id per row,
// with '-'-prefixed strings (per the contract). The FOREVER-STABLE id
// strings come from the same table used to register them.
{
const auto& table = reasampler::captureActionTable();
for (std::size_t i = 0; i < table.size(); ++i)
{
if (i < g_captureAccels.size())
g_rec->Register("-gaccel", (void*)&g_captureAccels[i]);
g_rec->Register("-command_id", (void*)table[i].commandString);
}
}
// Retire the removed M7 command ids (command_id only — we never held a
// gaccel for them this session). Clears stale user keybindings on unload.
for (const char* id : kRetiredCaptureCmdStrings)
g_rec->Register("-command_id", (void*)id);
}
// Destroy the docked window and release cached thumbnails before we drop
// the API pointers (DockWindowRemove/DestroyWindow need them live).
reasampler::bankPanelShutdown();
g_rec = nullptr;
return 0;
}
// ABI guard: the struct layout we compiled against must match this REAPER.
if (rec->caller_version != REAPER_PLUGIN_VERSION)
return 0;
// Resolve every REAPER API function pointer. Returns the number that FAILED
// to load; 0 == success. Non-zero usually means REAPER is older than our SDK.
if (REAPERAPI_LoadAPI(rec->GetFunc) != 0)
return 0;
g_hInst = hInstance;
g_rec = rec;
// Register the three-scope capture action family (command_id -> gaccel per table row).
// The single hookcommand below routes every fired id back to its row by index.
// g_captureAccels must be sized BEFORE the loop and never reallocated after —
// REAPER holds a pointer to each element until we mirror-unregister it.
{
const auto& table = reasampler::captureActionTable();
g_captureCmdIds.assign(table.size(), 0);
g_captureAccels.assign(table.size(), gaccel_register_t{});
for (std::size_t i = 0; i < table.size(); ++i)
{
const int cmd =
rec->Register("command_id", (void*)table[i].commandString);
g_captureCmdIds[i] = cmd;
if (cmd)
{
g_captureAccels[i].accel.cmd = cmd;
g_captureAccels[i].desc = table[i].description;
rec->Register("gaccel", (void*)&g_captureAccels[i]);
}
}
}
// Point the bank panel at the live session BEFORE registering its action, so
// a toggle firing immediately has a session to read (M5). Does not open the
// window — only stores the session pointer.
reasampler::bankPanelInit(&g_session);
// Register the M5 "toggle bank panel" action (command_id -> gaccel ->
// hookcommand + toggleaction for the checked state).
g_cmdToggleBankPanel = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL"));
if (g_cmdToggleBankPanel)
{
g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel;
g_accelToggleBankPanel.desc = "ReaSampler: toggle bank panel";
rec->Register("gaccel", (void*)&g_accelToggleBankPanel);
rec->Register("toggleaction", (void*)&OnToggleAction);
}
// Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two
// variants: native-length (default, no stretch) and the EXPLICIT conform-to-
// tempo opt-in. Both read the bank panel selection and place at the edit cursor.
g_cmdInsertSelected = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED"));
if (g_cmdInsertSelected)
{
g_accelInsertSelected.accel.cmd = g_cmdInsertSelected;
g_accelInsertSelected.desc =
"ReaSampler: insert selected sample at edit cursor";
rec->Register("gaccel", (void*)&g_accelInsertSelected);
}
g_cmdInsertSelectedConform = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM"));
if (g_cmdInsertSelectedConform)
{
g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform;
g_accelInsertSelectedConform.desc =
"ReaSampler: insert selected sample at edit cursor (conform to tempo)";
rec->Register("gaccel", (void*)&g_accelInsertSelectedConform);
}
// Register the "capture selected track (realtime)" action (command_id -> gaccel ->
// hookcommand). Realtime sibling of the offline CAPTURE_TRACK scope: records the
// selected track's own output in realtime into a hidden temp track, moves it into
// the bank. Dialog-free. NEW FOREVER-STABLE id string.
g_cmdCaptureTrackRealtime = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_TRACK_REALTIME"));
if (g_cmdCaptureTrackRealtime)
{
g_accelCaptureTrackRealtime.accel.cmd = g_cmdCaptureTrackRealtime;
g_accelCaptureTrackRealtime.desc =
"ReaSampler: capture selected track (realtime)";
rec->Register("gaccel", (void*)&g_accelCaptureTrackRealtime);
}
// Cancel-in-flight sibling: aborts a running realtime capture (stop + restore).
// FOREVER-STABLE id string.
g_cmdCancelRealtime = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE"));
if (g_cmdCancelRealtime)
{
g_accelCancelRealtime.accel.cmd = g_cmdCancelRealtime;
g_accelCancelRealtime.desc = "ReaSampler: cancel realtime capture";
rec->Register("gaccel", (void*)&g_accelCancelRealtime);
}
// Register the Design View action family (D4): toggle/activate mode, tag/untag/
// show-both selected tracks. Each mints its own command_id + gaccel; the single
// hookcommand below routes them via designViewHandleCommand. Registered before
// the hook so every id is minted first.
reasampler::designViewRegisterActions(rec, &g_session);
// Register the multi-bank action family (B3): create/rename/delete/evacuate bank,
// activate (cycle + pool), move/copy selected samples to a bank, and the two
// full-height layout toggles. Shares g_session with the Design View family; routed
// by the same hookcommand via bankHandleCommand. Registered before the hook.
reasampler::bankRegisterActions(rec, &g_session);
// One hookcommand routes every ReaSampler action (spike + toggle + Design View).
// Registered once, after all command ids are minted.
rec->Register("hookcommand", (void*)&OnHookCommand);
// Drive project-load / Save-As detection (M4 persist). The timer polls the
// active project each tick; on a project load it reloads the bank from ext
// state, on a Save-As it relocates the bank folder under the new .rpp.
rec->Register("timer", (void*)&OnTimer);
ShowConsoleMsg("ReaSampler loaded.\n");
return 1; // success — REAPER keeps us loaded
}