Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
#include "core/namespaces.h"
|
||||
// drag_out_win — OS/COM initiation of native OS drag-out (M11). See drag_out_win.h.
|
||||
//
|
||||
// Windows path (primary): a hand-rolled minimal IDataObject exposing exactly one format,
|
||||
// CF_HDROP, plus a minimal IDropSource, handed to OLE DoDragDrop with a COPY-ONLY effect
|
||||
// mask. We roll our own rather than pull in a helper because the object is tiny (one
|
||||
// format, one medium) and the copy-only guarantee must be structural and auditable in one
|
||||
// place. mac/linux route to SWELL's file-list drag behind the same seam.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. No REAPER API is used here (pure OS/COM); it
|
||||
// is a leaf the bank_panel calls.
|
||||
|
||||
#include "shell/actions/drag_out_win.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <windows.h>
|
||||
#include <ole2.h> // DoDragDrop, IDataObject, IDropSource, ReleaseStgMedium
|
||||
#include <shlobj.h> // DROPFILES, CF_HDROP
|
||||
#include <cstring>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// Builds the CF_HDROP HGLOBAL: a DROPFILES header followed by a double-null-terminated
|
||||
// list of wide (UTF-16) absolute paths. Windows CF_HDROP requires backslash separators and
|
||||
// a trailing extra NUL after the final path's NUL. Returns nullptr on allocation failure or
|
||||
// empty input. Ownership transfers to the STGMEDIUM (freed by ReleaseStgMedium / the OS).
|
||||
HGLOBAL buildHDrop(const std::vector<std::string>& paths) {
|
||||
if (paths.empty()) return nullptr;
|
||||
|
||||
// 1) Convert each UTF-8 path to wide, normalizing '/' -> '\\' (the panel stores paths
|
||||
// slash-normalized for its own resolution; CF_HDROP wants native backslashes).
|
||||
std::vector<std::wstring> wide;
|
||||
wide.reserve(paths.size());
|
||||
std::size_t totalChars = 0; // characters incl. each path's terminating NUL
|
||||
for (const std::string& p : paths) {
|
||||
if (p.empty()) continue;
|
||||
const int need = MultiByteToWideChar(CP_UTF8, 0, p.c_str(), -1, nullptr, 0);
|
||||
if (need <= 0) continue; // unconvertible path — skip rather than emit garbage
|
||||
std::wstring w(static_cast<std::size_t>(need), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, p.c_str(), -1, &w[0], need);
|
||||
// `need` includes the NUL; drop it from the string length, we re-add it in the buffer.
|
||||
if (!w.empty() && w.back() == L'\0') w.pop_back();
|
||||
for (wchar_t& c : w) if (c == L'/') c = L'\\';
|
||||
totalChars += w.size() + 1; // + the per-path NUL
|
||||
wide.push_back(std::move(w));
|
||||
}
|
||||
if (wide.empty()) return nullptr;
|
||||
totalChars += 1; // the extra double-NUL terminator after the last path
|
||||
|
||||
const SIZE_T bytes = sizeof(DROPFILES) + totalChars * sizeof(wchar_t);
|
||||
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, bytes);
|
||||
if (!h) return nullptr;
|
||||
|
||||
auto* df = static_cast<DROPFILES*>(GlobalLock(h));
|
||||
if (!df) { GlobalFree(h); return nullptr; }
|
||||
df->pFiles = sizeof(DROPFILES); // offset to the file list
|
||||
df->fWide = TRUE; // wide (UTF-16) path list
|
||||
|
||||
auto* dst = reinterpret_cast<wchar_t*>(reinterpret_cast<char*>(df) + sizeof(DROPFILES));
|
||||
for (const std::wstring& w : wide) {
|
||||
std::memcpy(dst, w.c_str(), (w.size() + 1) * sizeof(wchar_t)); // incl. NUL
|
||||
dst += w.size() + 1;
|
||||
}
|
||||
*dst = L'\0'; // double-NUL terminates the list (ZEROINIT already did, explicit for clarity)
|
||||
|
||||
GlobalUnlock(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
// Minimal IDropSource: continue until the (left) button releases or Escape cancels; always
|
||||
// request the copy cursor. This is the standard textbook drop source — no custom feedback.
|
||||
class DropSource final : public IDropSource {
|
||||
public:
|
||||
// IUnknown
|
||||
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override {
|
||||
if (riid == IID_IUnknown || riid == IID_IDropSource) {
|
||||
*ppv = static_cast<IDropSource*>(this);
|
||||
AddRef();
|
||||
return S_OK;
|
||||
}
|
||||
*ppv = nullptr;
|
||||
return E_NOINTERFACE;
|
||||
}
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
|
||||
ULONG STDMETHODCALLTYPE Release() override {
|
||||
const ULONG r = --refs_;
|
||||
if (r == 0) delete this;
|
||||
return r;
|
||||
}
|
||||
// IDropSource
|
||||
HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escapePressed, DWORD keyState) override {
|
||||
if (escapePressed) return DRAGDROP_S_CANCEL;
|
||||
if (!(keyState & MK_LBUTTON)) return DRAGDROP_S_DROP; // released -> drop
|
||||
return S_OK; // keep dragging
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE GiveFeedback(DWORD /*effect*/) override {
|
||||
return DRAGDROP_S_USEDEFAULTCURSORS; // let OLE draw the standard copy cursor
|
||||
}
|
||||
private:
|
||||
ULONG refs_ = 1;
|
||||
};
|
||||
|
||||
// Minimal IDataObject exposing exactly one format (CF_HDROP / TYMED_HGLOBAL). The HDROP is
|
||||
// built once at construction and cloned on each GetData call (OLE owns the returned medium).
|
||||
class HDropDataObject final : public IDataObject {
|
||||
public:
|
||||
explicit HDropDataObject(HGLOBAL hdrop) : hdrop_(hdrop) {}
|
||||
~HDropDataObject() { if (hdrop_) GlobalFree(hdrop_); }
|
||||
|
||||
// IUnknown
|
||||
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override {
|
||||
if (riid == IID_IUnknown || riid == IID_IDataObject) {
|
||||
*ppv = static_cast<IDataObject*>(this);
|
||||
AddRef();
|
||||
return S_OK;
|
||||
}
|
||||
*ppv = nullptr;
|
||||
return E_NOINTERFACE;
|
||||
}
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
|
||||
ULONG STDMETHODCALLTYPE Release() override {
|
||||
const ULONG r = --refs_;
|
||||
if (r == 0) delete this;
|
||||
return r;
|
||||
}
|
||||
|
||||
// IDataObject — the two that matter for a drag source.
|
||||
HRESULT STDMETHODCALLTYPE GetData(FORMATETC* fmt, STGMEDIUM* med) override {
|
||||
if (!fmt || !med) return E_INVALIDARG;
|
||||
if (!isHDrop(*fmt)) return DV_E_FORMATETC;
|
||||
if (!hdrop_) return E_UNEXPECTED;
|
||||
|
||||
// Clone the HGLOBAL so the caller (OLE / target) owns an independent copy; our
|
||||
// hdrop_ stays valid for repeat GetData calls and is freed in the dtor.
|
||||
const SIZE_T sz = GlobalSize(hdrop_);
|
||||
HGLOBAL copy = GlobalAlloc(GMEM_MOVEABLE, sz);
|
||||
if (!copy) return E_OUTOFMEMORY;
|
||||
void* src = GlobalLock(hdrop_);
|
||||
if (!src) { GlobalFree(copy); return E_OUTOFMEMORY; }
|
||||
void* dst = GlobalLock(copy);
|
||||
if (!dst) { GlobalUnlock(hdrop_); GlobalFree(copy); return E_OUTOFMEMORY; }
|
||||
std::memcpy(dst, src, sz);
|
||||
GlobalUnlock(hdrop_);
|
||||
GlobalUnlock(copy);
|
||||
|
||||
med->tymed = TYMED_HGLOBAL;
|
||||
med->hGlobal = copy;
|
||||
med->pUnkForRelease = nullptr; // caller releases via ReleaseStgMedium
|
||||
return S_OK;
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE QueryGetData(FORMATETC* fmt) override {
|
||||
return (fmt && isHDrop(*fmt)) ? S_OK : DV_E_FORMATETC;
|
||||
}
|
||||
|
||||
// The remainder are the standard "not supported for a simple source" stubs.
|
||||
HRESULT STDMETHODCALLTYPE GetDataHere(FORMATETC*, STGMEDIUM*) override { return E_NOTIMPL; }
|
||||
HRESULT STDMETHODCALLTYPE GetCanonicalFormatEtc(FORMATETC*, FORMATETC* out) override {
|
||||
if (out) out->ptd = nullptr;
|
||||
return E_NOTIMPL;
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE SetData(FORMATETC*, STGMEDIUM*, BOOL) override { return E_NOTIMPL; }
|
||||
HRESULT STDMETHODCALLTYPE EnumFormatEtc(DWORD dir, IEnumFORMATETC** out) override {
|
||||
if (dir == DATADIR_GET && out) {
|
||||
FORMATETC fe = hdropFormat();
|
||||
return SHCreateStdEnumFmtEtc(1, &fe, out);
|
||||
}
|
||||
return E_NOTIMPL;
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE DAdvise(FORMATETC*, DWORD, IAdviseSink*, DWORD*) override {
|
||||
return OLE_E_ADVISENOTSUPPORTED;
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE DUnadvise(DWORD) override { return OLE_E_ADVISENOTSUPPORTED; }
|
||||
HRESULT STDMETHODCALLTYPE EnumDAdvise(IEnumSTATDATA**) override {
|
||||
return OLE_E_ADVISENOTSUPPORTED;
|
||||
}
|
||||
|
||||
private:
|
||||
static FORMATETC hdropFormat() {
|
||||
FORMATETC fe{};
|
||||
fe.cfFormat = CF_HDROP;
|
||||
fe.ptd = nullptr;
|
||||
fe.dwAspect = DVASPECT_CONTENT;
|
||||
fe.lindex = -1;
|
||||
fe.tymed = TYMED_HGLOBAL;
|
||||
return fe;
|
||||
}
|
||||
static bool isHDrop(const FORMATETC& fe) {
|
||||
return fe.cfFormat == CF_HDROP &&
|
||||
(fe.tymed & TYMED_HGLOBAL) &&
|
||||
fe.dwAspect == DVASPECT_CONTENT;
|
||||
}
|
||||
ULONG refs_ = 1;
|
||||
HGLOBAL hdrop_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths) {
|
||||
if (absolutePaths.empty()) return false;
|
||||
|
||||
// REAPER's main thread is already OLE-initialized (it hosts OLE drag targets), so we do
|
||||
// NOT call OleInitialize here — a nested OleInitialize on an already-initialized STA is
|
||||
// harmless-but-unnecessary, and OleUninitialize pairing across a REAPER-owned apartment
|
||||
// is the kind of thing that bites. DoDragDrop works on the already-initialized STA.
|
||||
HGLOBAL hdrop = buildHDrop(absolutePaths);
|
||||
if (!hdrop) return false;
|
||||
|
||||
auto* data = new HDropDataObject(hdrop); // takes ownership of hdrop
|
||||
auto* source = new DropSource();
|
||||
|
||||
DWORD effect = 0;
|
||||
// COPY-ONLY (invariant #1): the allowed-effects mask is DROPEFFECT_COPY alone. MOVE is
|
||||
// NEVER offered, so no drop target can relocate (delete) the bank file — only prune
|
||||
// deletes bank bytes (Phase R boundary).
|
||||
const HRESULT hr = DoDragDrop(data, source, DROPEFFECT_COPY, &effect);
|
||||
|
||||
source->Release();
|
||||
data->Release(); // frees the source HGLOBAL via HDropDataObject's dtor
|
||||
|
||||
return hr == DRAGDROP_S_DROP && effect == DROPEFFECT_COPY;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
#else // ---- macOS / Linux (SWELL) -----------------------------------------------------
|
||||
|
||||
#include "wdltypes.h"
|
||||
#include "swell/swell.h"
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// SWELL provides a file-list drag surface (SWELL_InitiateDragDropOfFileList, verified in
|
||||
// vendor/WDL/WDL/swell/swell-functions.h). It takes a C-string array + count and initiates
|
||||
// a copy-style file drag from the given window. Unlike OLE it exposes no per-source effect
|
||||
// mask, so the copy-only guarantee rests on SWELL's copy semantics rather than an explicit
|
||||
// DROPEFFECT_COPY mask — an honest platform difference, not a faked equivalence. Windows is
|
||||
// the exact-control path (D5: Windows is the shipping target).
|
||||
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths) {
|
||||
if (absolutePaths.empty() || !panelHwnd) return false;
|
||||
|
||||
std::vector<const char*> ptrs;
|
||||
ptrs.reserve(absolutePaths.size());
|
||||
for (const std::string& p : absolutePaths) ptrs.push_back(p.c_str());
|
||||
|
||||
// srcrect null: SWELL positions the drag image at the current event. No custom icon.
|
||||
SWELL_InitiateDragDropOfFileList(reinterpret_cast<HWND>(panelHwnd), nullptr,
|
||||
ptrs.data(), static_cast<int>(ptrs.size()), nullptr);
|
||||
return true; // fire-and-forget; SWELL owns the drag from here (no accept/cancel return)
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,44 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// drag_out_win — the OS/COM initiation half of native OS drag-out (Milestone 11). The pure
|
||||
// gesture-boundary decision and path-list assembly live in drag_out.*; THIS is the platform
|
||||
// shell that hands a resolved, existing-file path list to the operating system's drag-drop
|
||||
// machinery so the user can drop bank samples into Explorer / another app / another DAW.
|
||||
//
|
||||
// ONE seam, platform-forked inside the .cpp:
|
||||
// * Windows (primary — Daniel's target): OLE DoDragDrop with a minimal IDataObject
|
||||
// carrying CF_HDROP (absolute paths, double-null-terminated wide list) and a minimal
|
||||
// IDropSource. COPY-ONLY is STRUCTURAL: the IDataObject offers DROPEFFECT_COPY and the
|
||||
// effect mask passed to DoDragDrop is DROPEFFECT_COPY alone — MOVE is never offered, so
|
||||
// no target can pull the bank file out of the bank folder (invariant #1: a move would
|
||||
// delete bank bytes, and per the Phase R boundary ONLY prune deletes files).
|
||||
// * macOS/Linux (SWELL): SWELL_InitiateDragDropOfFileList (verified present in
|
||||
// vendor/WDL/WDL/swell/swell-functions.h) behind the same seam. SWELL's file-list drag
|
||||
// is a copy-style file drag; it exposes no per-source effect mask the way OLE does, so
|
||||
// the copy-only guarantee there rests on SWELL's copy semantics rather than an explicit
|
||||
// mask — noted honestly, not faked. Windows is where the mask control is exact.
|
||||
//
|
||||
// NON-DESTRUCTIVE (invariant #2): initiating a drag reads nothing but the path list and
|
||||
// mutates no sample / index / selection. A cancelled or failed drag changes nothing — the
|
||||
// OS layer here neither writes ext-state nor touches the book.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct HWND__; // avoid dragging windows.h into every includer; the shell casts as needed.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Initiates a native OS drag-out of `absolutePaths` (already resolved, existing, de-duped —
|
||||
// the pure drag_out::assemblePathList output) from the panel window `panelHwnd`. COPY-ONLY;
|
||||
// see the header note. A no-op when the path list is empty (nothing draggable — the caller
|
||||
// checks this too, but the guard is repeated here so a direct call is safe).
|
||||
//
|
||||
// BLOCKING on Windows: OLE DoDragDrop runs its own modal message loop until the drop or
|
||||
// cancel, then returns — the caller's gesture state should be reset AFTER this returns.
|
||||
// Returns true if a drop was accepted (DROPEFFECT_COPY), false on cancel / failure /
|
||||
// empty input. The return is advisory (a failed drag is visible by nothing happening —
|
||||
// the caller does not surface an error, per the brief's no-console-output constraint).
|
||||
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,158 @@
|
||||
#include "core/namespaces.h"
|
||||
// instrument_drop_win — the REAPER shell for S17 drop-and-load. See instrument_drop_win.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the pointers; here they are extern via the WANT list).
|
||||
|
||||
#include "shell/actions/instrument_drop_win.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include "core/version/app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing)
|
||||
#include "core/wire/instrument_drop.h" // infoNamesFxHotspot — the PURE, unit-tested hotspot classifier
|
||||
|
||||
#include "reaper_plugin.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetThingFromPoint
|
||||
#define REAPERAPI_WANT_TrackFX_AddByName
|
||||
#define REAPERAPI_WANT_TrackFX_Delete
|
||||
#define REAPERAPI_WANT_TrackFX_SetPreset
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// Write `bytes` to a fresh uniquely-named .vstpreset in the OS temp dir and return its path;
|
||||
// returns an empty path on any failure. The .vstpreset extension is load-bearing —
|
||||
// TrackFX_SetPreset's full-path form is documented for .vstpreset files (VST3). The file is
|
||||
// transient: the caller deletes it right after the SetPreset call.
|
||||
//
|
||||
// The temp filename embeds the process ID so two concurrent REAPER instances (e.g. stable +
|
||||
// beta) cannot collide in the shared OS temp dir, and one instance's cleanup cannot
|
||||
// accidentally delete another's in-flight file.
|
||||
//
|
||||
// Non-throwing: every std::filesystem call uses the error_code overload. The whole body is
|
||||
// wrapped in try/catch to guarantee no exception crosses the REAPER C callback boundary
|
||||
// (the same discipline persist.cpp uses — see its non-throwing scanPruneOrphans comment).
|
||||
//
|
||||
// Returns the path object (not a narrow string) so the caller can:
|
||||
// (a) pass path.u8string() to TrackFX_SetPreset — UTF-8 on MSVC, not ACP-converted,
|
||||
// so a temp dir with accented or CJK user-name bytes is handled correctly;
|
||||
// (b) delete via the retained path object — not via re-parsing the narrow string —
|
||||
// so the cleanup cannot leak if the conversion above were to round-trip incorrectly.
|
||||
std::filesystem::path writeTempPreset(const std::vector<std::uint8_t>& bytes) {
|
||||
try {
|
||||
static std::atomic<unsigned> counter{0};
|
||||
std::error_code ec;
|
||||
const std::filesystem::path dir = std::filesystem::temp_directory_path(ec);
|
||||
if (ec) return {};
|
||||
// PID in the name keeps files from distinct REAPER instances distinct in the shared
|
||||
// temp dir — prevents cross-instance collisions and spurious post-apply deletions.
|
||||
const std::string name =
|
||||
"reasampler_drop_" + std::to_string(GetCurrentProcessId()) +
|
||||
"_" + std::to_string(counter.fetch_add(1)) + ".vstpreset";
|
||||
const std::filesystem::path path = dir / name;
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return {};
|
||||
out.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
out.close();
|
||||
if (!out) { // short write / flush failure -> don't hand REAPER a truncated preset
|
||||
std::filesystem::remove(path, ec);
|
||||
return {};
|
||||
}
|
||||
return path;
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FxDropTarget resolveFxDropTarget(int screenX, int screenY) {
|
||||
FxDropTarget out;
|
||||
char info[256] = {0};
|
||||
// GetThingFromPoint returns the track under the point (may be null for a non-track thing)
|
||||
// and fills `info` with what was hit. A non-empty info OR a non-null track means the point
|
||||
// is over REAPER's own UI; a null track with an empty info means the pointer has left
|
||||
// REAPER entirely (over another app / the desktop) — the OsDrag boundary.
|
||||
MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
|
||||
out.track = track;
|
||||
out.overReaperUi = (track != nullptr) || (info[0] != '\0');
|
||||
// The hotspot is either the FX chain/floating window ("fx_*") OR the FX-button family of
|
||||
// the track/mixer panel ("tcp.fx*"/"mcp.fx*"). The pure classifier owns the rule.
|
||||
out.overFxHotspot = (track != nullptr) && infoNamesFxHotspot(info);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes) {
|
||||
if (!track || presetBytes.empty()) return false;
|
||||
|
||||
// Materialize the .vstpreset FIRST so an I/O failure leaves the track untouched (no FX
|
||||
// added yet — nothing to roll back).
|
||||
const std::filesystem::path presetPath = writeTempPreset(presetBytes);
|
||||
if (presetPath.empty()) return false;
|
||||
|
||||
// The CHANNEL-correct FX name: "VST3:ReaSampler 9000" on stable, "VST3:ReaSampler 9000
|
||||
// beta" on beta. Sourcing it from app_version::vstPluginName() (the same accessor the VST
|
||||
// factory display name derives from) keeps the pairing invariant intact — a beta extension
|
||||
// drops the beta VST, a stable extension the stable VST — with no literal to drift. (The
|
||||
// preset's class ID forks by the same channel bit inside buildInstrumentDropPreset.)
|
||||
const std::string fxName = "VST3:" + vstPluginName();
|
||||
|
||||
// Negative `instantiate` => always create a NEW instance (verified in the header). recFX
|
||||
// = false: a normal track FX chain instance, not a record/monitoring FX.
|
||||
const int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
|
||||
/*instantiate=*/-1);
|
||||
bool ok = fxIndex >= 0;
|
||||
|
||||
// Apply the dragged capture's component state through the DOCUMENTED channel: a full
|
||||
// .vstpreset path handed to TrackFX_SetPreset (SDK: "Full paths to .vstpreset files are
|
||||
// also supported for VST3 plug-ins"). REAPER parses the Steinberg container and feeds the
|
||||
// 'Comp' chunk to the instance's setState — the same bytes the instrument's own
|
||||
// serializer produced (instrument_drop::buildInstrumentDropPreset ->
|
||||
// sample_map::serializeComponentState). Unlike the former "vst_chunk" named-config-parm
|
||||
// write, a failure here is REPORTED (false), not silently ignored.
|
||||
//
|
||||
// u8string() gives UTF-8 bytes on MSVC (not ACP-converted), so a temp dir under an
|
||||
// accented or CJK user-name is handled correctly by REAPER's path APIs.
|
||||
if (ok) ok = TrackFX_SetPreset(track, fxIndex, presetPath.u8string().c_str());
|
||||
|
||||
// The preset file is transient regardless of outcome; delete via the retained path object
|
||||
// (not a re-parsed narrow string) so cleanup cannot leak even if the UTF-8 conversion
|
||||
// round-trip were incorrect.
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(presetPath, ec);
|
||||
|
||||
if (!ok && fxIndex >= 0) {
|
||||
// All-or-nothing: if the preset apply fails, remove the empty FX instance we just
|
||||
// added so the track is left exactly as it was. TrackFX_Delete signature (verified
|
||||
// in reaper_plugin_functions.h:7236): bool TrackFX_Delete(MediaTrack*, int fx).
|
||||
TrackFX_Delete(track, fxIndex);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool performInstrumentDrop(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes) {
|
||||
if (!track || presetBytes.empty()) return false;
|
||||
|
||||
// One undo point for the whole gesture (mirrors the bank-verb undo discipline). Both the
|
||||
// FX add and the state apply are REAPER-undoable, so Ctrl-Z removes the instance cleanly.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
const bool ok = loadInstrumentOntoTrack(track, presetBytes);
|
||||
// The undo label reflects the placement-of-the-player framing (not a capture, not an insert).
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture
|
||||
// decision lives in drag_out (DragGesture::InstrumentDrop) and the pure payload construction
|
||||
// in instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track
|
||||
// + its FX-surface hotspot via REAPER's hit-test API, and (b) on release adds a ReaSampler
|
||||
// 9000 instance to that track and applies the dragged capture as its component state via a
|
||||
// temp .vstpreset + TrackFX_SetPreset (S-GA-DropFX: the earlier "vst_chunk" named-config-parm
|
||||
// write was silently unappliable — see instrument_drop.h for the diagnosis).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. REAPER-facing (GetThingFromPoint, TrackFX_*,
|
||||
// Undo_*), so DAW-verified, not unit-tested; the pure decision + preset it drives are CTest'd.
|
||||
//
|
||||
// LOAD-BEARING (CONTEXT.md §Drop-and-load): this is an EXPLICIT user placement-of-the-player
|
||||
// gesture — it adds a READER of the bank on a track and points it at one already-captured
|
||||
// sample. It NEVER captures, NEVER writes the bank, and NEVER inserts a timeline item. The
|
||||
// only writes are: a new FX instance on the target track + that instance's own component
|
||||
// state — both REAPER-undoable, wrapped in one undo block so the whole gesture is one Ctrl-Z
|
||||
// — plus a transient .vstpreset in the OS temp dir, deleted before returning.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
// Opaque REAPER track handle at the boundary so includers don't need the SDK. The SDK
|
||||
// declares it as a class (reaper_plugin.h) — match that spelling so the mangled name agrees.
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The result of hit-testing a screen point during a live InstrumentDrop drag.
|
||||
struct FxDropTarget {
|
||||
MediaTrack* track = nullptr; // the track under the pointer (null if none / not a track)
|
||||
bool overReaperUi = false; // the point is over REAPER's own window/UI at all
|
||||
bool overFxHotspot = false; // specifically over this track's FX button/chain surface
|
||||
|
||||
// A valid drop target: a resolved track whose FX hotspot is under the pointer.
|
||||
bool valid() const { return track != nullptr && overFxHotspot; }
|
||||
};
|
||||
|
||||
// Hit-test a screen point (REAPER screen coords) to an FX drop target. Wraps
|
||||
// GetThingFromPoint, whose info string tells us what was hit ("tcp.fx*"/"mcp.fx*" for the
|
||||
// TCP/MCP FX button family; "fx_chain"/"fx_N" for the FX-chain and floating-FX windows; bare
|
||||
// "tcp"/"mcp" or other sub-element tokens for non-FX track-panel regions). `overReaperUi` is
|
||||
// the shell-supplied predicate the pure drag_out::decideGesture consumes (true when the point
|
||||
// is over REAPER's own UI — i.e. GetThingFromPoint returned a track OR a recognizable
|
||||
// non-track thing, false when the pointer has left REAPER entirely). `overFxHotspot` is true
|
||||
// only when the info string names a genuine FX-bearing surface — decided by the pure
|
||||
// instrument_drop::infoNamesFxHotspot from the SDK's own hit-test string.
|
||||
FxDropTarget resolveFxDropTarget(int screenX, int screenY);
|
||||
|
||||
// Perform the drop on `track`: add a fresh ReaSampler 9000 instance and apply `presetBytes`
|
||||
// (the instrument_drop::buildInstrumentDropPreset output — a .vstpreset image) as its
|
||||
// component state so it plays the dragged capture. Wraps the add + apply in one REAPER undo
|
||||
// block (mirrors the bank-verb undo discipline). Returns true on success (the FX was added
|
||||
// and the preset applied), false on any failure. All-or-nothing: if the preset apply fails
|
||||
// after a successful add, the freshly-added FX instance is removed via TrackFX_Delete before
|
||||
// returning false, leaving the track exactly as it was (no orphaned empty-state FX).
|
||||
// NEVER inserts a timeline item; the ONLY persistent mutations are the FX instance + its
|
||||
// state, both undoable.
|
||||
bool performInstrumentDrop(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes);
|
||||
|
||||
// Add a fresh ReaSampler 9000 instance to `track` and apply `presetBytes` as its component
|
||||
// state. Same all-or-nothing add+apply contract as performInstrumentDrop (rolls the FX back
|
||||
// via TrackFX_Delete on apply failure), but does NOT open its own undo block — the caller owns
|
||||
// the undo grouping so the whole gesture (persist + FX-add + apply) collapses to
|
||||
// one Ctrl-Z. This is the shared inner half performInstrumentDrop wraps in its own block.
|
||||
// Returns true on success, false on any failure. NEVER inserts a timeline item.
|
||||
bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,540 @@
|
||||
#include "core/namespaces.h"
|
||||
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend).
|
||||
//
|
||||
// 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 (CLAUDE.md §contract).
|
||||
//
|
||||
// Renders a CaptureRequest's source over its requested range. The full three-scope
|
||||
// capture family (item / track / master, each over a razor-else-time range) is
|
||||
// driven here — all wet-only with optional tail. FX scope is enforced by the
|
||||
// caller (via FX-bypass-around-render / FxBypassGuard) before invoking capture;
|
||||
// this backend is source-agnostic and does not itself read the DAW selection.
|
||||
// Drives the RENDER_* project settings via GetSetProjectInfo / _String
|
||||
// (the source-selection bits come from render_settings.cpp, the pure mapping),
|
||||
// snapshots and restores every setting it changes (non-destructive), triggers a
|
||||
// render, then populates a Sample. It NEVER inserts into the arrange
|
||||
// (load-bearing principle) — RENDER_ADDTOPROJ&1 is cleared on every path.
|
||||
//
|
||||
// The backend is SOURCE-AGNOSTIC: it does NOT read the DAW selection. The action
|
||||
// layer (main.cpp) resolves each source mode to a concrete time range (+ track
|
||||
// GUIDs for track captures) and hands it in via the CaptureRequest. This keeps
|
||||
// the render-driving here and the selection-reading testable/visible up in the
|
||||
// actions layer.
|
||||
//
|
||||
// RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API):
|
||||
// Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show
|
||||
// its offline-render progress dialog (progress bar + waveform view) for the
|
||||
// duration of the render. The RENDER_SETTINGS bits documented in
|
||||
// reaper_plugin_functions.h (line ~3041) contain no "no-dialog", "headless", or
|
||||
// "suppress-progress-window" flag. No GetSetProjectInfo desc documents such a
|
||||
// flag either. There is no stock, header-verifiable mechanism to prevent REAPER
|
||||
// from showing this UI for an offline file render triggered via Main_OnCommand.
|
||||
// This is inherent to REAPER's offline render path. The dialog-free alternative
|
||||
// is the realtime-record backend (M8), which captures the master bus output to a
|
||||
// temp track during playback and never invokes the offline render pipeline.
|
||||
|
||||
#include "shell/capture/capture.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "core/util/file_bytes.h"
|
||||
#include "core/capture/render_settings.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo_String
|
||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
||||
#define REAPERAPI_WANT_Main_OnCommand
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// --- Render command / setting constants -------------------------------------
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION (open question, CONTEXT.md §Open questions): the no-dialog
|
||||
// render is triggered by the built-in action "File: Render project, using the
|
||||
// most recent render settings" — command id 42230. This is a stock REAPER main
|
||||
// action id, NOT part of reaper_plugin_functions.h, so it CANNOT be verified
|
||||
// against the SDK header; it must be confirmed in a running REAPER. It renders
|
||||
// headlessly (no dialog) using whatever RENDER_* settings are currently on the
|
||||
// project — which is exactly why we set them all explicitly first.
|
||||
constexpr int kActionRenderUsingMostRecentSettings = 42230;
|
||||
|
||||
// RENDER_BOUNDSFLAG value 0 = custom time bounds (we set STARTPOS/ENDPOS
|
||||
// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042.
|
||||
constexpr double kBoundsCustom = 0.0;
|
||||
|
||||
// RENDER_TAILFLAG / RENDER_TAILMS / RENDER_NORMALIZE / RENDER_TRIMEND for the tail
|
||||
// are driven from the pure tailRenderSettingsFor mapping (render_settings.h),
|
||||
// unit-tested outside the DAW. See the tail-driving block in capture() below.
|
||||
|
||||
// RENDER_DITHER disable-all: &16 = disable all dither/noise-shaping.
|
||||
// Verified: SDK header line ~3050: "&16=disable all".
|
||||
// Float-32 output does not need dither, but if the user's project has dither
|
||||
// enabled the render would obey it, breaking bit-identical repeats. Force off.
|
||||
constexpr double kDitherDisableAll = 16.0;
|
||||
|
||||
// --- WAV render sink configuration ------------------------------------------
|
||||
//
|
||||
// FORMAT CHOICE (CONTEXT.md open question — surfaced for Daniel to confirm):
|
||||
// 32-bit IEEE float. Rationale: float is lossless and needs NO dither, so
|
||||
// identical inputs render bit-identically (enables the M10 null test) and a dry
|
||||
// capture nulls exactly against its source. 16/24-bit int paths require dither
|
||||
// for correctness, which is nondeterministic — unacceptable for a precision tool.
|
||||
//
|
||||
// API FACT (SDK header line ~3114): GetSetProjectInfo_String("RENDER_FORMAT", ...)
|
||||
// uses the BASE64-ENCODED string form of the sink config — NOT raw binary bytes.
|
||||
// Writing raw bytes causes REAPER to silently reject the value and fall back to
|
||||
// the project's default render format (typically 16-bit/44.1 kHz). This was the
|
||||
// confirmed root cause of the M3 offline-capture regression.
|
||||
//
|
||||
// GROUND TRUTH: base64 string captured from a live REAPER configured to
|
||||
// WAV / 32-bit float. Decodes to 7 bytes: 65 76 61 77 20 00 00
|
||||
// = "evaw" (WAV fourcc, little-endian) + 0x20 (=32, the float bit-depth field)
|
||||
// + 0x00 0x00 (flags: little-endian, no BWF/loop metadata).
|
||||
constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA==";
|
||||
|
||||
// Int16 / Int24 blob strings are NOT implemented in M3 — their byte encoding
|
||||
// was not captured from a live REAPER and must not be guessed. If M7+ adds
|
||||
// them, capture the ground-truth base64 from a running REAPER first.
|
||||
//
|
||||
// Returns nullptr for unsupported depths.
|
||||
const char* wavSinkConfigBase64(WavBitDepth depth) {
|
||||
switch (depth) {
|
||||
case WavBitDepth::Float32: return kRenderFormatWavFloat32;
|
||||
case WavBitDepth::Int16: return nullptr; // M7+: capture ground-truth blob first
|
||||
case WavBitDepth::Int24: return nullptr; // M7+: capture ground-truth blob first
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// --- RENDER_* snapshot / restore --------------------------------------------
|
||||
//
|
||||
// The RENDER_* settings are project-GLOBAL: clobbering them would destroy the
|
||||
// user's render configuration. We snapshot every value we are about to change,
|
||||
// then restore all of them in the reverse order on the way out (non-destructive
|
||||
// invariant). Modeled as a small RAII guard so early returns cannot leak a
|
||||
// half-restored state.
|
||||
struct RenderSettingsSnapshot {
|
||||
ReaProject* proj = nullptr;
|
||||
|
||||
// Numeric settings (GetSetProjectInfo).
|
||||
double boundsFlag = 0.0;
|
||||
double startPos = 0.0;
|
||||
double endPos = 0.0;
|
||||
double tailFlag = 0.0;
|
||||
double tailMs = 0.0;
|
||||
double srate = 0.0;
|
||||
double channels = 0.0;
|
||||
double renderSettings = 0.0;
|
||||
double addToProj = 0.0;
|
||||
double dither = 0.0; // RENDER_DITHER — snapshotted so user's setting is restored
|
||||
double normalize = 0.0; // RENDER_NORMALIZE — snapshotted so user's setting is restored
|
||||
double trimEnd = 0.0; // RENDER_TRIMEND — snapshotted so the Auto trim threshold is restored
|
||||
|
||||
// String settings (GetSetProjectInfo_String). Big buffers: REAPER writes the
|
||||
// full value in, and RENDER_FORMAT is a base64 blob that can be long.
|
||||
std::string renderFile;
|
||||
std::string renderPattern;
|
||||
std::string renderFormat;
|
||||
|
||||
bool captured = false;
|
||||
};
|
||||
|
||||
std::string getProjString(ReaProject* proj, const char* desc) {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
GetSetProjectInfo_String(proj, desc, buf.data(), false);
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
void setProjString(ReaProject* proj, const char* desc, const std::string& value) {
|
||||
// GetSetProjectInfo_String takes a non-const char*; copy into a mutable buf.
|
||||
std::vector<char> buf(value.begin(), value.end());
|
||||
buf.push_back('\0');
|
||||
GetSetProjectInfo_String(proj, desc, buf.data(), true);
|
||||
}
|
||||
|
||||
void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) {
|
||||
s.proj = proj;
|
||||
s.boundsFlag = GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", 0.0, false);
|
||||
s.startPos = GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false);
|
||||
s.endPos = GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false);
|
||||
s.tailFlag = GetSetProjectInfo(proj, "RENDER_TAILFLAG", 0.0, false);
|
||||
s.tailMs = GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, false);
|
||||
s.srate = GetSetProjectInfo(proj, "RENDER_SRATE", 0.0, false);
|
||||
s.channels = GetSetProjectInfo(proj, "RENDER_CHANNELS", 0.0, false);
|
||||
s.renderSettings = GetSetProjectInfo(proj, "RENDER_SETTINGS", 0.0, false);
|
||||
s.addToProj = GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, false);
|
||||
s.dither = GetSetProjectInfo(proj, "RENDER_DITHER", 0.0, false);
|
||||
s.normalize = GetSetProjectInfo(proj, "RENDER_NORMALIZE", 0.0, false);
|
||||
s.trimEnd = GetSetProjectInfo(proj, "RENDER_TRIMEND", 0.0, false);
|
||||
s.renderFile = getProjString(proj, "RENDER_FILE");
|
||||
s.renderPattern = getProjString(proj, "RENDER_PATTERN");
|
||||
s.renderFormat = getProjString(proj, "RENDER_FORMAT");
|
||||
s.captured = true;
|
||||
}
|
||||
|
||||
void restoreRenderSettings(const RenderSettingsSnapshot& s) {
|
||||
if (!s.captured) return;
|
||||
// Restore strings first, then numerics — order is not load-bearing since the
|
||||
// fields are independent, but we mirror snapshot order for readability.
|
||||
setProjString(s.proj, "RENDER_FILE", s.renderFile);
|
||||
setProjString(s.proj, "RENDER_PATTERN", s.renderPattern);
|
||||
setProjString(s.proj, "RENDER_FORMAT", s.renderFormat);
|
||||
GetSetProjectInfo(s.proj, "RENDER_BOUNDSFLAG", s.boundsFlag, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_STARTPOS", s.startPos, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_ENDPOS", s.endPos, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_TAILFLAG", s.tailFlag, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_TAILMS", s.tailMs, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_SRATE", s.srate, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_CHANNELS", s.channels, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_SETTINGS", s.renderSettings, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_ADDTOPROJ", s.addToProj, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_DITHER", s.dither, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_NORMALIZE", s.normalize, true);
|
||||
GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true);
|
||||
}
|
||||
|
||||
// RAII wrapper: guarantees restore on every return path from capture().
|
||||
struct ScopedRenderSettings {
|
||||
RenderSettingsSnapshot snap;
|
||||
explicit ScopedRenderSettings(ReaProject* proj) {
|
||||
snapshotRenderSettings(snap, proj);
|
||||
}
|
||||
~ScopedRenderSettings() { restoreRenderSettings(snap); }
|
||||
ScopedRenderSettings(const ScopedRenderSettings&) = delete;
|
||||
ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete;
|
||||
};
|
||||
|
||||
// A monotonic, filesystem-safe timestamp tag so repeated captures in one session
|
||||
// do not collide on the file name. NOTE: the tag varies the file NAME, not the
|
||||
// audio bytes — bit-identical-repeat is about identical *content* for identical
|
||||
// requests; two deliberate captures naturally live in two files.
|
||||
std::string makeUniqueTag() {
|
||||
std::time_t now = std::time(nullptr);
|
||||
return std::to_string(static_cast<long long>(now));
|
||||
}
|
||||
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
|
||||
// empty on any I/O failure (the caller then leaves contentHash empty — the safe,
|
||||
// confirm-eliciting direction for an unreadable file).
|
||||
|
||||
} // namespace
|
||||
|
||||
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
CaptureResult result;
|
||||
|
||||
// Resolve the RENDER_SETTINGS source/processing bits for this mode + wet/dry
|
||||
// (pure mapping, unit-tested in render_settings). An unsupported mode (only
|
||||
// SourceMode::Realtime — that is the M8 realtime backend) is refused here so
|
||||
// the offline path never silently renders the wrong thing.
|
||||
const RenderSettingsChoice choice =
|
||||
renderSettingsFor(request.sourceMode, request.wetDry);
|
||||
if (!choice.supported) {
|
||||
result.status = CaptureStatus::UnsupportedMode;
|
||||
result.message = "OfflineRenderBackend does not render this source mode "
|
||||
"(realtime capture is the M8 backend).";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Exact bounds: reject an empty/inverted range rather than render silence.
|
||||
if (!(request.endSeconds > request.startSeconds)) {
|
||||
result.status = CaptureStatus::EmptyRange;
|
||||
result.message = "Capture range is empty (end <= start).";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Current project (idx -1 == the active project tab). Verified: SDK header
|
||||
// line ~1264, EnumProjects(int idx, char*, int).
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) {
|
||||
result.status = CaptureStatus::NoProject;
|
||||
result.message = "No active project.";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Resolve the project directory from the .rpp file path.
|
||||
//
|
||||
// Unsaved-project detection: we use EnumProjects(-1, buf, bufsz) to read
|
||||
// the project's .rpp filename. Per SDK header line ~1262:
|
||||
// EnumProjects(int idx, char* projfnOutOptional, int sz)
|
||||
// "idx=-1 for current project, projfn can be NULL if not interested in filename."
|
||||
// The out-parameter is the full path to the .rpp file, and is EMPTY for a
|
||||
// project that has never been saved — making it a reliable unsaved sentinel.
|
||||
//
|
||||
// WHY NOT GetProjectPathEx: that function returns the project *recording path*
|
||||
// (SDK header line ~2548: "Get the project recording path."), NOT the .rpp
|
||||
// location. For an unsaved project it returns REAPER's default media/recording
|
||||
// directory — never empty — so it cannot detect the unsaved state. Using it
|
||||
// caused the original bug: the guard never fired, and captures landed in
|
||||
// REAPER's default media location rather than alongside the .rpp.
|
||||
//
|
||||
// WHY NOT GetProjectPathEx for the saved-project dir: even for a saved project,
|
||||
// GetProjectPathEx returns the recording path (which may be a media subfolder),
|
||||
// not the .rpp parent directory. We need the .rpp parent so reasampler_bank/
|
||||
// sits alongside the .rpp and travels with the project.
|
||||
//
|
||||
// FLOW:
|
||||
// 1. Read .rpp path via EnumProjects(-1, buf, bufsz).
|
||||
// 2. If non-empty (saved) -> derive project dir as parent of the .rpp.
|
||||
// 3. If empty (unsaved) -> Main_SaveProject(proj, true) prompts Save-As.
|
||||
// Re-read. If now non-empty -> proceed. If still empty (user cancelled) ->
|
||||
// refuse CaptureStatus::NoProject, write nothing.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save/Save-As
|
||||
// dialog and blocks until the user dismisses it. "true" = forceSaveAsIn.
|
||||
// Verified SDK header line ~4599:
|
||||
// void Main_SaveProject(ReaProject* proj, bool forceSaveAsInOptional)
|
||||
// The blocking behaviour and dialog appearance can only be confirmed in a
|
||||
// running REAPER.
|
||||
auto readRppPath = [&]() -> std::string {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
// EnumProjects(-1, ...) returns the active project and writes the .rpp
|
||||
// path into buf. We already have the ReaProject* from the earlier call
|
||||
// (nullptr-checked above), but calling EnumProjects again is the only
|
||||
// stock, header-documented way to read the .rpp filename.
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
};
|
||||
|
||||
std::string rppPath = readRppPath();
|
||||
if (rppPath.empty()) {
|
||||
// Project is unsaved. Prompt the user to choose a save location.
|
||||
Main_SaveProject(proj, true);
|
||||
// Re-read: non-empty if the user confirmed, still empty if cancelled.
|
||||
rppPath = readRppPath();
|
||||
}
|
||||
if (rppPath.empty()) {
|
||||
// User cancelled the save dialog — refuse, write nothing.
|
||||
result.status = CaptureStatus::NoProject;
|
||||
result.message = "Project must be saved before capture — nothing captured.";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Derive the project directory as the parent folder of the .rpp file.
|
||||
// std::filesystem::path handles both forward- and back-slash paths; .parent_path()
|
||||
// gives the containing directory. Convert to forward-slash string so the rest
|
||||
// of the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path.
|
||||
const std::string projectDir = [&]() -> std::string {
|
||||
namespace fs = std::filesystem;
|
||||
std::string dir = fs::path(rppPath).parent_path().string();
|
||||
// normalizeSlashes is in capture_paths (pure); replicate the transform
|
||||
// inline here to avoid a cross-module dependency for a one-liner.
|
||||
for (char& c : dir) { if (c == '\\') c = '/'; }
|
||||
// Strip a single trailing slash (defensive; parent_path usually omits it).
|
||||
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
|
||||
return dir;
|
||||
}();
|
||||
|
||||
// Compute the unique tag ONCE so the file stem and Sample.id carry the same
|
||||
// timestamp. Calling makeUniqueTag() twice could yield different values if a
|
||||
// second boundary crosses between the two calls (bug: id and filename diverge).
|
||||
const std::string uniqueTag = makeUniqueTag();
|
||||
const BankPaths paths =
|
||||
deriveBankPaths(projectDir, request.baseName, uniqueTag);
|
||||
|
||||
// Snapshot + auto-restore ALL render settings we are about to touch.
|
||||
ScopedRenderSettings guard(proj);
|
||||
|
||||
// --- Drive the render settings (exact, deterministic) -------------------
|
||||
// Custom time bounds so the rendered length equals the requested range with
|
||||
// NO rounding and NO added silence (unless a tail was explicitly requested).
|
||||
GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true);
|
||||
GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
|
||||
GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
|
||||
|
||||
// Tail: TAILFLAG / TAILMS / NORMALIZE / TRIMEND all come from the pure mapping
|
||||
// (render_settings.h, unit-tested). None -> exact bounds + disable-all normalize
|
||||
// (byte-identical to the pre-tail path); Auto -> 8 s tail + surgical trim-end
|
||||
// normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no trim.
|
||||
// RENDER_NORMALIZE is driven HERE from the mapping (not the determinism block
|
||||
// below) so the Auto surgical value is not clobbered — the snapshot guard restores
|
||||
// the user's original RENDER_NORMALIZE / RENDER_TRIMEND on every exit path.
|
||||
const TailRenderSettings tail =
|
||||
tailRenderSettingsFor(request.tailMode, request.tailMs);
|
||||
GetSetProjectInfo(proj, "RENDER_TAILFLAG",
|
||||
static_cast<double>(tail.tailFlag), true);
|
||||
GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);
|
||||
|
||||
// Source-selection bits for this mode, from the pure render_settings mapping
|
||||
// (verified against SDK header ~3041). All M7 actions are wet-only:
|
||||
// master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file.
|
||||
GetSetProjectInfo(proj, "RENDER_SETTINGS",
|
||||
static_cast<double>(choice.settings), true);
|
||||
|
||||
// Resolve the effective sample rate. When the request carries 0 ("follow
|
||||
// project"), read PROJECT_SRATE explicitly so RENDER_SRATE is set to the
|
||||
// actual value — not left as 0 for REAPER to interpret. SDK header line ~3064:
|
||||
// PROJECT_SRATE = sample rate (ignored unless PROJECT_SRATE_USE set); the
|
||||
// value is still readable via GetSetProjectInfo even when _USE is clear.
|
||||
const int effectiveSampleRate = (request.sampleRate > 0)
|
||||
? request.sampleRate
|
||||
: static_cast<int>(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false));
|
||||
|
||||
// Pin RENDER_SRATE only when the resolved rate is known (> 0). PROJECT_SRATE
|
||||
// can read 0 on a project that has never explicitly pinned a sample rate (e.g.
|
||||
// brand-new projects before the user has visited the project settings). Forcing
|
||||
// RENDER_SRATE = 0 would re-introduce the "0 as literal" trap we fixed by
|
||||
// moving away from blind passthrough. When the rate is unknown, leave
|
||||
// RENDER_SRATE unset so REAPER follows its own project-rate default — which is
|
||||
// correct behaviour for that project — rather than pinning a bogus 0.
|
||||
if (effectiveSampleRate > 0) {
|
||||
GetSetProjectInfo(proj, "RENDER_SRATE",
|
||||
static_cast<double>(effectiveSampleRate), true);
|
||||
}
|
||||
GetSetProjectInfo(proj, "RENDER_CHANNELS",
|
||||
static_cast<double>(request.channelCount), true);
|
||||
|
||||
// Load-bearing principle: do NOT add the rendered file to the project as an
|
||||
// item. Clearing RENDER_ADDTOPROJ&1 keeps capture out of the arrange.
|
||||
GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);
|
||||
|
||||
// Determinism: disable dither so identical inputs produce bit-identical files
|
||||
// and a dry capture nulls to silence. RENDER_DITHER &16 = disable all dither/
|
||||
// noise-shaping (SDK header line ~3050). Snapshotted above; restored by the guard.
|
||||
GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);
|
||||
|
||||
// RENDER_NORMALIZE + RENDER_TRIMEND come from the tail mapping (above). None /
|
||||
// Manual -> disable-all (byte-identical to the pre-tail path); Auto -> surgical
|
||||
// trim-end (only &32768) + the -72 dB TRIMEND. A fixed-threshold trailing-silence
|
||||
// trim scales/limits/fades nothing, so Auto stays deterministic and un-coloring
|
||||
// (spec §surgical normalize). TRIMEND is only consulted when the trim bit is set,
|
||||
// but we write it unconditionally (harmless when clear) so the value is explicit.
|
||||
GetSetProjectInfo(proj, "RENDER_NORMALIZE",
|
||||
static_cast<double>(tail.normalize), true);
|
||||
GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true);
|
||||
|
||||
// Output location: directory (RENDER_FILE) + file stem (RENDER_PATTERN).
|
||||
// RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the
|
||||
// format extension. Use paths.fileStem — capture_paths owns the .wav suffix
|
||||
// knowledge; re-stripping here would duplicate that coupling.
|
||||
setProjString(proj, "RENDER_FILE", paths.absoluteDir);
|
||||
setProjString(proj, "RENDER_PATTERN", paths.fileStem);
|
||||
|
||||
// Pin the WAV format using the ground-truth base64 blob for the chosen depth.
|
||||
// Int16/Int24 are not implemented (no live-captured blob) — fail explicitly
|
||||
// rather than silently mis-render at the wrong bit depth.
|
||||
const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth);
|
||||
if (!fmtBase64) {
|
||||
result.status = CaptureStatus::UnsupportedFormat;
|
||||
result.message = "Requested bit depth has no verified RENDER_FORMAT blob "
|
||||
"(M3 supports Float32 only; Int16/Int24 are M7+).";
|
||||
return result;
|
||||
// guard's dtor restores every RENDER_* setting here.
|
||||
}
|
||||
setProjString(proj, "RENDER_FORMAT", fmtBase64);
|
||||
|
||||
// --- Trigger the render -------------------------------------------------
|
||||
// DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs
|
||||
// the render synchronously on the current build. REAPER will show its
|
||||
// offline-render progress window for the duration (see file-top comment —
|
||||
// the progress UI is not suppressible via stock API).
|
||||
Main_OnCommand(kActionRenderUsingMostRecentSettings, 0);
|
||||
|
||||
// --- Verify the output file exists ---------------------------------------
|
||||
// Main_OnCommand returns void, so a failed render is silent. Stat the
|
||||
// expected output path; if the file does not exist the render failed.
|
||||
// Note: std::filesystem is used only in this REAPER-facing .cpp — the pure
|
||||
// libs (capture_paths, bank_model) remain filesystem-free.
|
||||
const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName;
|
||||
if (!std::filesystem::exists(expectedPath)) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Render produced no output file (expected: " +
|
||||
expectedPath + "). Check the REAPER console for errors.";
|
||||
return result;
|
||||
// guard's dtor restores every RENDER_* setting here.
|
||||
}
|
||||
|
||||
// --- Populate the Sample -------------------------------------------------
|
||||
// We record the request's own bounds (exact) rather than re-measuring the
|
||||
// file, so the Sample's range is precisely what was asked for.
|
||||
Sample s;
|
||||
// Use the same uniqueTag that named the file — calling makeUniqueTag() again
|
||||
// here would risk a different timestamp if a second boundary crosses between
|
||||
// the two calls, making Sample.id inconsistent with the file name.
|
||||
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
|
||||
s.displayName = request.baseName;
|
||||
s.relativePath = paths.relativePath; // project-relative (invariant)
|
||||
s.sourceMode = request.sourceMode;
|
||||
s.sourceRange.startSeconds = request.startSeconds;
|
||||
s.sourceRange.endSeconds = request.endSeconds;
|
||||
// DEFERRED (M6/M7): startPpq, endPpq, and lengthBeats are left at 0.
|
||||
// PPQ mapping via TimeMap2_timeToBeats is a musical-placement concern for the
|
||||
// insert milestone; the model refuses to re-derive one bound from the other.
|
||||
// Seconds are the authoritative source for the render. Do NOT add DAW-
|
||||
// unverifiable PPQ resolution here — it requires a live REAPER to validate.
|
||||
s.wetDry = request.wetDry;
|
||||
// Track GUIDs for track-scoped captures (empty for master/items/razor). The
|
||||
// caller resolved the selection to canonical GUID strings; we record them so a
|
||||
// "re-capture from source" (M10) knows which tracks the sample came from.
|
||||
s.trackGuids = request.trackGuids;
|
||||
s.channelCount = request.channelCount;
|
||||
// Store the resolved sample rate only when it is known (> 0). If the project
|
||||
// never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE
|
||||
// either, so the render ran at REAPER's project default — an unknown value from
|
||||
// this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather
|
||||
// than store a bogus literal; M6/M7 can fill it in by probing the rendered file.
|
||||
s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown
|
||||
s.lengthSeconds = request.endSeconds - request.startSeconds;
|
||||
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
|
||||
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
|
||||
// (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time,
|
||||
// int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at that
|
||||
// project time, so a sample captured under 3/4 keeps a 3/4 read-out even if the
|
||||
// project later switches to 4/4. proj=nullptr => the active project (matches the
|
||||
// Master_GetTempo() call above, which is also active-project). The tempoOut is
|
||||
// ignored — captureTempo already carries the master tempo. Leaves 0/0 (unstamped)
|
||||
// if the API is somehow unavailable; the formatter renders a blank musical read-out.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
TimeMap_GetTimeSigAtTime(nullptr, request.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
||||
s.captureTimeSigNum = tsNum;
|
||||
s.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
||||
// Content hash: WAV-aware FNV-1a over the rendered file's fmt+data chunks so
|
||||
// hashReferencedElsewhere can identify copies in other banks and suppress the
|
||||
// last-reference confirm when another bank still holds the same file. Using
|
||||
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
|
||||
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders of identical
|
||||
// audio collapse to the same hash. Best-effort: an unreadable file leaves
|
||||
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
|
||||
// "" as non-participating in dedup, which is the existing fallback semantics).
|
||||
{
|
||||
const std::vector<std::uint8_t> fileBytes = readFileBytes(expectedPath);
|
||||
if (!fileBytes.empty()) {
|
||||
s.contentHash = hashWavContent(fileBytes);
|
||||
}
|
||||
}
|
||||
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
// Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a
|
||||
// master mix / track / time-selection is not a single played note, so no root
|
||||
// note is derivable here — we do NOT guess one. Loop points are set later by an
|
||||
// explicit user action, not at capture. Leaving them empty is the honest default;
|
||||
// the instrument (Phase S) treats an absent root note as "not a pitched sample".
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = s;
|
||||
result.message = "Captured [" +
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] -> " +
|
||||
paths.relativePath;
|
||||
return result;
|
||||
// guard's dtor restores every RENDER_* setting here.
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,235 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split).
|
||||
//
|
||||
// This header declares the capture *seam* the later milestones fill:
|
||||
// * CaptureRequest — everything a capture needs, source-mode-agnostic.
|
||||
// * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements
|
||||
// (headless, immediate, returns a finished Sample).
|
||||
// * OfflineRenderBackend — the deterministic default; drives the offline scopes.
|
||||
// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven
|
||||
// across timer ticks; deliberately NOT an ICaptureBackend
|
||||
// (see the SEAM CHOICE note at its declaration).
|
||||
//
|
||||
// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER
|
||||
// headers — the .cpp is the REAPER-facing translation unit. Keeping this header
|
||||
// REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam
|
||||
// without dragging the SDK into every include site.
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract
|
||||
|
||||
// MediaTrack is forward-declared (like track_guid.h) so this header stays
|
||||
// REAPER-free while RealtimeRecordBackend::begin can take the resolved source
|
||||
// MediaTrack* to tap. The pointers are opaque here — never dereferenced in a
|
||||
// pure/header context; only the REAPER-facing capture_realtime.cpp touches them.
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Audio bit-depth for the rendered wav. 32-bit float is the M3 default —
|
||||
// rationale lives in capture.cpp next to the sink-config bytes.
|
||||
enum class WavBitDepth {
|
||||
Int16,
|
||||
Int24,
|
||||
Float32,
|
||||
};
|
||||
|
||||
// One capture, independent of source mode. Populated by the caller (the action
|
||||
// handler in M3; the action family in M7) and consumed by a backend.
|
||||
//
|
||||
// M3 fills only the fields the master-mix/time-selection path needs; the rest
|
||||
// are declared now so M7/M8 do not reshape the struct (they are the seam).
|
||||
struct CaptureRequest {
|
||||
SourceMode sourceMode = SourceMode::MasterMix;
|
||||
|
||||
// Sample-accurate render bounds in project seconds. For the M3 spike these
|
||||
// come straight from the time selection (GetSet_LoopTimeRange) — NO rounding.
|
||||
double startSeconds = 0.0;
|
||||
double endSeconds = 0.0;
|
||||
|
||||
// 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet).
|
||||
// The field is kept as the seam for future true-dry work (M10 null test):
|
||||
// true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires
|
||||
// FX-bypass-around-render or the M8 realtime pre-FX path, and will be
|
||||
// designed alongside the M10 null test. Also recorded on the Sample.
|
||||
double wetDry = 1.0;
|
||||
|
||||
// Track GUID(s) the capture came from, when the source mode is track-scoped
|
||||
// (SelectedTracks). Empty for master/items/razor. The action layer (M7)
|
||||
// resolves the selection to canonical GUID strings and passes them here; the
|
||||
// backend copies them onto the Sample (it does NOT itself read the selection —
|
||||
// it stays source-agnostic, driven entirely by the request).
|
||||
std::vector<std::string> trackGuids;
|
||||
|
||||
// Render tail (docs/product/capture-tail.md §The three tail states). Default
|
||||
// None: exact bounds, no added silence — the precision invariant, and the only
|
||||
// mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for
|
||||
// TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the
|
||||
// 8 s cap + -72 dB trim internally, None ignores it.
|
||||
TailMode tailMode = TailMode::None;
|
||||
double tailMs = 0.0;
|
||||
|
||||
// Output format. 0 sampleRate => follow project rate (deterministic: the
|
||||
// project rate is fixed for a given project).
|
||||
int sampleRate = 0;
|
||||
int channelCount = 2;
|
||||
WavBitDepth bitDepth = WavBitDepth::Float32;
|
||||
|
||||
// Human base name for the file stem; sanitized by capture_paths. The unique
|
||||
// tag (disambiguator) is supplied separately by the backend caller so the
|
||||
// pure naming logic stays testable.
|
||||
std::string baseName = "capture";
|
||||
std::string uniqueTag; // e.g. a timestamp/counter; may be empty
|
||||
};
|
||||
|
||||
// Outcome of a capture attempt. `Ok` carries the populated Sample; every failure
|
||||
// is an explicit code (never a thrown exception across the REAPER boundary) so
|
||||
// the action handler can log a precise reason.
|
||||
enum class CaptureStatus {
|
||||
Ok,
|
||||
NoProject, // no active project to render / resolve a bank folder
|
||||
EmptyRange, // start >= end: nothing to render
|
||||
UnsupportedMode, // backend does not implement this source mode (M3 scope)
|
||||
UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only)
|
||||
RenderFailed, // the render action ran but produced no output file
|
||||
TransportBusy, // realtime backend: transport already playing/recording — refused
|
||||
};
|
||||
|
||||
struct CaptureResult {
|
||||
CaptureStatus status = CaptureStatus::RenderFailed;
|
||||
Sample sample; // valid only when status == Ok
|
||||
std::string message; // human-readable detail for the console log
|
||||
};
|
||||
|
||||
// The capture seam. One method: run a request, return a populated Sample (or a
|
||||
// failure code). Backends are non-destructive — they must restore any global
|
||||
// state they touch before returning (OfflineRenderBackend snapshots/restores the
|
||||
// RENDER_* project settings).
|
||||
class ICaptureBackend {
|
||||
public:
|
||||
virtual ~ICaptureBackend() = default;
|
||||
virtual CaptureResult capture(const CaptureRequest& request) = 0;
|
||||
};
|
||||
|
||||
// Deterministic offline-render backend. Drives the full offline source family —
|
||||
// master mix / time selection, selected tracks, selected items, razor area — all
|
||||
// wet-only (render_settings.h) with optional tail. The source selection + range
|
||||
// are resolved by the caller (the action layer) and handed in via the
|
||||
// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection
|
||||
// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend).
|
||||
class OfflineRenderBackend : public ICaptureBackend {
|
||||
public:
|
||||
CaptureResult capture(const CaptureRequest& request) override;
|
||||
};
|
||||
|
||||
// --- Realtime-record backend: the ASYNC seam ---------------------------------
|
||||
//
|
||||
// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport
|
||||
// on REAPER's audio thread and returns immediately — it does NOT block until the
|
||||
// range completes, which takes (end - start) wall-clock seconds. Blocking the main
|
||||
// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN
|
||||
// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called
|
||||
// from the same OnTimer that runs session.poll()) advances the in-flight record and
|
||||
// reports when it is done.
|
||||
//
|
||||
// SEAM CHOICE (surfaced): RealtimeRecordBackend deliberately does NOT implement the
|
||||
// synchronous ICaptureBackend — that interface returns a finished Sample from one
|
||||
// call, which no longer fits a record that spans ticks. The two backends have
|
||||
// genuinely different lifecycles (offline is headless + immediate; realtime is
|
||||
// transport-driven + async), so forcing a shared async interface would make offline
|
||||
// fake a lifecycle it does not have (its tick() would always be Done on the first
|
||||
// call — dead code / an LSP smell). Offline stays synchronous and unchanged; the
|
||||
// realtime backend owns this small bespoke async seam, driven by exactly one caller
|
||||
// (main.cpp's OnTimer). This is the split-sync/async fork, chosen over a unified
|
||||
// async interface for that reason.
|
||||
|
||||
// One tick's verdict from the in-flight record.
|
||||
enum class RealtimeTickStatus {
|
||||
InProgress, // still recording — call tick() again next timer tick
|
||||
Done, // finished (range end reached, or the user stopped) — `result` is set
|
||||
Failed, // an error tore the capture down — `result.message` explains
|
||||
};
|
||||
|
||||
struct RealtimeTickResult {
|
||||
RealtimeTickStatus status = RealtimeTickStatus::InProgress;
|
||||
CaptureResult result; // meaningful only when status == Done or Failed
|
||||
};
|
||||
|
||||
// The opaque in-flight capture state. Owns the snapshot of everything to restore
|
||||
// (temp track + its receive sends from the source tracks, other tracks' I_RECARM,
|
||||
// transport, edit cursor, time selection) and the record's own project handle.
|
||||
// Defined in
|
||||
// capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject*
|
||||
// leaks here) by holding it behind a forward-declared type + unique_ptr.
|
||||
//
|
||||
// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope
|
||||
// RAII guard) because the record spans ticks — no single stack frame outlives it.
|
||||
// Every terminal path (normal completion, user stop, error, project switch, unload)
|
||||
// funnels through the same single restore, safe to call once from whichever fires.
|
||||
class RealtimeCaptureState;
|
||||
|
||||
// Out-of-line deleter so callers (main.cpp) can own a unique_ptr to the opaque
|
||||
// RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the delete is
|
||||
// compiled in capture_realtime.cpp where the type is complete, keeping this header
|
||||
// REAPER-free (load-bearing split).
|
||||
struct RealtimeCaptureStateDeleter {
|
||||
void operator()(RealtimeCaptureState* p) const noexcept;
|
||||
};
|
||||
using RealtimeCaptureHandle =
|
||||
std::unique_ptr<RealtimeCaptureState, RealtimeCaptureStateDeleter>;
|
||||
|
||||
// Realtime-record backend — captures by RECORDING in realtime (transport-driven)
|
||||
// into a hidden temp track, then moves the recorded file into the bank as a Sample.
|
||||
// For sources offline render cannot do (hardware, performed FX) and as the true
|
||||
// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline
|
||||
// render has none). Dialog-free: never invokes the offline-render progress window.
|
||||
//
|
||||
// Non-bit-identical by nature (it is realtime); offline stays the deterministic
|
||||
// default. Non-destructive across EVERY terminal path — the review gate — which is
|
||||
// harder here than offline because the record spans ticks: the snapshot + restore
|
||||
// live on RealtimeCaptureState, not a function-scope RAII destructor.
|
||||
//
|
||||
// SCOPE (this increment): TRACK scope only — records the selected track's OWN
|
||||
// output (item + that track's own FX + its own fader/pan, PRE-parent), matching
|
||||
// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's
|
||||
// output is naturally PRE-parent (the parent has not summed it yet), so the tap is
|
||||
// chain-independent by construction. Item realtime is deferred (UnsupportedMode).
|
||||
class RealtimeRecordBackend {
|
||||
public:
|
||||
// Starts a realtime record: validates the request (track scope, non-empty range,
|
||||
// at least one source track, active + saved project, transport idle), snapshots
|
||||
// all state to restore, creates the hidden temp track, routes a send FROM each
|
||||
// source track INTO the temp track, arms, and CSurf_OnRecord — then returns
|
||||
// IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to
|
||||
// tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free,
|
||||
// carrying only the provenance GUIDs). On success the returned unique_ptr owns the
|
||||
// in-flight state; drive it with tick(). On a validation/setup failure returns
|
||||
// nullptr and fills `outFailure` with the CaptureStatus + message (nothing was
|
||||
// left mutated — begin() restores on its own failure paths).
|
||||
RealtimeCaptureHandle begin(const CaptureRequest& request,
|
||||
const std::vector<MediaTrack*>& sourceTracks,
|
||||
CaptureResult& outFailure);
|
||||
|
||||
// Advances the in-flight record one tick. Reads the transport (bound to the
|
||||
// record's OWN project handle so a project switch cannot confuse it), and on a
|
||||
// terminal verdict stops the transport, finalizes the recorded file into the
|
||||
// bank Sample (Done) or reports the failure (Failed), then restores ALL
|
||||
// snapshotted state. Returns InProgress while the record is still running.
|
||||
// After Done/Failed the state is spent — the caller drops the unique_ptr.
|
||||
RealtimeTickResult tick(RealtimeCaptureState& state);
|
||||
|
||||
// Force-terminate an in-flight record NOW without waiting for the range end:
|
||||
// stops the transport, finalizes whatever was captured (best effort) or abandons
|
||||
// it, and restores ALL snapshotted state. For the shutdown / project-switch
|
||||
// paths (extension unload, a new project became active) where the record must
|
||||
// not leak a temp track / armed track / altered transport into the user's
|
||||
// project. Idempotent — safe even if a prior tick already tore the state down.
|
||||
RealtimeTickResult abort(RealtimeCaptureState& state);
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,858 @@
|
||||
#include "core/namespaces.h"
|
||||
// capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend).
|
||||
//
|
||||
// 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 (CLAUDE.md §contract).
|
||||
//
|
||||
// Captures the requested scope over the requested range by RECORDING in realtime
|
||||
// (transport-driven) into a hidden temp track, then moves the recorded file into
|
||||
// the bank as a Sample — non-destructively. This increment implements the TRACK
|
||||
// scope only (records the selected track's own output). Item realtime is deferred
|
||||
// (UnsupportedMode) rather than silently half-built.
|
||||
//
|
||||
// ============================================================================
|
||||
// §ASYNC — timer-driven, no UI block (M8 rework — Daniel: "do it right")
|
||||
// ============================================================================
|
||||
// A realtime record takes (end - start) wall-clock seconds. The earlier spike ran a
|
||||
// bounded MAIN-THREAD wait for the transport to reach the range end — which FREEZES
|
||||
// REAPER's UI for the whole record. That is gone. The record is now driven across
|
||||
// timer ticks:
|
||||
// begin() — validate, snapshot ALL state to restore, create the temp track,
|
||||
// route the source-track tap, arm, CSurf_OnRecord, RETURN IMMEDIATELY.
|
||||
// tick() — (from OnTimer, the same tick as session.poll()) read the transport,
|
||||
// and on a terminal verdict stop + finalize/abort + RESTORE everything.
|
||||
// abort() — force-terminate now (shutdown / project switch) + RESTORE everything.
|
||||
//
|
||||
// The snapshot + restore live on RealtimeCaptureState (below), NOT a function-scope
|
||||
// RAII guard — because the record spans ticks, no single stack frame outlives it.
|
||||
// restore() is idempotent (a restored_ latch): every terminal path — normal
|
||||
// completion, user stop, error, second-capture reject, project switch, unload —
|
||||
// funnels through the SAME single restore, safe to call once from whichever fires.
|
||||
// The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the
|
||||
// completion state machine (advanceRecordPhase) all live in realtime_record.{h,cpp}
|
||||
// (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe.
|
||||
//
|
||||
// ============================================================================
|
||||
// §TAP — track-output tap (selected track's own output, PRE-parent)
|
||||
// ============================================================================
|
||||
// The recipe: the hidden temp track RECEIVES a send FROM each selected source track
|
||||
// (CreateTrackSend(source, temp)). The temp track records its OWN output
|
||||
// (I_RECMODE 3/6, latency-compensated) with B_MAINSEND=0 (it does NOT sum back into
|
||||
// the master — no feedback, no monitoring double). Multiple selected tracks each get
|
||||
// a send into the one temp track, so their outputs SUM in the temp track — matching
|
||||
// how offline track scope handles a multi-track selection.
|
||||
//
|
||||
// WHY THIS FAITHFULLY CAPTURES THE TRACK'S OUTPUT — and why NO FxBypassGuard:
|
||||
// A CreateTrackSend defaults to I_SENDMODE=0 (post-fader) with I_SRCCHAN=0
|
||||
// (channel offset 0, (srcchan>>10)==0 => full stereo — SDK ~3302/3304). Post-fader
|
||||
// taps the source track AFTER its own FX and AFTER its own fader/pan — i.e. exactly
|
||||
// the track's OWN OUTPUT — but BEFORE the parent/folder/master sums it. The send is
|
||||
// a branch off the signal at the track's output stage; the parent chain downstream
|
||||
// of that branch is not in the tapped path AT ALL. So the tap is chain-independent
|
||||
// BY CONSTRUCTION: there is nothing to neutralize, and FxBypassGuard (which mutates
|
||||
// the live chain, altering the user's monitoring) is deliberately NOT used. This is
|
||||
// the realtime analogue of offline track scope (item + the track's own FX + its own
|
||||
// fader/pan; parent/folder/master excluded), reached without touching any live FX.
|
||||
//
|
||||
// This ALSO fixes the earlier silent-file bug: that spike sent FROM the master INTO
|
||||
// a temp track, which REAPER refuses to carry (master->track is a feedback loop), so
|
||||
// the temp recorded silence. A regular track->track send has no feedback — it works.
|
||||
//
|
||||
// Non-destructive: the temp track is deleted on teardown, which removes every send we
|
||||
// created INTO it (REAPER cannot leave a send dangling to a deleted destination) — so
|
||||
// NO source track retains any routing change. We never mutate any existing track's
|
||||
// persistent state; we only add sends FROM the source tracks that vanish with the
|
||||
// temp track. The selected source tracks are UNCHANGED after capture.
|
||||
//
|
||||
// Item realtime is deferred (UnsupportedMode): item scope would need per-item take
|
||||
// isolation on top of the tap, which is a separate increment.
|
||||
|
||||
#include "shell/capture/capture.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // hashBytes, deriveBankPaths
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "core/audio/peaks.h" // lastFrameAboveThreshold, AudioSample
|
||||
#include "core/capture/realtime_record.h"
|
||||
#include "core/capture/render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
|
||||
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_InsertTrackAtIndex
|
||||
#define REAPERAPI_WANT_DeleteTrack
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_CreateTrackSend
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_GetTrackNumMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
||||
#define REAPERAPI_WANT_CSurf_OnRecord
|
||||
#define REAPERAPI_WANT_OnStopButtonEx
|
||||
#define REAPERAPI_WANT_GetPlayStateEx
|
||||
#define REAPERAPI_WANT_GetPlayPositionEx
|
||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
||||
#define REAPERAPI_WANT_GetCursorPosition
|
||||
#define REAPERAPI_WANT_SetEditCurPos
|
||||
#define REAPERAPI_WANT_ValidatePtr2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// A monotonic, filesystem-safe timestamp tag so repeated captures do not collide.
|
||||
std::string makeUniqueTag() {
|
||||
std::time_t now = std::time(nullptr);
|
||||
return "rt-" + std::to_string(static_cast<long long>(now));
|
||||
}
|
||||
|
||||
std::string normSlashes(std::string s) {
|
||||
for (char& c : s) if (c == '\\') c = '/';
|
||||
if (s.size() > 1 && s.back() == '/') s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// Reads the ACTIVE project's .rpp path (empty if unsaved). Only needed at begin()
|
||||
// time, when the record's project IS the active project.
|
||||
std::string readRppPath() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
// Discovers the file REAPER actually recorded onto the temp track: the first media
|
||||
// item's active take's source file. Empty string if nothing was recorded.
|
||||
std::string recordedFilePath(MediaTrack* temp) {
|
||||
if (!temp) return {};
|
||||
if (GetTrackNumMediaItems(temp) <= 0) return {};
|
||||
MediaItem* item = GetTrackMediaItem(temp, 0);
|
||||
if (!item) return {};
|
||||
MediaItem_Take* take = GetMediaItemTake(item, 0);
|
||||
if (!take) return {};
|
||||
PCM_source* src = GetMediaItemTake_Source(take);
|
||||
if (!src) return {};
|
||||
std::vector<char> buf(4096, '\0');
|
||||
GetMediaSourceFileName(src, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
// The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no
|
||||
// item/take/source, or the file does not exist on disk this tick). Used by the flush
|
||||
// wait to detect stability (size unchanged across a tick) BEFORE moving the file — a
|
||||
// take REAPER is still flushing on the audio thread grows tick over tick.
|
||||
std::int64_t recordedFileSize(MediaTrack* temp) {
|
||||
const std::string path = recordedFilePath(temp);
|
||||
if (path.empty()) return -1;
|
||||
std::error_code ec;
|
||||
const auto sz = std::filesystem::file_size(path, ec);
|
||||
if (ec) return -1;
|
||||
return static_cast<std::int64_t>(sz);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ============================================================================
|
||||
// RealtimeCaptureState — the in-flight snapshot + idempotent restore
|
||||
// ============================================================================
|
||||
// Holds EVERYTHING to restore across the many ticks the record spans (temp track +
|
||||
// its receive-sum sends, other tracks' I_RECARM, transport, edit cursor, time selection),
|
||||
// plus the request echo needed to finalize the Sample. restore() is idempotent
|
||||
// (restored_ latch) and is the single teardown every terminal path calls.
|
||||
class RealtimeCaptureState {
|
||||
public:
|
||||
// Bound at begin(): the record's OWN project (transport reads use *Ex(proj_) so
|
||||
// a project switch mid-record cannot read the wrong transport), the request
|
||||
// echo, and the resolved bank paths + tag for finalize.
|
||||
ReaProject* proj_ = nullptr;
|
||||
CaptureRequest request_;
|
||||
BankPaths paths_;
|
||||
std::string uniqueTag_;
|
||||
|
||||
// The RECORDED window end in project seconds (>= request_.endSeconds). For a tail
|
||||
// mode the transport runs PAST the range end (Auto: +8 s cap; Manual: +the set
|
||||
// length), so this — not request_.endSeconds — is the end the completion state
|
||||
// machine waits for. Equals request_.endSeconds for TailMode::None (exact bounds).
|
||||
double recordWindowEnd_ = 0.0;
|
||||
|
||||
// The transient sink. The sends we create (from each selected source track INTO
|
||||
// temp_) live on those source tracks pointing AT temp_, and are removed automatically
|
||||
// when temp_ is deleted — REAPER cannot leave a send dangling to a deleted
|
||||
// destination. So there is no separate send handle to track here.
|
||||
MediaTrack* temp_ = nullptr;
|
||||
|
||||
// The record phase (pure state machine drives the transition). Starts Recording.
|
||||
RecordPhase phase_ = RecordPhase::Recording;
|
||||
|
||||
// Wall-clock anchors for the pure machine's safety ceilings (a steady clock — not
|
||||
// the play cursor — so a stuck/looping transport is still caught, review §3).
|
||||
// begunAt_ is set at begin(); finalizingAt_ is set on the Recording->Finalizing
|
||||
// edge (the transport stop) so the flush wait is bounded from the stop, not begin.
|
||||
std::chrono::steady_clock::time_point begunAt_{};
|
||||
std::chrono::steady_clock::time_point finalizingAt_{};
|
||||
|
||||
// Deferred-finalize (review §2) flush tracking: the recorded file's size the
|
||||
// previous tick, so "size unchanged across a tick" signals REAPER finished
|
||||
// flushing/closing the take. -1 = not yet seen.
|
||||
std::int64_t lastFileSize_ = -1;
|
||||
|
||||
void markElapsedStart() { begunAt_ = std::chrono::steady_clock::now(); }
|
||||
double elapsedSeconds() const {
|
||||
return std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now() - begunAt_).count();
|
||||
}
|
||||
// Set the flush-wait anchor once, on the first Finalizing tick.
|
||||
void markFinalizingStartOnce() {
|
||||
if (finalizingAt_.time_since_epoch().count() == 0)
|
||||
finalizingAt_ = std::chrono::steady_clock::now();
|
||||
}
|
||||
double finalizingSeconds() const {
|
||||
if (finalizingAt_.time_since_epoch().count() == 0) return 0.0;
|
||||
return std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now() - finalizingAt_).count();
|
||||
}
|
||||
|
||||
// Snapshot of state to restore. Filled at begin(), replayed once by restore().
|
||||
double curPos_ = 0.0;
|
||||
double tsStart_ = 0.0;
|
||||
double tsEnd_ = 0.0;
|
||||
struct ArmSnap { MediaTrack* track; double recarm; };
|
||||
std::vector<ArmSnap> armSnaps_;
|
||||
|
||||
// Snapshot the transport-adjacent state (cursor + time selection) and every
|
||||
// OTHER track's arm, disarming them so only our sink records. Call ONCE, before
|
||||
// the temp track exists (so the temp track is never in the arm snapshot).
|
||||
void snapshotAndDisarmOthers() {
|
||||
curPos_ = GetCursorPosition();
|
||||
GetSet_LoopTimeRange(false, false, &tsStart_, &tsEnd_, false);
|
||||
const int n = CountTracks(proj_);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaTrack* tr = GetTrack(proj_, i);
|
||||
if (!tr) continue;
|
||||
const double armed = GetMediaTrackInfo_Value(tr, "I_RECARM");
|
||||
if (armed != 0.0) {
|
||||
armSnaps_.push_back({tr, armed});
|
||||
SetMediaTrackInfo_Value(tr, "I_RECARM", 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The single, idempotent teardown. Called on EVERY terminal path (normal
|
||||
// completion, user stop, error, project switch, unload). Safe to call more than
|
||||
// once — the restored_ latch makes every call after the first a no-op. Order:
|
||||
// 1. stop the transport if anything is still running (we own it),
|
||||
// 2. delete the temp track (drops its receive-sum sends + the recorded item),
|
||||
// 3. restore every other track's arm,
|
||||
// 4. restore the time selection + edit cursor.
|
||||
// Stop the record's OWN project transport if it is still playing/recording. Uses
|
||||
// the project-scoped OnStopButtonEx(proj_) (not the global CSurf_OnStop) so a
|
||||
// project switch mid-record — where proj_ is no longer the ACTIVE project — stops
|
||||
// OUR project's transport, never the foreign now-active one. &1=playing,
|
||||
// &4=recording. Idempotent to call (the playstate guard makes a repeat a no-op).
|
||||
void stopOwnTransport() {
|
||||
if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_);
|
||||
}
|
||||
|
||||
// Is the captured project STILL OPEN? (review §1 — CRITICAL). If the captured
|
||||
// project was CLOSED mid-record, proj_/temp_ point at freed memory;
|
||||
// touching them (stopOwnTransport, DeleteTrack, arm restore) is a use-after-free.
|
||||
// ValidatePtr2 with a null project validates the ReaProject* itself (the header:
|
||||
// "proj is ignored if pointer is itself a project"). Every teardown that
|
||||
// dereferences a captured REAPER object MUST gate on this first.
|
||||
bool captureProjectStillOpen() const {
|
||||
return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*");
|
||||
}
|
||||
|
||||
// Drop the handle WITHOUT touching any REAPER state — for the closed-project case
|
||||
// (review §1). A closed project already reclaimed its temp track, arms, and
|
||||
// transport; there is nothing to restore and the pointers are freed. Latch
|
||||
// restored_ so any later terminal path is a no-op (idempotent), but skip every
|
||||
// REAPER call restore() would make.
|
||||
void dropWithoutRestore() {
|
||||
restored_ = true;
|
||||
temp_ = nullptr;
|
||||
armSnaps_.clear();
|
||||
}
|
||||
|
||||
void restore() {
|
||||
if (restored_) return;
|
||||
restored_ = true;
|
||||
|
||||
// 1. Transport: stop OUR project's if still running (usually already stopped
|
||||
// by the terminal path's explicit stop-before-finalize — a safe no-op then).
|
||||
stopOwnTransport();
|
||||
|
||||
// 2. Temp track: deleting it drops the source-track sends (REAPER removes every
|
||||
// send whose destination is deleted — no source track is left mutated) AND the
|
||||
// recorded arrange item in one move — nothing stays behind (load-bearing).
|
||||
if (temp_) { DeleteTrack(temp_); temp_ = nullptr; }
|
||||
|
||||
// 3. Other tracks' record-arm.
|
||||
for (const ArmSnap& s : armSnaps_)
|
||||
SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm);
|
||||
armSnaps_.clear();
|
||||
|
||||
// 4. Time selection + edit cursor (no view move, no seek).
|
||||
GetSet_LoopTimeRange(true, false, &tsStart_, &tsEnd_, false);
|
||||
SetEditCurPos(curPos_, false, false);
|
||||
}
|
||||
|
||||
bool restored() const { return restored_; }
|
||||
bool finalized() const { return finalized_; }
|
||||
void markFinalized() { finalized_ = true; }
|
||||
|
||||
private:
|
||||
bool restored_ = false;
|
||||
bool finalized_ = false;
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
|
||||
// empty on any I/O failure — the caller treats an unreadable file as "skip the
|
||||
// trim" (keep the untrimmed window), never as a corruption of the recorded audio.
|
||||
|
||||
// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields).
|
||||
void writeU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
|
||||
bytes[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||
bytes[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||
bytes[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||
bytes[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime)
|
||||
// ============================================================================
|
||||
// After the recorded file is stable and moved into the bank (the file we OWN — never
|
||||
// the project), Auto mode trims the trailing decay: read the WAV, scan the tail
|
||||
// region (frames AFTER the original range end) backward for the last frame above
|
||||
// -72 dB, and truncate the file there. Rules (spec):
|
||||
// * no frame in the tail window above -72 dB -> trim back to the original range end
|
||||
// * signal never falls below -72 dB in window -> keep the full window (cap did its job)
|
||||
// * otherwise -> trim one frame past the last audible
|
||||
//
|
||||
// Returns the trimmed length in SECONDS (for the Sample), or a negative value to
|
||||
// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and
|
||||
// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window)
|
||||
// rather than risk corrupting the capture — realtime tail is a convenience path.
|
||||
//
|
||||
// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit
|
||||
// float WAV (REAPER project record format — the manual procedure sets it) and is fully
|
||||
// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees
|
||||
// that for the normal path; abort()'s best-effort finalize races it, documented).
|
||||
double trimAutoTailInPlace(const std::string& path,
|
||||
double rangeStartSeconds,
|
||||
double rangeEndSeconds) {
|
||||
constexpr double kNoTrim = -1.0;
|
||||
|
||||
std::vector<std::uint8_t> bytes = readFileBytes(path);
|
||||
if (bytes.empty()) return kNoTrim;
|
||||
|
||||
const reasampler::WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim
|
||||
|
||||
const std::size_t totalFrames = layout.frameCount();
|
||||
if (totalFrames == 0) return kNoTrim;
|
||||
|
||||
// The original range end as a frame index within the file (frame 0 == start). Use
|
||||
// the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow
|
||||
// project). Clamp to the file so a rounding overshoot cannot exceed it.
|
||||
const double rangeSeconds = rangeEndSeconds - rangeStartSeconds;
|
||||
if (rangeSeconds <= 0.0) return kNoTrim;
|
||||
std::size_t rangeEndFrame = static_cast<std::size_t>(
|
||||
rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5);
|
||||
if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames;
|
||||
|
||||
// Nothing recorded past the range end (the tail window was empty) -> nothing to
|
||||
// trim; keep as-is. (Shouldn't happen for Auto, but total by construction.)
|
||||
if (rangeEndFrame >= totalFrames) return kNoTrim;
|
||||
|
||||
// Scan ONLY the tail region (frames after the original range end). The trim never
|
||||
// eats into the range body — the scan starts at rangeEndFrame.
|
||||
const std::size_t tailFrames = totalFrames - rangeEndFrame;
|
||||
const std::vector<reasampler::AudioSample> tailPcm =
|
||||
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
|
||||
if (tailPcm.empty()) return kNoTrim;
|
||||
|
||||
const float threshold = static_cast<float>(reasampler::autoTrimEndRatio());
|
||||
const std::size_t lastAbove = reasampler::lastFrameAboveThreshold(
|
||||
tailPcm, layout.channelCount, tailFrames, threshold);
|
||||
|
||||
// keptFrames: the total frame count the trimmed file retains.
|
||||
// no audible tail frame -> trim back to the range end (rangeEndFrame frames)
|
||||
// an audible frame at idx -> keep range body + up to and including that frame
|
||||
// The "signal never falls below threshold" case falls out naturally: lastAbove is
|
||||
// the final tail frame, so keptFrames == totalFrames (the full window is kept).
|
||||
std::size_t keptFrames;
|
||||
if (lastAbove == reasampler::kNoFrameAboveThreshold) {
|
||||
keptFrames = rangeEndFrame;
|
||||
} else {
|
||||
keptFrames = rangeEndFrame + (lastAbove + 1);
|
||||
}
|
||||
if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate
|
||||
|
||||
const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames);
|
||||
if (!plan.valid) return kNoTrim;
|
||||
|
||||
// Patch the RIFF + data size fields in the in-memory buffer so they describe the
|
||||
// kept frame count, then rewrite the file as exactly the first newFileByteLength
|
||||
// bytes (header + patched sizes + retained PCM). A single truncating write is the
|
||||
// simplest correct truncate — no separate resize step, no partial-write window
|
||||
// where the on-disk sizes and length disagree. The result is a valid, playable WAV
|
||||
// of the kept frames (verified by the wav_trim re-parse test).
|
||||
writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
|
||||
writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize);
|
||||
|
||||
// NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full
|
||||
// disk, a yanked drive) would leave a short file while we return kNoTrim, so the
|
||||
// Sample length would overstate the file. Vanishingly unlikely for a just-recorded
|
||||
// local bank file, and realtime tail is a convenience path, so a temp-file+atomic-
|
||||
// rename is not warranted here; flagged rather than built.
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file
|
||||
out.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(plan.newFileByteLength));
|
||||
if (!out) return kNoTrim;
|
||||
out.close();
|
||||
|
||||
// The trimmed length in seconds for the Sample metadata.
|
||||
return static_cast<double>(keptFrames) / static_cast<double>(layout.sampleRate);
|
||||
}
|
||||
|
||||
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
||||
// move it into the bank, populate the Sample via the pure mapping. Returns Ok +
|
||||
// Sample on success, or a RenderFailed result. Does NOT restore — the caller
|
||||
// restores unconditionally afterward (finalize + restore are separate steps so a
|
||||
// finalize failure still restores).
|
||||
CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
||||
CaptureResult result;
|
||||
|
||||
const std::string recorded = normSlashes(recordedFilePath(st.temp_));
|
||||
if (recorded.empty() || !std::filesystem::exists(recorded)) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Realtime record produced no file (check transport/record "
|
||||
"settings in the DAW).";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(st.paths_.absoluteDir, ec);
|
||||
const std::string destPath = st.paths_.absoluteDir + "/" + st.paths_.fileName;
|
||||
std::filesystem::rename(recorded, destPath, ec);
|
||||
if (ec) {
|
||||
// Cross-volume rename can fail; fall back to copy+remove.
|
||||
ec.clear();
|
||||
std::filesystem::copy_file(
|
||||
recorded, destPath,
|
||||
std::filesystem::copy_options::overwrite_existing, ec);
|
||||
if (ec) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Recorded file could not be moved into the bank: " +
|
||||
ec.message();
|
||||
return result;
|
||||
}
|
||||
std::error_code rmEc;
|
||||
std::filesystem::remove(recorded, rmEc); // best-effort
|
||||
}
|
||||
|
||||
// TAIL (Auto): trim the trailing decay of the recorded window in place — on the
|
||||
// BANK file we now own (destPath), never the project. Best-effort: an unreadable /
|
||||
// unknown-format / short file skips the trim (keeps the full window) rather than
|
||||
// corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a
|
||||
// fixed window (spec §The realtime path). Returns the trimmed length in seconds,
|
||||
// or < 0 for "no trim applied".
|
||||
double trimmedLenSeconds = -1.0;
|
||||
if (st.request_.tailMode == TailMode::Auto) {
|
||||
trimmedLenSeconds = trimAutoTailInPlace(destPath,
|
||||
st.request_.startSeconds,
|
||||
st.request_.endSeconds);
|
||||
}
|
||||
|
||||
RecordedCapture cap;
|
||||
cap.relativePath = st.paths_.relativePath;
|
||||
cap.uniqueTag = st.uniqueTag_;
|
||||
cap.sourceMode = SourceMode::Realtime;
|
||||
cap.startSeconds = st.request_.startSeconds;
|
||||
cap.endSeconds = st.request_.endSeconds;
|
||||
cap.wetDry = st.request_.wetDry;
|
||||
cap.displayName = st.request_.baseName;
|
||||
cap.trackGuids = st.request_.trackGuids;
|
||||
cap.channelCount = st.request_.channelCount;
|
||||
cap.sampleRate = (st.request_.sampleRate > 0)
|
||||
? st.request_.sampleRate
|
||||
: static_cast<int>(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false));
|
||||
cap.captureTempo = Master_GetTempo();
|
||||
// Time signature at the record range's START (L7 F1). TimeMap_GetTimeSigAtTime
|
||||
// (reaper_plugin_functions.h:7130) reads the meter effective at that project time;
|
||||
// proj=st.proj_ pins the recording's own project. tempoOut ignored (captureTempo is
|
||||
// the master tempo above). Leaves 0/0 (unstamped) on any failure.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
TimeMap_GetTimeSigAtTime(st.proj_, st.request_.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
||||
cap.captureTimeSigNum = tsNum;
|
||||
cap.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
cap.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = sampleFromRecordedCapture(cap);
|
||||
|
||||
// Content hash: WAV-aware FNV-1a over the (possibly trimmed) bank file's fmt+data
|
||||
// chunks so hashReferencedElsewhere can identify copies in other banks and suppress
|
||||
// the last-reference confirm when another bank still holds the same file. Using
|
||||
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
|
||||
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two records of identical
|
||||
// audio collapse to the same hash. Best-effort: an unreadable file leaves
|
||||
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
|
||||
// "" as non-participating).
|
||||
{
|
||||
const std::vector<std::uint8_t> fileBytes = readFileBytes(destPath);
|
||||
if (!fileBytes.empty()) {
|
||||
result.sample.contentHash = hashWavContent(fileBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// The recorded file's true length differs from the request range when a tail was
|
||||
// recorded, so the Sample length must reflect the FILE, not the range:
|
||||
// Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned.
|
||||
// Auto with no trim, or Manual -> the full recorded window (end - start).
|
||||
// None -> the exact range (unchanged; recordWindowEnd_ == endSeconds).
|
||||
// sampleFromRecordedCapture already set lengthSeconds = end - start; override it
|
||||
// to the recorded/trimmed length so downstream (thumbnail, placement) matches disk.
|
||||
if (trimmedLenSeconds >= 0.0) {
|
||||
result.sample.lengthSeconds = trimmedLenSeconds;
|
||||
} else {
|
||||
result.sample.lengthSeconds =
|
||||
st.recordWindowEnd_ - st.request_.startSeconds;
|
||||
}
|
||||
|
||||
result.message = "Realtime-captured [" +
|
||||
std::to_string(st.request_.startSeconds) + "s, " +
|
||||
std::to_string(st.request_.endSeconds) + "s] (recorded " +
|
||||
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
||||
st.paths_.relativePath;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ============================================================================
|
||||
// begin — start the record, snapshot, return immediately (no UI block)
|
||||
// ============================================================================
|
||||
void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noexcept {
|
||||
delete p; // full type is visible here — keeps capture.h REAPER-free
|
||||
}
|
||||
|
||||
RealtimeCaptureHandle
|
||||
RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
const std::vector<MediaTrack*>& sourceTracks,
|
||||
CaptureResult& outFailure) {
|
||||
// Only the track scope is implemented this increment (see §TAP). Item realtime
|
||||
// is deferred — it needs per-item take isolation on top of the track-output tap.
|
||||
if (request.sourceMode != SourceMode::SelectedTracks) {
|
||||
outFailure.status = CaptureStatus::UnsupportedMode;
|
||||
outFailure.message = "RealtimeRecordBackend implements TRACK scope only this "
|
||||
"increment (item realtime is deferred).";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Track scope needs at least one source track to tap. No selection -> refuse
|
||||
// (matching offline track scope's no-op on an empty selection).
|
||||
if (sourceTracks.empty()) {
|
||||
outFailure.status = CaptureStatus::UnsupportedMode;
|
||||
outFailure.message = "No track selected — realtime track capture needs at least "
|
||||
"one selected track to tap.";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Exact bounds: refuse an empty/inverted range rather than record silence.
|
||||
if (!(request.endSeconds > request.startSeconds)) {
|
||||
outFailure.status = CaptureStatus::EmptyRange;
|
||||
outFailure.message = "Capture range is empty (end <= start).";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) {
|
||||
outFailure.status = CaptureStatus::NoProject;
|
||||
outFailure.message = "No active project.";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Refuse if the transport is already playing/recording — we own the transport for
|
||||
// the capture window and must not hijack a user's live take.
|
||||
if (GetPlayStateEx(proj) & (1 | 4)) {
|
||||
outFailure.status = CaptureStatus::TransportBusy;
|
||||
outFailure.message = "Transport is already playing/recording — realtime capture "
|
||||
"refused. Stop the transport first.";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Saved-project gate (same as offline): the bank folder resolves against the
|
||||
// .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved.
|
||||
std::string rppPath = readRppPath();
|
||||
if (rppPath.empty()) {
|
||||
Main_SaveProject(proj, true); // DAW-only: opens Save-As, blocks (verify)
|
||||
rppPath = readRppPath();
|
||||
}
|
||||
if (rppPath.empty()) {
|
||||
outFailure.status = CaptureStatus::NoProject;
|
||||
outFailure.message = "Project must be saved before capture — nothing captured.";
|
||||
return nullptr;
|
||||
}
|
||||
const std::string projectDir =
|
||||
normSlashes(std::filesystem::path(rppPath).parent_path().string());
|
||||
|
||||
// --- Build the in-flight state (owns the snapshot + teardown) ---------------
|
||||
RealtimeCaptureHandle st(new RealtimeCaptureState());
|
||||
st->proj_ = proj;
|
||||
st->request_ = request;
|
||||
st->uniqueTag_ = makeUniqueTag();
|
||||
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
|
||||
|
||||
// The recorded window end: extended past the range end for a tail mode (Auto/Manual),
|
||||
// exact for None. This — not request.endSeconds — is what the completion machine
|
||||
// waits for; the extra window past the range end is trimmed later (Auto) or kept
|
||||
// (Manual). Pure mapping (render_settings), shared caps with the offline tail.
|
||||
st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode,
|
||||
request.endSeconds,
|
||||
request.tailMs);
|
||||
|
||||
// DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT
|
||||
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
|
||||
// shells is intentional. This backend fully restores its own state across every
|
||||
// terminal path (the restore() latch); an undo point would surface an internal,
|
||||
// fully-reversed scaffold in the user's undo history for no user-meaningful action.
|
||||
// Snapshot cursor + time selection, and disarm every OTHER track BEFORE the temp
|
||||
// track exists (so it is never in the arm snapshot and keeps the arm we set).
|
||||
st->snapshotAndDisarmOthers();
|
||||
|
||||
// Hidden temp track at the end: no default FX/envelopes (clean sink), hidden from
|
||||
// both panels, B_MAINSEND=0 so it does NOT sum back into the master (monitoring
|
||||
// invariant — it would otherwise double the tapped tracks in the user's monitoring).
|
||||
const int idx = CountTracks(proj);
|
||||
InsertTrackAtIndex(idx, false);
|
||||
st->temp_ = GetTrack(proj, idx);
|
||||
if (!st->temp_) {
|
||||
outFailure.status = CaptureStatus::RenderFailed;
|
||||
outFailure.message = "Could not create the hidden temp record track.";
|
||||
st->restore(); // undo the disarm + cursor/time-sel snapshot
|
||||
return nullptr;
|
||||
}
|
||||
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINTCP", 0.0);
|
||||
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINMIXER", 0.0);
|
||||
SetMediaTrackInfo_Value(st->temp_, "B_MAINSEND", 0.0);
|
||||
|
||||
// Route the TRACK-OUTPUT tap: a send FROM each selected source track INTO the temp
|
||||
// track (CreateTrackSend(source, temp)). The temp records its OWN output, so the
|
||||
// sends' outputs SUM in it — multiple selected tracks are captured together (same as
|
||||
// offline track scope). See §TAP for why this faithfully captures each track's own
|
||||
// output and needs no FxBypassGuard.
|
||||
//
|
||||
// Sends default to post-fader (I_SENDMODE 0) and full-stereo (I_SRCCHAN default,
|
||||
// (srcchan>>10)==0 — SDK ~3302/3304): post-fader = after the source track's FX and
|
||||
// fader/pan = the track's OWN output, tapped BEFORE the parent sums it. Left at
|
||||
// defaults deliberately — that IS the track-scope tap point.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION (flag): that a post-fader track->temp send + output-record
|
||||
// reproduces the track's own output sample-for-sample (latency comp, pan law,
|
||||
// mono/stereo folding) is the crux to verify live.
|
||||
int sendsMade = 0;
|
||||
for (MediaTrack* src : sourceTracks) {
|
||||
if (!src || src == st->temp_) continue;
|
||||
if (CreateTrackSend(src, st->temp_) >= 0) ++sendsMade;
|
||||
}
|
||||
if (sendsMade == 0) {
|
||||
// Every send failed (should not happen for valid selected tracks). Refuse
|
||||
// rather than record a guaranteed-silent file.
|
||||
outFailure.status = CaptureStatus::RenderFailed;
|
||||
outFailure.message = "Could not route any selected track into the record tap — "
|
||||
"nothing to capture.";
|
||||
st->restore(); // deleting the temp track drops any partial sends too
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Record-mode values from the pure planner. The temp track records its OWN output;
|
||||
// it has no FX and unity fader, so its post-fader output equals the summed sends.
|
||||
// Track scope is fully wet -> PostFader. (The actual track-scope tap point is the
|
||||
// source sends' default post-fader mode; the temp's recmode only records the sum.)
|
||||
const OutputTap tap = outputTapForWetDry(request.wetDry);
|
||||
const RecordModePlan rec = recordModePlanFor(request.channelCount, tap);
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast<double>(rec.recMode));
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE_FLAGS",
|
||||
static_cast<double>(rec.recModeFlags));
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
|
||||
|
||||
// Record range: time selection over [start, recordWindowEnd], play cursor at start.
|
||||
// recordWindowEnd extends past the request's range end for a tail mode so the
|
||||
// transport captures the decaying tail; it equals the range end for None (exact
|
||||
// bounds). Both cursor + time selection were snapshotted and are restored by
|
||||
// restore().
|
||||
double rs = request.startSeconds, re = st->recordWindowEnd_;
|
||||
GetSet_LoopTimeRange(true, false, &rs, &re, false);
|
||||
SetEditCurPos(request.startSeconds, false, false);
|
||||
|
||||
// Start the transport and RETURN. tick() drives the rest across timer ticks.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION (flag): CSurf_OnRecord starts recording and the exact
|
||||
// range/auto-punch/stop behavior depends on the user's transport settings — not
|
||||
// header-guaranteed. tick() detects completion via the play cursor reaching the
|
||||
// range end (the pure state machine), independent of REAPER's auto-punch.
|
||||
CSurf_OnRecord();
|
||||
|
||||
// Anchor the wall-clock safety ceiling from here (steady clock — independent of the
|
||||
// play cursor, so a transport that starts but never advances is still bounded).
|
||||
st->markElapsedStart();
|
||||
|
||||
return st;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// tick — advance the in-flight record; on terminal, finalize/abort + restore
|
||||
// ============================================================================
|
||||
RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
||||
RealtimeTickResult out;
|
||||
|
||||
// If a prior terminal path already tore this down (e.g. abort() then a stray
|
||||
// tick), do nothing — the state is spent.
|
||||
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
|
||||
|
||||
const RecordPhase prevPhase = state.phase_;
|
||||
|
||||
// Read the transport bound to the record's OWN project (a project switch cannot
|
||||
// point these reads at the wrong transport). &4 = recording. Gather everything the
|
||||
// pure machine needs (transport + wall-clock ceilings + file-flush readiness).
|
||||
RecordTickInputs inputs;
|
||||
inputs.transport.recording = (GetPlayStateEx(state.proj_) & 4) != 0;
|
||||
inputs.transport.playPosition = GetPlayPositionEx(state.proj_);
|
||||
inputs.elapsedSeconds = state.elapsedSeconds();
|
||||
|
||||
// Deferred-finalize flush check (review §2), only meaningful once stopped. The
|
||||
// recorded file is READY when its size is a valid positive value AND unchanged
|
||||
// from the previous tick — REAPER finished flushing/closing the take on the audio
|
||||
// thread. Comparing across a tick avoids moving a file mid-write (truncated take).
|
||||
if (prevPhase == RecordPhase::Finalizing) {
|
||||
state.markFinalizingStartOnce();
|
||||
inputs.finalizingSeconds = state.finalizingSeconds();
|
||||
const std::int64_t sz = recordedFileSize(state.temp_);
|
||||
inputs.fileReady = (sz > 0 && sz == state.lastFileSize_);
|
||||
state.lastFileSize_ = sz;
|
||||
}
|
||||
|
||||
// Wait for the transport to reach the RECORDED window end (extended past the
|
||||
// range end for a tail mode), not the request's range end — the extra tail window
|
||||
// is part of the record. The record safety ceiling scales with it (window - start
|
||||
// + margin) inside the pure machine.
|
||||
state.phase_ = advanceRecordPhase(state.phase_, inputs,
|
||||
state.request_.startSeconds,
|
||||
state.recordWindowEnd_);
|
||||
|
||||
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
|
||||
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
|
||||
// — never the global CSurf_OnStop, which would stop whatever project is ACTIVE (a
|
||||
// foreign one during a project switch), not the record's own. The flush wait then
|
||||
// proceeds across subsequent ticks before the file is moved.
|
||||
if (prevPhase == RecordPhase::Recording &&
|
||||
isStopRequested(state.phase_)) {
|
||||
state.stopOwnTransport();
|
||||
state.markFinalizingStartOnce(); // anchor the flush ceiling from the stop
|
||||
}
|
||||
|
||||
if (!isTerminalPhase(state.phase_)) {
|
||||
out.status = RealtimeTickStatus::InProgress;
|
||||
return out; // keep the OnTimer tick fast — recording or flushing
|
||||
}
|
||||
|
||||
// Terminal (Done: file flushed + stable; Failed: flush ceiling tripped). On Done,
|
||||
// finalize moves the now-stable file into the bank + builds the Sample. On Failed
|
||||
// (the flush timeout) there is nothing usable — report RenderFailed. Then restore
|
||||
// ALL snapshotted state — the non-destructive gate, idempotent + unconditional.
|
||||
CaptureResult res;
|
||||
if (state.phase_ == RecordPhase::Done) {
|
||||
res = finalizeRecording(state);
|
||||
} else {
|
||||
res.status = CaptureStatus::RenderFailed;
|
||||
res.message = "Realtime record timed out waiting for the recorded file to "
|
||||
"flush/close (nothing captured).";
|
||||
}
|
||||
state.markFinalized();
|
||||
state.restore();
|
||||
|
||||
out.result = res;
|
||||
out.status = (res.status == CaptureStatus::Ok)
|
||||
? RealtimeTickStatus::Done
|
||||
: RealtimeTickStatus::Failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// abort — force-terminate now (shutdown / project switch) + restore
|
||||
// ============================================================================
|
||||
RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
|
||||
RealtimeTickResult out;
|
||||
|
||||
// Already torn down (idempotent): report Failed and leave it.
|
||||
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
|
||||
|
||||
// CRITICAL (review §1): if the captured project was CLOSED mid-record, proj_ /
|
||||
// temp_ point at freed memory. The closed project already reclaimed its
|
||||
// temp track, arms, and transport — so DROP the handle WITHOUT touching any REAPER
|
||||
// state (no stop, no finalize, no DeleteTrack, no arm restore). Touching those
|
||||
// freed pointers is the use-after-free bug this guard exists to prevent. This is
|
||||
// the ONE terminal path that can run against a possibly-closed project (tick() only
|
||||
// runs while proj_ is the active — hence still-open — project); guarding here covers
|
||||
// both the project-switch and unload callers.
|
||||
if (!state.captureProjectStillOpen()) {
|
||||
state.dropWithoutRestore();
|
||||
out.result.status = CaptureStatus::RenderFailed;
|
||||
out.result.message = "Realtime capture dropped — the captured project was closed "
|
||||
"mid-record (nothing to restore; no capture persisted).";
|
||||
out.status = RealtimeTickStatus::Failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
// The project is still open (a tab-switch, or a clean unload with the project
|
||||
// present): stop the transport, then TRY to finalize whatever was captured so a
|
||||
// near-complete record still keeps the audio; if nothing was recorded (or the file
|
||||
// has not flushed yet), finalize returns RenderFailed and we abort clean.
|
||||
// Project-scoped stop (OnStopButtonEx(proj_)) — on a project switch proj_ is no
|
||||
// longer active, so the global CSurf_OnStop would stop the wrong (foreign) project.
|
||||
//
|
||||
// NOTE (residual timing — DAW-verify): abort is the force-terminate path (unload /
|
||||
// switch); it cannot span ticks to wait for the flush the way tick() does, so its
|
||||
// finalize still races REAPER's audio-thread take close. That is inherent to a
|
||||
// best-effort terminal grab and is acceptable — the normal completion path (tick)
|
||||
// is the one that must be flush-safe.
|
||||
state.stopOwnTransport();
|
||||
|
||||
CaptureResult res = finalizeRecording(state);
|
||||
state.markFinalized();
|
||||
state.restore(); // the non-destructive gate — always runs
|
||||
|
||||
out.result = res;
|
||||
out.status = (res.status == CaptureStatus::Ok)
|
||||
? RealtimeTickStatus::Done
|
||||
: RealtimeTickStatus::Failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,186 @@
|
||||
#include "core/namespaces.h"
|
||||
// insert.cpp — REAPER-facing placement shell (M6). See insert.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are
|
||||
// extern (CLAUDE.md §contract).
|
||||
//
|
||||
// THIS IS THE INTENDED PLACEMENT PATH. Unlike capture / bank_panel (which never
|
||||
// touch the arrange), insert deliberately adds items to the arrange — that is its
|
||||
// whole job (CONTEXT.md §load-bearing principle). It runs ONLY from its own action.
|
||||
//
|
||||
// FLAGGED RUNTIME ASSUMPTIONS (semantics the header does not fully specify — must
|
||||
// be DAW-verified by Daniel post-merge; see the handoff):
|
||||
// A. InsertMedia base mode 0 ("add to current track") targets the track that is
|
||||
// currently the ONLY selected track. The header names the base target but does
|
||||
// not spell out how "current track" resolves at runtime. We force exactly one
|
||||
// selected track via SetOnlyTrackSelected before each InsertMedia call, which
|
||||
// is the most defensible interpretation; if REAPER uses a different notion of
|
||||
// "current" (e.g. last-focused, not last-selected), DAW-verify and adjust.
|
||||
// B. InsertMedia mode 0 inserts AT THE EDIT CURSOR. Placement at the edit cursor
|
||||
// is REAPER's documented convention for base modes 0/1 (the header does not
|
||||
// spell out an explicit "at edit cursor" bit). Flagged for DAW-verification.
|
||||
// C. InsertMedia ADVANCES the edit cursor to the end of the inserted media. We
|
||||
// reset the cursor to the snapshot position before EACH track's insert, so
|
||||
// assumption C's truth or falsity is irrelevant: we own the cursor reset.
|
||||
// D. SetEditCurPos(time, false, false) moves the cursor without scrolling the view
|
||||
// and without seeking the transport. The header lists the args as
|
||||
// (time, moveview, seekplay) — moveview=false and seekplay=false are the
|
||||
// non-disruptive choice; flagged in case the DAW shows otherwise.
|
||||
// E. SetOnlyTrackSelected deselects all tracks and selects exactly one. The header
|
||||
// doc-comment says "Set exactly one track selected, deselect all others" —
|
||||
// this is the strongest confirmation we have; flagged for DAW-verification.
|
||||
|
||||
#include "shell/capture/insert.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_model.h"
|
||||
#include "bank_panel.h"
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "persist.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetCursorPosition
|
||||
#define REAPERAPI_WANT_GetSelectedTrack
|
||||
#define REAPERAPI_WANT_InsertMedia
|
||||
#define REAPERAPI_WANT_SetEditCurPos
|
||||
#define REAPERAPI_WANT_SetOnlyTrackSelected
|
||||
#define REAPERAPI_WANT_SetTrackSelected
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// The current project's directory (mirrors bank_panel/capture/persist). The bank
|
||||
// index stores relative paths; resolving a bank file needs the current .rpp dir.
|
||||
// FOLLOW-UP (already noted in bank_panel.cpp): a shared "current project dir"
|
||||
// REAPER helper is a clean small refactor now that a fourth consumer exists — out
|
||||
// of scope for M6.
|
||||
std::string currentProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
std::string rpp(buf.data());
|
||||
if (rpp.empty()) return {}; // unsaved project: no resolvable bank
|
||||
return normalizeSlashes(fs::path(rpp).parent_path().string());
|
||||
}
|
||||
|
||||
// Snapshot the user's currently-selected track set (ignores master, matches
|
||||
// CountSelectedTracks / GetSelectedTrack which both skip master). Returns the
|
||||
// tracks in selection order so we can restore the original state afterward.
|
||||
std::vector<MediaTrack*> snapshotSelectedTracks() {
|
||||
const int n = CountSelectedTracks(nullptr); // nullptr = active project
|
||||
std::vector<MediaTrack*> tracks;
|
||||
tracks.reserve(static_cast<size_t>(n));
|
||||
for (int i = 0; i < n; ++i)
|
||||
tracks.push_back(GetSelectedTrack(nullptr, i));
|
||||
return tracks;
|
||||
}
|
||||
|
||||
// Restore a previously-snapshotted track selection: deselect all (by setting the
|
||||
// first track alone) then re-select the full set. If the snapshot is empty we
|
||||
// leave all tracks deselected; no-op guard handles a completely empty project.
|
||||
void restoreSelectedTracks(const std::vector<MediaTrack*>& tracks) {
|
||||
if (tracks.empty()) return;
|
||||
// Deselect all via the first track, then re-add the rest.
|
||||
SetOnlyTrackSelected(tracks[0]);
|
||||
for (size_t i = 1; i < tracks.size(); ++i)
|
||||
SetTrackSelected(tracks[i], true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) {
|
||||
InsertResult result;
|
||||
if (!session) { result.status = InsertStatus::NoSelection; return result; }
|
||||
|
||||
// WHO to target: the user's currently-selected track set. No-op (with a clear
|
||||
// console message) when nothing is selected — inserting without a target track
|
||||
// would create an unintended new track or behave unpredictably.
|
||||
const std::vector<MediaTrack*> selectedTracks = snapshotSelectedTracks();
|
||||
if (selectedTracks.empty()) {
|
||||
ShowConsoleMsg("ReaSampler insert: select a track first.\n");
|
||||
result.status = InsertStatus::NoSelection;
|
||||
return result;
|
||||
}
|
||||
|
||||
// WHAT to place: the single focused sample from the panel. Multi-select is
|
||||
// deprioritized; take the first (or only) selected id. An empty panel selection
|
||||
// is a no-op — nothing to place.
|
||||
const std::vector<std::string> ids = bankPanelSelectedSampleIds();
|
||||
if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; }
|
||||
const std::string& id = ids.front(); // focused / first selected — single sample
|
||||
|
||||
// WHERE the bank lives on disk. An unsaved project has no resolvable bank dir;
|
||||
// insert is a no-op rather than resolving against CWD (CLAUDE.md invariant).
|
||||
const std::string projectDir = currentProjectDir();
|
||||
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
|
||||
|
||||
// Resolve the id against the bank the SELECTION came from — under B4's vertical
|
||||
// split the selection may live in the pool or a shown named bank, which is NOT
|
||||
// necessarily the active/capture-target bank. Fall back to the active bank when
|
||||
// the source id names no bank (defensive).
|
||||
const std::string srcBankId = bankPanelSelectedSourceBankId();
|
||||
const BankModel* srcIndex = session->book().index(srcBankId);
|
||||
const BankModel& bank = srcIndex ? *srcIndex : session->bank();
|
||||
const Sample* sample = bank.query(id);
|
||||
if (!sample) { result.status = InsertStatus::NothingResolved; return result; }
|
||||
|
||||
const std::string abs = resolveBankFile(projectDir, sample->relativePath);
|
||||
if (abs.empty() || !fs::exists(fs::path(abs))) {
|
||||
result.status = InsertStatus::NothingResolved;
|
||||
return result;
|
||||
}
|
||||
|
||||
const int mode = computeInsertMode(request.options);
|
||||
|
||||
// Snapshot the edit cursor position up front so we can restore it to the same
|
||||
// position for each track insert (and after the whole operation).
|
||||
const double cursorPos = GetCursorPosition();
|
||||
|
||||
// Wrap the whole placement (all tracks + selection/cursor save-restore) in ONE
|
||||
// undo block so a single undo removes every item and restores the state before
|
||||
// the action. Opened before the first InsertMedia, closed after the restore,
|
||||
// unconditionally — the block is always balanced.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
|
||||
// Insert onto EACH selected track at the SAME edit-cursor position (assumption B).
|
||||
// For each track: isolate it as the only selection so InsertMedia mode 0 targets
|
||||
// it unambiguously (assumption A + E), reset the cursor to the snapshot position
|
||||
// (assumption C cursor advance is irrelevant — we own the reset), then insert.
|
||||
for (MediaTrack* track : selectedTracks) {
|
||||
SetOnlyTrackSelected(track); // assumption A + E
|
||||
SetEditCurPos(cursorPos, false, false); // assumption D
|
||||
InsertMedia(abs.c_str(), mode);
|
||||
++result.inserted;
|
||||
}
|
||||
|
||||
// Restore the user's original track selection and cursor position so the action
|
||||
// is non-destructive to their DAW state (non-negotiable per the brief).
|
||||
restoreSelectedTracks(selectedTracks);
|
||||
SetEditCurPos(cursorPos, false, false);
|
||||
|
||||
// Label reflects the count and the conform choice so the undo history reads
|
||||
// clearly ("ReaSampler: insert on 2 tracks" etc.). extraflags -1 = UNDO_STATE_ALL
|
||||
// (superset: tracks, items, envelope points, project state).
|
||||
const std::string label =
|
||||
"ReaSampler: insert on " + std::to_string(result.inserted) +
|
||||
(result.inserted == 1 ? " track" : " tracks") +
|
||||
(request.options.conform == TempoConform::None ? "" : " (conform)");
|
||||
Undo_EndBlock2(nullptr, label.c_str(), -1);
|
||||
|
||||
result.status = InsertStatus::Ok;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,63 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// insert — placement of bank samples into the arrange (M6). REAPER-facing shell:
|
||||
// it reads the bank_panel's current selection, resolves each selected sample's
|
||||
// file, and drops it into the arrange at the edit cursor via InsertMedia, wrapped
|
||||
// in an undo block.
|
||||
//
|
||||
// THE INTENDED PLACEMENT PATH (CONTEXT.md §load-bearing principle): capture NEVER
|
||||
// auto-inserts; `insert` is the deliberate, user-invoked placement act, so it IS
|
||||
// allowed and expected to add items to the arrange. It must only ever run from its
|
||||
// own action — never from a capture path.
|
||||
//
|
||||
// Non-destructive to the bank: insert references the bank file (adds an arrange
|
||||
// item pointing at it); it never modifies the bank, the bank files, or ext state.
|
||||
// No SILENT time-stretch: conform-to-tempo is an explicit opt-in on the request,
|
||||
// defaulting OFF (native length). See insert_plan for the mode-bit computation.
|
||||
//
|
||||
// The header is SDK-free: all REAPER API use lives in insert.cpp. The pure
|
||||
// mode-bit arithmetic lives in insert_plan (unit-tested outside the DAW).
|
||||
|
||||
#include "core/capture/insert_plan.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// What one insert action does. Carries the InsertMedia options (target track +
|
||||
// tempo-conform choice) so the two action variants (native-length vs
|
||||
// conform-to-tempo) differ only by this struct — no divergent code paths.
|
||||
struct InsertRequest {
|
||||
InsertOptions options; // defaults: current track, no conform, native length
|
||||
};
|
||||
|
||||
// The outcome of an insert action, for the caller to log to the console.
|
||||
enum class InsertStatus {
|
||||
Ok, // one or more samples inserted
|
||||
NoSelection, // the panel had no selection — a no-op (not an error)
|
||||
NoProject, // no saved project, so no resolvable bank dir — no-op
|
||||
NothingResolved, // a selection existed but no sample resolved to a file
|
||||
};
|
||||
|
||||
struct InsertResult {
|
||||
InsertStatus status = InsertStatus::NoSelection;
|
||||
int inserted = 0; // how many samples were actually placed
|
||||
int skipped = 0; // selected-but-unresolvable/unreadable samples skipped
|
||||
};
|
||||
|
||||
// Runs the insert: reads the bank panel's single focused sample and the user's
|
||||
// currently-selected track set, then inserts the sample onto EACH selected track
|
||||
// at the SAME edit-cursor position. Snapshot/restore ensures the user's track
|
||||
// selection and cursor position are unchanged after the action. The whole operation
|
||||
// is wrapped in a single Undo_BeginBlock2 / Undo_EndBlock2.
|
||||
//
|
||||
// No-op cases (with console messages):
|
||||
// - No track selected: prints "select a track first."
|
||||
// - No sample selected in the panel: NoSelection status.
|
||||
// - Unsaved project (no resolvable bank dir): NoProject status.
|
||||
// - Sample id not in bank / file missing: NothingResolved status.
|
||||
//
|
||||
// `session` supplies the live bank the selected id resolves against.
|
||||
InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "core/namespaces.h"
|
||||
// item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See
|
||||
// item_read.h. Compiled into the reaper_reasampler MODULE; includes
|
||||
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU that
|
||||
// defines the API pointers — CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/item_read.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetSetMediaItemInfo_String
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string itemGuid(MediaItem* it) {
|
||||
char buf[64] = {0};
|
||||
if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {};
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
std::string itemLaneName(MediaTrack* tr, MediaItem* it) {
|
||||
const int laneIdx = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
|
||||
char parm[32];
|
||||
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
|
||||
char buf[512] = {0};
|
||||
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,35 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// item_read — the ONE place a MediaItem* is read for its canonical GUID string and for
|
||||
// the durable P_LANENAME of the fixed lane it sits on. Before this seam, view.cpp and
|
||||
// bank_panel.cpp each carried a near-identical private itemGuid / itemLaneName pair
|
||||
// (both files' comments acknowledged the deliberate copy); the D2 Wave-3-B item actions
|
||||
// need the same two reads, so the duplication is extracted here — the item-read analog
|
||||
// of track_guid's single MediaTrack* -> GUID-key formatter.
|
||||
//
|
||||
// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
|
||||
// CLAUDE.md §contract). MediaItem / MediaTrack are forward-declared so this header
|
||||
// stays SDK-lite. These are shell reads (REAPER string/value getters); the managed/
|
||||
// manual DECISION that consumes the lane name stays pure in lane_keys (isOnManualLane).
|
||||
|
||||
#include <string>
|
||||
|
||||
class MediaItem;
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on a
|
||||
// read failure (an empty GUID must never be tagged — every caller skips empties).
|
||||
std::string itemGuid(MediaItem* it);
|
||||
|
||||
// The durable P_LANENAME of the fixed lane item `it` currently sits on (read via the
|
||||
// item's I_FIXEDLANE ordinal, then P_LANENAME:n on `tr`). Empty if the lane is unnamed
|
||||
// or the param is unavailable. Callers must already know `tr` is a fixed-lane track
|
||||
// (I_FREEMODE==2) before calling — I_FIXEDLANE is meaningless otherwise; the pure
|
||||
// isOnManualLane predicate handles the non-fixed-lane case via its own argument, so
|
||||
// callers should not call this at all for a normal track.
|
||||
std::string itemLaneName(MediaTrack* tr, MediaItem* it);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,183 @@
|
||||
#include "core/namespaces.h"
|
||||
// provenance_shell.cpp — the REAPER reads behind Milestone 10. See provenance_shell.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers
|
||||
// (CLAUDE.md §contract). Every REAPER symbol used here is verified against
|
||||
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||
// * TrackFX_GetCount(MediaTrack*) (~7283)
|
||||
// * TrackFX_GetFXName(MediaTrack*, int, char*, int) -> bool (~7356)
|
||||
// * TrackFX_GetFXGUID(MediaTrack*, int) -> GUID* (~7348)
|
||||
// * TrackFX_GetEnabled(MediaTrack*, int) -> bool (~7291)
|
||||
// * TakeFX_GetCount(MediaItem_Take*) (~6710)
|
||||
// * TakeFX_GetFXName(MediaItem_Take*, int, char*, int) -> bool (~6758)
|
||||
// * TakeFX_GetFXGUID(MediaItem_Take*, int) -> GUID* (~6750)
|
||||
// * TakeFX_GetEnabled(MediaItem_Take*, int) -> bool (~6718)
|
||||
// * CountSelectedMediaItems / GetSelectedMediaItem (selection reads)
|
||||
// * GetActiveTake(MediaItem*) -> MediaItem_Take* (active take)
|
||||
// * GetMediaItemTake_Source(MediaItem_Take*) -> PCM_source* (~2053)
|
||||
// * GetMediaSourceFileName(PCM_source*, char*, int) (~2141)
|
||||
// * CountTracks / GetTrack (track scan)
|
||||
// * guidToString (via track_guid)
|
||||
|
||||
#include "shell/capture/provenance_shell.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_book.h" // BankBook, Bank, BankModel::all
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile, normalizeSlashes
|
||||
#include "shell/capture/track_guid.h" // guidString — the ONE canonical GUID key formatter
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||
#define REAPERAPI_WANT_TrackFX_GetFXName
|
||||
#define REAPERAPI_WANT_TrackFX_GetFXGUID
|
||||
#define REAPERAPI_WANT_TrackFX_GetEnabled
|
||||
#define REAPERAPI_WANT_TakeFX_GetCount
|
||||
#define REAPERAPI_WANT_TakeFX_GetFXName
|
||||
#define REAPERAPI_WANT_TakeFX_GetFXGUID
|
||||
#define REAPERAPI_WANT_TakeFX_GetEnabled
|
||||
#define REAPERAPI_WANT_CountSelectedMediaItems
|
||||
#define REAPERAPI_WANT_GetSelectedMediaItem
|
||||
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
#define REAPERAPI_WANT_GetActiveTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string fxChainIdentityForTrack(MediaTrack* tr) {
|
||||
if (!tr) return fxChainIdentity({});
|
||||
std::vector<FxIdentityEntry> rows;
|
||||
const int n = TrackFX_GetCount(tr);
|
||||
rows.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
FxIdentityEntry e;
|
||||
char nameBuf[512] = {0};
|
||||
if (TrackFX_GetFXName(tr, i, nameBuf, static_cast<int>(sizeof(nameBuf))))
|
||||
e.name = nameBuf;
|
||||
// Per-instance GUID: the stable identity of THIS FX in the chain, so swapping
|
||||
// one FX for another of the same name registers as drift. guidToString needs a
|
||||
// >=64-char destination (SDK contract).
|
||||
if (GUID* g = TrackFX_GetFXGUID(tr, i)) {
|
||||
char gb[64] = {0};
|
||||
guidToString(g, gb);
|
||||
e.guid = gb;
|
||||
}
|
||||
e.enabled = TrackFX_GetEnabled(tr, i);
|
||||
rows.push_back(std::move(e));
|
||||
}
|
||||
return fxChainIdentity(rows);
|
||||
}
|
||||
|
||||
std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items) {
|
||||
// For Item scope the in-scope chain is each item's active take's FX chain, NOT
|
||||
// the owning track's FX chain (the track chain is out-of-scope and is bypassed
|
||||
// during render). TakeFX_* is the correct family here.
|
||||
std::vector<std::string> perItem;
|
||||
perItem.reserve(items.size());
|
||||
for (MediaItem* it : items) {
|
||||
if (!it) { perItem.push_back(fxChainIdentity({})); continue; }
|
||||
MediaItem_Take* take = GetActiveTake(it);
|
||||
if (!take) { perItem.push_back(fxChainIdentity({})); continue; }
|
||||
std::vector<FxIdentityEntry> rows;
|
||||
const int n = TakeFX_GetCount(take);
|
||||
rows.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
FxIdentityEntry e;
|
||||
char nameBuf[512] = {0};
|
||||
if (TakeFX_GetFXName(take, i, nameBuf, static_cast<int>(sizeof(nameBuf))))
|
||||
e.name = nameBuf;
|
||||
if (GUID* g = TakeFX_GetFXGUID(take, i)) {
|
||||
char gb[64] = {0};
|
||||
guidToString(g, gb);
|
||||
e.guid = gb;
|
||||
}
|
||||
e.enabled = TakeFX_GetEnabled(take, i);
|
||||
rows.push_back(std::move(e));
|
||||
}
|
||||
perItem.push_back(fxChainIdentity(rows));
|
||||
}
|
||||
return combineChainIdentities(perItem);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The active take source file of one item, normalized. Empty if unresolvable.
|
||||
std::string itemSourceFile(MediaItem* it) {
|
||||
if (!it) return {};
|
||||
MediaItem_Take* take = GetActiveTake(it);
|
||||
if (!take) return {}; // empty (MIDI-less?) / no active take -> unresolvable
|
||||
PCM_source* src = GetMediaItemTake_Source(take);
|
||||
if (!src) return {};
|
||||
char buf[4096] = {0};
|
||||
GetMediaSourceFileName(src, buf, static_cast<int>(sizeof(buf)));
|
||||
return normalizeSlashes(std::string(buf));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> selectedItemSourceFiles() {
|
||||
std::vector<std::string> files;
|
||||
const int n = CountSelectedMediaItems(nullptr); // nullptr = active project
|
||||
for (int i = 0; i < n; ++i) {
|
||||
std::string f = itemSourceFile(GetSelectedMediaItem(nullptr, i));
|
||||
if (!f.empty()) files.push_back(std::move(f)); // omit unresolvable (never empty)
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
std::vector<std::string> trackItemSourceFiles(const std::vector<MediaTrack*>& tracks,
|
||||
double startSeconds, double endSeconds) {
|
||||
std::vector<std::string> files;
|
||||
for (MediaTrack* tr : tracks) {
|
||||
if (!tr) continue;
|
||||
const int n = CountTrackMediaItems(tr);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaItem* it = GetTrackMediaItem(tr, i);
|
||||
if (!it) continue;
|
||||
const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
|
||||
const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
|
||||
// Positive overlap with the capture range (a zero-length touch is not an
|
||||
// overlap): item [pos, pos+len) intersects [startSeconds, endSeconds).
|
||||
if (pos < endSeconds && (pos + len) > startSeconds) {
|
||||
std::string f = itemSourceFile(it);
|
||||
if (!f.empty()) files.push_back(std::move(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
std::vector<BankFileRef> bankFileRefs(const BankBook& book, const std::string& projectDir) {
|
||||
std::vector<BankFileRef> refs;
|
||||
for (const Bank& b : book.banks()) {
|
||||
for (const Sample& s : b.index.all()) {
|
||||
BankFileRef ref;
|
||||
ref.sampleId = s.id;
|
||||
// Resolve to the same normalized absolute form selectedItemSourceFiles
|
||||
// produces, so detectParent compares like-for-like. Empty projectDir /
|
||||
// relativePath -> empty absolutePath (never a false match).
|
||||
ref.absolutePath = normalizeSlashes(resolveBankFile(projectDir, s.relativePath));
|
||||
refs.push_back(std::move(ref));
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
MediaTrack* trackByGuid(const std::string& guid) {
|
||||
if (guid.empty()) return nullptr;
|
||||
const int n = CountTracks(nullptr); // nullptr = active project; excludes master
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaTrack* tr = GetTrack(nullptr, i);
|
||||
if (!tr) continue;
|
||||
if (guidString(tr) == guid) return tr;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,78 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// provenance_shell — the REAPER-facing reads Milestone 10 needs, in one place.
|
||||
//
|
||||
// The PURE provenance module (provenance.h) owns the fingerprint encoding, the
|
||||
// recipe model, the FX-identity fold, and the parent-detection DECISION — all over
|
||||
// plain strings/values. This shell gathers those strings/values FROM REAPER:
|
||||
// * the in-scope FX-chain identity of a source track (name/GUID/enabled rows),
|
||||
// * the media-file paths of a resolved capture's source items,
|
||||
// * the active book's bank samples resolved to absolute file paths,
|
||||
// * a canonical track-GUID string back to a live MediaTrack*.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
|
||||
// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays
|
||||
// SDK-lite. It depends on the pure provenance module (FxIdentityEntry / recipe /
|
||||
// BankFileRef) and bank_book (to enumerate the active book's samples).
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/provenance.h"
|
||||
|
||||
class MediaTrack;
|
||||
class MediaItem;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class BankBook;
|
||||
|
||||
// The in-scope FX-chain identity of a source track (Track scope), folded to the
|
||||
// pure provenance string. Reads the track's own FX chain via TrackFX_GetCount /
|
||||
// TrackFX_GetFXName / TrackFX_GetFXGUID / TrackFX_GetEnabled in chain order.
|
||||
std::string fxChainIdentityForTrack(MediaTrack* tr);
|
||||
|
||||
// The in-scope FX-chain identity for Item scope: enumerates each item's active
|
||||
// take FX chain via TakeFX_GetCount / TakeFX_GetFXName / TakeFX_GetFXGUID /
|
||||
// TakeFX_GetEnabled, in item order then FX order, combined with
|
||||
// combineChainIdentities so distinct per-item partitions never collide. Returns
|
||||
// the combined identity string (empty combined identity for a no-FX or no-item
|
||||
// set). The items vector is the same source-item set the shell collected for the
|
||||
// item-scope capture (selected items whose owning tracks were also collected).
|
||||
std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items);
|
||||
|
||||
// Reads the media-file path of every SELECTED media item's active take source
|
||||
// (GetMediaItemTake_Source -> GetMediaSourceFileName), normalized to forward-slash.
|
||||
// Unresolvable items (no take / no source / empty name) are omitted — never an
|
||||
// empty string in the result, so detectParent's "not in bank" branch is honest.
|
||||
// The active-project selection is read directly (mirrors main.cpp's collectors).
|
||||
// This is the ITEM-scope source set (the user selected the items being resampled).
|
||||
std::vector<std::string> selectedItemSourceFiles();
|
||||
|
||||
// The TRACK-scope source set: the media-file paths of the items ON `tracks` that
|
||||
// OVERLAP the capture range [startSeconds, endSeconds). For a track capture the user
|
||||
// selects the track, not the item, so the "what audio is being captured" set is the
|
||||
// range-overlapping items on the source tracks. Same normalize + omit-unresolvable
|
||||
// contract as selectedItemSourceFiles. An item overlaps iff its [pos, pos+len)
|
||||
// intersects the range with positive overlap (a zero-length touch does not count).
|
||||
std::vector<std::string> trackItemSourceFiles(const std::vector<MediaTrack*>& tracks,
|
||||
double startSeconds, double endSeconds);
|
||||
|
||||
// Enumerates the ACTIVE book's samples across every bank (pool + named) as pure
|
||||
// BankFileRefs — each sample id paired with its file resolved to a normalized
|
||||
// ABSOLUTE path against `projectDir` (resolveBankFile + normalizeSlashes). A sample
|
||||
// whose path cannot be resolved (empty projectDir / empty relativePath) is emitted
|
||||
// with an empty absolutePath, which detectParent never matches. `projectDir` is the
|
||||
// current .rpp parent (the shell resolves it; empty -> all refs unresolved).
|
||||
std::vector<BankFileRef> bankFileRefs(const BankBook& book, const std::string& projectDir);
|
||||
|
||||
// Resolves a canonical track-GUID string (guidString form) to a live MediaTrack*
|
||||
// in the active project by scanning tracks and comparing guidString(tr). Returns
|
||||
// nullptr when no live track carries that GUID (the source track was deleted since
|
||||
// capture — a re-capture failure mode the caller reports). The master track is not
|
||||
// scanned (it has no membership GUID and is never a capture source).
|
||||
MediaTrack* trackByGuid(const std::string& guid);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,25 @@
|
||||
#include "core/namespaces.h"
|
||||
// track_guid.cpp — the single MediaTrack* -> canonical GUID key formatter. See
|
||||
// track_guid.h. Compiled into the reaper_reasampler MODULE; includes
|
||||
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU
|
||||
// that defines the API pointers — CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/track_guid.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetTrackGUID
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string guidString(MediaTrack* tr) {
|
||||
if (!tr) return {};
|
||||
GUID* g = GetTrackGUID(tr);
|
||||
if (!g) return {};
|
||||
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
|
||||
guidToString(g, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,25 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID
|
||||
// string used as a membership-index key. Both the Design View shell (view.cpp) and
|
||||
// the actions layer (actions.cpp) key membership on this exact string, so the key
|
||||
// contract lives in a single helper rather than being re-derived (and drifting) at
|
||||
// two call sites (the cross-module key contract flagged in D2 review).
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
|
||||
// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays SDK-lite.
|
||||
|
||||
#include <string>
|
||||
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// REAPER's canonical "{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}" form of a track's
|
||||
// GUID (GetTrackGUID -> guidToString). Empty string if `tr` has no GUID. This IS
|
||||
// the membership-index key format — it must match guidToString's braces exactly so
|
||||
// the view tree keys and the model/actions keys align.
|
||||
std::string guidString(MediaTrack* tr);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,161 @@
|
||||
#include "core/namespaces.h"
|
||||
// reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin.
|
||||
|
||||
#include "shell/instrument/reaper_bridge.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/map/bridge_marshal.h"
|
||||
#include "core/capture/capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation)
|
||||
#include "ext_keys.h" // kProjExtNamespace (shared wire contract)
|
||||
|
||||
// The VST3 base types must be included before REAPER's VST3 interface header, which
|
||||
// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from
|
||||
// pluginterfaces/base — all in namespace Steinberg.
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
#include "pluginterfaces/base/ftypes.h"
|
||||
|
||||
// REAPER's VST3-side bridge interface (vendored). IReaperHostApplication is what REAPER
|
||||
// passes (as an IHostApplication) to IComponent::initialize; it exposes getReaperApi
|
||||
// (resolve-by-name) and getReaperParent (host context). The header uses UNQUALIFIED
|
||||
// Steinberg types (FUnknown, CStringA, uint32, FUID, DECLARE_CLASS_IID, PLUGIN_API), so
|
||||
// it must be pulled into the Steinberg namespace — the same way REAPER's own VST3
|
||||
// examples include it.
|
||||
namespace Steinberg {
|
||||
#include "reaper_vst3_interfaces.h"
|
||||
} // namespace Steinberg
|
||||
|
||||
// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperHostApplication::iid; some
|
||||
// TU must DEFINE it. We do it here — this is the only place that queries for the
|
||||
// interface (FUnknownPtr uses the iid), so the definition lives with its sole use.
|
||||
DEF_CLASS_IID(Steinberg::IReaperHostApplication)
|
||||
|
||||
// The ext-state namespace is the SHARED wire contract between the extension (writer)
|
||||
// and this instrument (reader); it lives in ext_keys.h (pure, REAPER-free) —
|
||||
// reasampler::kProjExtNamespace() — so the two artifacts read one symbol and cannot
|
||||
// drift. Channel-derived (Phase V, V4): the accessor returns "reasampler" (stable) or
|
||||
// "reasampler_beta" (beta), matching whatever the extension wrote. The S1 spike
|
||||
// duplicated it locally; that duplication is retired.
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
||||
getProjExtState_ = nullptr;
|
||||
enumProjExtState_ = nullptr;
|
||||
enumProjects_ = nullptr;
|
||||
setProjExtState_ = nullptr;
|
||||
getTrackGuid_ = nullptr;
|
||||
guidToString_ = nullptr;
|
||||
hostApp_ = nullptr;
|
||||
if (!context) return false;
|
||||
|
||||
// Query the host context for REAPER's bridge interface. In a non-REAPER host this
|
||||
// query fails and we stay unconnected — the instrument still loads.
|
||||
Steinberg::FUnknownPtr<Steinberg::IReaperHostApplication> reaper(context);
|
||||
if (!reaper) return false;
|
||||
hostApp_ = reaper.get();
|
||||
|
||||
// Resolve the ext-state functions by name. getReaperApi returns the same function
|
||||
// pointers the extension resolves via rec->GetFunc; a null return means the symbol
|
||||
// is unavailable (very old REAPER) — degrade gracefully.
|
||||
getProjExtState_ = reinterpret_cast<GetProjExtStateFn>(
|
||||
reaper->getReaperApi("GetProjExtState"));
|
||||
enumProjExtState_ = reinterpret_cast<EnumProjExtStateFn>(
|
||||
reaper->getReaperApi("EnumProjExtState"));
|
||||
// EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call
|
||||
// persist.cpp uses, so the instrument derives the project directory identically.
|
||||
enumProjects_ = reinterpret_cast<EnumProjectsFn>(
|
||||
reaper->getReaperApi("EnumProjects"));
|
||||
// pS-usage: the (prefix-guarded) usage publish write + the track-identity pair the
|
||||
// usage record stamps. All degrade to null gracefully — an old REAPER just never
|
||||
// publishes usage (the extension then protects by bank references only).
|
||||
setProjExtState_ = reinterpret_cast<SetProjExtStateFn>(
|
||||
reaper->getReaperApi("SetProjExtState"));
|
||||
getTrackGuid_ = reinterpret_cast<GetTrackGuidFn>(
|
||||
reaper->getReaperApi("GetTrackGUID"));
|
||||
guidToString_ = reinterpret_cast<GuidToStringFn>(
|
||||
reaper->getReaperApi("guidToString"));
|
||||
|
||||
return getProjExtState_ != nullptr;
|
||||
}
|
||||
|
||||
std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::string& key) {
|
||||
if (!getProjExtState_ || !hostApp_) return std::nullopt;
|
||||
|
||||
// Fetch the host project (getReaperParent(3) — project). Reads that live "reasampler"
|
||||
// ext-state against the ACTIVE project the instrument was instantiated in, so it
|
||||
// follows project switches for free (D6).
|
||||
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
|
||||
void* proj = reaper->getReaperParent(3);
|
||||
// A null project is legitimate (e.g. instantiated before a project context exists);
|
||||
// REAPER treats null as the current project for these calls, so we pass it through
|
||||
// rather than bailing — but if the read yields nothing the caller sees nullopt.
|
||||
|
||||
// GetProjExtState writes into a caller buffer; the bank blob can be large (many
|
||||
// samples), so grow the buffer until the value fits rather than risk a silent
|
||||
// truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The
|
||||
// return value is the value length; if it fits strictly inside the buffer it is
|
||||
// complete, else grow and retry up to a 16 MB ceiling.
|
||||
for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) {
|
||||
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
|
||||
const int rv = getProjExtState_(proj, kProjExtNamespace(), key.c_str(),
|
||||
buf.data(), cap);
|
||||
if (rv <= 0) return std::nullopt; // absent / empty key
|
||||
std::string s(buf.data());
|
||||
if (static_cast<int>(s.size()) + 1 < cap) {
|
||||
return decodeGetProjExtState(rv, s);
|
||||
}
|
||||
// else: possibly truncated -> grow and retry.
|
||||
}
|
||||
return std::nullopt; // pathologically large (>16 MB) — give up rather than loop
|
||||
}
|
||||
|
||||
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
|
||||
const std::string& value) {
|
||||
if (!setProjExtState_ || !hostApp_) return false;
|
||||
// STRUCTURAL read-only-bank guard: this module writes usage keys and nothing else.
|
||||
// A non-"rsusage_" key is a programming error upstream — refuse rather than widen
|
||||
// the instrument's write surface (banks/view/tail/assign stay extension-owned).
|
||||
const std::string prefix = kProjExtUsageKeyPrefix;
|
||||
if (usageKey.compare(0, prefix.size(), prefix) != 0) return false;
|
||||
|
||||
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
|
||||
void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
|
||||
// SetProjExtState returns "the size of the state for this extname" (SDK ~6288) —
|
||||
// after storing our non-empty value the namespace state is necessarily > 0, so a
|
||||
// <= 0 return means the write did not land. Reported to the caller (the publish
|
||||
// path retries on the next reload tick); a silently-dropped record would leave the
|
||||
// instance's holds unprotected.
|
||||
const int rv =
|
||||
setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str());
|
||||
// Deliberately NO MarkProjectDirty: a usage change always accompanies a component-
|
||||
// state change that already dirties the project; an idempotent load-time republish
|
||||
// must not flag an untouched project as modified.
|
||||
return rv > 0;
|
||||
}
|
||||
|
||||
std::string ReaperBridge::currentTrackGuid() {
|
||||
if (!hostApp_ || !getTrackGuid_ || !guidToString_) return {};
|
||||
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
|
||||
void* track = reaper->getReaperParent(1); // the hosting MediaTrack*
|
||||
if (!track) return {}; // no track context (unusual host state)
|
||||
void* guid = getTrackGuid_(track);
|
||||
if (!guid) return {};
|
||||
char buf[64] = {0}; // guidToString's documented destNeed64 contract
|
||||
guidToString_(guid, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
std::string ReaperBridge::activeProjectDir() {
|
||||
if (!enumProjects_) return {};
|
||||
// idx=-1 is the current project tab; the out-buffer receives the full .rpp path,
|
||||
// EMPTY for a never-saved project. Same call + convention as persist.cpp; the pure
|
||||
// projectDirOfRpp turns the .rpp path into the project directory (parent, forward-
|
||||
// slashed) and keeps an unsaved project's empty path empty (no default-location
|
||||
// fallback — the tool's invariant).
|
||||
std::vector<char> buf(4096, '\0');
|
||||
enumProjects_(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,111 @@
|
||||
#include "core/namespaces.h"
|
||||
// reaper_bridge.h — the REAPER VST-host bridge (Phase S1 read spike). THIN shell:
|
||||
// resolves REAPER API functions by name over the host context and reads the live
|
||||
// "reasampler" project ext-state. The fiddly decode lives in bridge_marshal (pure).
|
||||
//
|
||||
// VERIFIED BRIDGE MECHANISM (corrects §1a's estimate). §1a described the VST2-style
|
||||
// hostcb opcode pattern (hostcb(&effect, 0xdeadbeef, 0xdeadf00d, ...)). That is the
|
||||
// VST2 path (video_processor.h documents it for a VST2 aEffect). For a VST3 plugin the
|
||||
// bridge is exposed differently and more cleanly: REAPER passes an IHostApplication as
|
||||
// the `context` to IComponent::initialize(FUnknown* context); querying it for
|
||||
// IReaperHostApplication (vendor/reaper-sdk/sdk/reaper_vst3_interfaces.h) yields:
|
||||
// * getReaperApi(funcname) -> resolve a REAPER API function pointer by name
|
||||
// (the VST3 equivalent of opcode 0xdeadf00d), and
|
||||
// * getReaperParent(3) -> the host ReaProject* (the VST3 equivalent of the
|
||||
// 0xdeadf00e host-context fetch; 1=track, 2=take, 3=project, 4=fxdsp, 5=trackchan).
|
||||
// So a VST3 uses IReaperHostApplication, not the raw hostcb opcodes. Verified against
|
||||
// reaper_vst3_interfaces.h + reaper_plugin_functions.h at the spike.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Wraps the REAPER host bridge for a single plugin instance. Constructed cheaply;
|
||||
// connect() must be called with the initialize() context before any read. All reads
|
||||
// degrade to nullopt (never crash) when the host is not REAPER or a symbol is absent —
|
||||
// the instrument must load in non-REAPER hosts too, just without live state.
|
||||
class ReaperBridge {
|
||||
public:
|
||||
ReaperBridge() = default;
|
||||
|
||||
// Bind to the host. `context` is the FUnknown* REAPER hands IComponent::initialize.
|
||||
// Returns true when the REAPER bridge is available (host is REAPER and the ext-state
|
||||
// API resolved). Safe to call with a null or non-REAPER context — returns false.
|
||||
bool connect(Steinberg::FUnknown* context);
|
||||
|
||||
// True once connect() found the REAPER host application AND resolved the ext-state
|
||||
// functions.
|
||||
bool isConnected() const { return getProjExtState_ != nullptr; }
|
||||
|
||||
// Read a "reasampler" ext-state value by key from the host's active project.
|
||||
// Returns nullopt when unconnected, when the project can't be resolved, or when the
|
||||
// key is absent. This is the S1 read-spike entry point.
|
||||
//
|
||||
// NOT REAL-TIME SAFE (it allocates a read buffer and calls into REAPER): callers on
|
||||
// the audio thread MUST NOT invoke it. The S4 instrument reads on the main/UI thread
|
||||
// and hands a snapshot to the process path (see reasampler_processor.cpp).
|
||||
std::optional<std::string> readReasamplerExtState(const std::string& key);
|
||||
|
||||
// The active project's directory (the folder holding its .rpp), forward-slashed,
|
||||
// no trailing slash — the M4 convention persist uses to place the bank alongside
|
||||
// the .rpp. Empty for an unsaved project or when unconnected. The instrument
|
||||
// resolves relative sample paths against this the SAME way persist does
|
||||
// (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe.
|
||||
std::string activeProjectDir();
|
||||
|
||||
// Write THIS INSTANCE's usage record (pS-usage): the ONE sanctioned instrument-side
|
||||
// ext-state write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's
|
||||
// usageKeyFor) — any other key is REFUSED here, so the read-only-BANK invariant is
|
||||
// enforced structurally: this module can publish the instance's own usage and
|
||||
// nothing else (banks/view/tail/assign remain unwritable from the instrument).
|
||||
// Returns true iff written (the SetProjExtState return is checked — a dropped
|
||||
// write must not silently claim protection). NOT RT-safe (calls into REAPER) —
|
||||
// publish sites are the off-audio-thread reload path only. Deliberately does NOT
|
||||
// mark the project dirty: a usage change always rides a component-state change
|
||||
// that already does.
|
||||
bool writeUsageExtState(const std::string& usageKey, const std::string& value);
|
||||
|
||||
// The canonical "{XXXXXXXX-...}" GUID string of the track hosting this FX instance
|
||||
// (getReaperParent(1) -> GetTrackGUID -> guidToString — the same rendering as the
|
||||
// extension's track_guid::guidString, so usage records and the extension's live-FX
|
||||
// enumeration compare byte-equal). Empty when unconnected or no track context (the
|
||||
// usage reader then falls back to any-instance liveness — fail-safe). Not RT-safe.
|
||||
std::string currentTrackGuid();
|
||||
|
||||
private:
|
||||
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
|
||||
// verified against reaper_plugin_functions.h.
|
||||
using GetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
|
||||
char* valOutNeedBig, int valOutNeedBig_sz);
|
||||
using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx,
|
||||
char* keyOut, int keyOut_sz, char* valOut,
|
||||
int valOut_sz);
|
||||
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path (SDK line
|
||||
// ~1264). The instrument uses idx=-1 (current tab) so it follows the active project,
|
||||
// and reads the .rpp path from the out-buffer exactly as persist.cpp does.
|
||||
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
|
||||
// SetProjExtState(proj, extname, key, value) -> int (SDK line ~6290). Used ONLY by
|
||||
// writeUsageExtState (prefix-guarded) — see the read-only-bank note there.
|
||||
using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
|
||||
const char* value);
|
||||
// GetTrackGUID(MediaTrack*) -> GUID* (SDK ~3562) + guidToString(const GUID*, char*
|
||||
// destNeed64) (SDK ~3848). Both held as opaque-pointer signatures so the header
|
||||
// stays SDK-type-free; the GUID* is passed straight through, never dereferenced here.
|
||||
using GetTrackGuidFn = void* (*)(void* tr);
|
||||
using GuidToStringFn = void (*)(const void* g, char* destNeed64);
|
||||
|
||||
void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp)
|
||||
GetProjExtStateFn getProjExtState_ = nullptr;
|
||||
EnumProjExtStateFn enumProjExtState_ = nullptr;
|
||||
EnumProjectsFn enumProjects_ = nullptr;
|
||||
SetProjExtStateFn setProjExtState_ = nullptr;
|
||||
GetTrackGuidFn getTrackGuid_ = nullptr;
|
||||
GuidToStringFn guidToString_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,274 @@
|
||||
#include "core/namespaces.h"
|
||||
// reasampler_embed.cpp — see reasampler_embed.h. The IReaperUIEmbedInterface shell.
|
||||
// Windows-only (D5); guarded so a non-Windows build degrades to a stub that reports
|
||||
// "not supported" and draws nothing.
|
||||
|
||||
#include "shell/instrument/reasampler_embed.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived embed label, S18)
|
||||
#include "core/instrument/map/bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh)
|
||||
#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3)
|
||||
#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text (L3)
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (shared with embed_strip)
|
||||
#include "core/instrument/ui/embed_strip.h" // the pure strip layout + hit-test
|
||||
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey
|
||||
#include "shell/instrument/reaper_bridge.h"
|
||||
#include "reasampler_processor.h"
|
||||
#include "core/ui/theme.h" // Role / InteractionState / spectralColor (L3)
|
||||
|
||||
// wdltypes.h first: it defines INT_PTR portably (and pulls <windows.h> on Windows), which
|
||||
// reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type.
|
||||
#include "wdltypes.h"
|
||||
|
||||
// REAPER's embed message/bitmap contract (vendored). REAPER_FXEMBED_IBitmap is an alias of
|
||||
// LICE_IBitmap, and the WM_* / DrawInfo / SizeHints definitions live here.
|
||||
#include "reaper_plugin_fx_embed.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
// LICE — the same drawing stack the IPlugView editor and bank_panel use. REAPER hands us a
|
||||
// LICE bitmap; we draw into it with the same calls, then return (REAPER blits it).
|
||||
#include "lice/lice.h"
|
||||
#endif
|
||||
|
||||
using namespace Steinberg;
|
||||
|
||||
// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperUIEmbedInterface::iid; some
|
||||
// TU must DEFINE it. This is the only place that answers queryInterface for it, so the
|
||||
// definition lives with its sole use (mirrors reaper_bridge.cpp doing this for
|
||||
// IReaperHostApplication).
|
||||
DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface)
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
#ifdef _WIN32
|
||||
// Kit adapter (Phase L, L3): the embed shell's Rect (editor_geometry) -> the kit's KitBox
|
||||
// (component_geometry). Every embed surface now draws by palette ROLE via the L1 kit, retiring
|
||||
// the local pre-L1 forest-green palette + raw GDI DrawTextA.
|
||||
KitBox toKitBox(const Rect& r) {
|
||||
return KitBox{r.x, r.y, r.width, r.height};
|
||||
}
|
||||
|
||||
// A short display name for a bank sample id, from the snapshotted list (the editor's helper,
|
||||
// duplicated small rather than shared across the shell/pure boundary).
|
||||
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
|
||||
for (const SampleChoice& c : samples) {
|
||||
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
#endif
|
||||
|
||||
// Project the instrument's performance map into the strip's minimal zone shape (key ranges
|
||||
// only). Pure projection — kept here (shell side) because it reads PerformanceMap, a shell
|
||||
// type; embed_strip stays free of it.
|
||||
std::vector<EmbedZone> toEmbedZones(const PerformanceMap& map) {
|
||||
std::vector<EmbedZone> out;
|
||||
out.reserve(map.zones.size());
|
||||
for (const PerformanceZone& z : map.zones) out.push_back(EmbedZone{z.lowNote, z.highNote});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) {
|
||||
QUERY_INTERFACE(iid, obj, FUnknown::iid, IReaperUIEmbedInterface)
|
||||
QUERY_INTERFACE(iid, obj, IReaperUIEmbedInterface::iid, IReaperUIEmbedInterface)
|
||||
*obj = nullptr;
|
||||
return kNoInterface;
|
||||
}
|
||||
|
||||
void ReaSamplerEmbed::refresh() {
|
||||
if (!processor_) {
|
||||
samples_.clear();
|
||||
map_.zones.clear();
|
||||
selectedZone_ = -1;
|
||||
return;
|
||||
}
|
||||
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
}
|
||||
|
||||
void ReaSamplerEmbed::maybeRefresh() {
|
||||
if (!processor_) { refresh(); return; } // clears state; cheap
|
||||
|
||||
// The performance map is a cheap in-process accessor (mutex + copy), and the editor may
|
||||
// have edited zones with NO bank-content change — always re-snapshot it so a zone edit
|
||||
// reflects immediately.
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
|
||||
// The EXPENSIVE part is the bank-blob bridge read (samples_). Gate it on the S9 bank-
|
||||
// generation stamp (a small ext-state read): only re-read the bank when the generation
|
||||
// changed since the last paint (a recapture / ingest / remove), or on the first paint
|
||||
// (lastSeenBankGeneration_ == -1). A pre-S9 project reads generation 0; the first paint
|
||||
// folds it and subsequent idle paints skip the bank read entirely.
|
||||
std::int64_t currentGen = lastSeenBankGeneration_;
|
||||
if (auto rawGen =
|
||||
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBankGenKey)) {
|
||||
currentGen = parseBankGeneration(*rawGen);
|
||||
} else if (lastSeenBankGeneration_ < 0) {
|
||||
currentGen = 0; // unprimed + no stamp (pre-S9): treat as generation 0 for the first read
|
||||
}
|
||||
// Intentional asymmetry: a TRANSIENT bridge failure (readReasamplerExtState returned
|
||||
// nullopt after we were already primed) leaves currentGen == lastSeenBankGeneration_,
|
||||
// so the bank-blob read is skipped and the editor keeps its last-known sample list.
|
||||
// A stale-but-intact list is better than clearing samples_ on every transient hiccup.
|
||||
|
||||
if (lastSeenBankGeneration_ < 0 || currentGen != lastSeenBankGeneration_) {
|
||||
auto banks =
|
||||
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
|
||||
lastSeenBankGeneration_ = currentGen;
|
||||
}
|
||||
}
|
||||
|
||||
TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
switch (msg) {
|
||||
case REAPER_FXEMBED_WM_IS_SUPPORTED:
|
||||
#ifdef _WIN32
|
||||
return 1; // supported and available
|
||||
#else
|
||||
return 0; // not a build target off Windows
|
||||
#endif
|
||||
case REAPER_FXEMBED_WM_CREATE:
|
||||
#ifdef _WIN32
|
||||
// Create the kit's cached AA fonts before the first paint (Phase L, L3).
|
||||
// Idempotent + process-global (shared with the editor in this binary); NOT torn
|
||||
// down per-view — the OS reclaims the tiny static HFONT set at module unload.
|
||||
kitFontsInit();
|
||||
#endif
|
||||
refresh(); // prime the first paint's snapshot
|
||||
return 0;
|
||||
case REAPER_FXEMBED_WM_DESTROY:
|
||||
return 0;
|
||||
case REAPER_FXEMBED_WM_GETMINMAXINFO: {
|
||||
auto* hints = reinterpret_cast<REAPER_FXEMBED_SizeHints*>(parm3);
|
||||
if (!hints) return 0;
|
||||
// Minimum usable strip height: the keymap must not collapse below its floor
|
||||
// (kEmbedKeymapMinHeight) plus the level band.
|
||||
hints->min_width = 64;
|
||||
hints->max_width = 0; // 0 = unconstrained
|
||||
hints->min_height = kEmbedKeymapMinHeight + kEmbedLevelBandHeight;
|
||||
hints->max_height = 0; // 0 = unconstrained
|
||||
// Preferred aspect: wide strip, roughly 8:1 (w:h). 16.16 fixed point.
|
||||
hints->preferred_aspect = (8 << 16) / 1;
|
||||
hints->minimum_aspect = (4 << 16) / 1;
|
||||
return 1;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
case REAPER_FXEMBED_WM_PAINT:
|
||||
return paint(parm2, parm3) ? 1 : 0;
|
||||
case REAPER_FXEMBED_WM_LBUTTONDOWN:
|
||||
// Selection at most (S6): map the click to a zone; force a redraw if it changed.
|
||||
return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0;
|
||||
#endif
|
||||
default:
|
||||
return 0; // unhandled messages (cursor, wheel, hittest) fall through
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
auto* bmp = reinterpret_cast<LICE_IBitmap*>(bitmap);
|
||||
auto* di = reinterpret_cast<const REAPER_FXEMBED_DrawInfo*>(drawInfo);
|
||||
if (!bmp || !di) return false;
|
||||
const int w = di->width;
|
||||
const int h = di->height;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
// Re-read live state each paint (UI thread) so the strip reflects keymap edits + bank
|
||||
// changes without its own timer — REAPER repaints the embed surface on its cadence. S9
|
||||
// dirty-guard: maybeRefresh does the EXPENSIVE bank-blob read only when the bank generation
|
||||
// changed (the flagged S6 follow-up), always refreshing the cheap performance map.
|
||||
maybeRefresh();
|
||||
|
||||
// REAPER hands us its own bitmap sized to the embed area; draw directly into it (unlike
|
||||
// the editor, which owns a LICE_SysBitmap and BitBlt's). Origin is the bitmap's (0,0).
|
||||
// Base canvas through the kit (bg/base + micro-gradient), Phase L L3.
|
||||
fillSurface(bmp, KitBox{0, 0, w, h}, Role::BgBase, InteractionState::Rest);
|
||||
|
||||
const EmbedLayout layout = layoutEmbed(w, h);
|
||||
|
||||
if (map_.zones.empty()) {
|
||||
// No opt-in zones authored: a faint bg/cell band spanning the keymap area so the strip
|
||||
// reads as "present, no zones" — the default single-capture face lives in the editor.
|
||||
LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width,
|
||||
layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0);
|
||||
const std::string label = reasampler::vstPluginName() + // channel-derived (S18)
|
||||
(samples_.empty() ? " (bank empty)" : " (no zones)");
|
||||
const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(),
|
||||
layout.keymap.bottom());
|
||||
text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left);
|
||||
} else {
|
||||
// Draw each zone as a segment across the keymap span, first-match order (so the painted
|
||||
// order matches selection + playback). Each segment takes its PASTEL SPECTRAL hue from
|
||||
// the center of its key span (spectralColor — §4), so the strip reads as the same
|
||||
// spectrum as the editor's keyboard strip. The SELECTED zone lifts to accent-primary
|
||||
// + a static glow ("which zone is live", never a pulse — §3.5).
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[i];
|
||||
const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote);
|
||||
if (r.width <= 0) continue;
|
||||
const bool sel = (i == selectedZone_);
|
||||
if (sel) {
|
||||
// Static glow halo, then the crisp accent-primary fill.
|
||||
LICE_FillRect(bmp, r.x - 2, r.y, r.width + 4, r.height,
|
||||
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
|
||||
LICE_FillRect(bmp, r.x, r.y, r.width, r.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
} else {
|
||||
const double t = ((z.lowNote + z.highNote) * 0.5) / 127.0;
|
||||
LICE_FillRect(bmp, r.x, r.y, r.width, r.height,
|
||||
toLice(spectralColor(t)), 0.65f, 0);
|
||||
}
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
// Label the segment with the sample name when it is wide enough to read. The
|
||||
// selected (accent-fill) segment draws its label in bg/base for contrast (the
|
||||
// tight text-on-pastel pair, §4); the rest in text/primary.
|
||||
if (r.width >= 24) {
|
||||
const Rect lr = Rect::ltrb(r.x + 3, r.y, r.right() - 2, r.bottom());
|
||||
text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(),
|
||||
Font::Label, sel ? Role::BgBase : Role::TextPrimary, Align::Left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The level band: a recessed bg/cell channel with an accent-primary fill following the
|
||||
// live activity level (a direct level follow — the one permitted "motion", §3.5).
|
||||
if (layout.levelBand.height > 0) {
|
||||
fillSurface(bmp, toKitBox(layout.levelBand), Role::BgCell, InteractionState::Pressed);
|
||||
const double level = processor_ ? processor_->embedActivityLevel() : 0.0;
|
||||
const Rect fill = levelFillRect(layout, level);
|
||||
if (fill.width > 0) {
|
||||
LICE_FillRect(bmp, fill.x, fill.y, fill.width, fill.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerEmbed::onMouseDown(TPtrInt drawInfo) {
|
||||
auto* di = reinterpret_cast<const REAPER_FXEMBED_DrawInfo*>(drawInfo);
|
||||
if (!di || di->width <= 0 || di->height <= 0) return false;
|
||||
refresh();
|
||||
const EmbedLayout layout = layoutEmbed(di->width, di->height);
|
||||
const std::vector<EmbedZone> zones = toEmbedZones(map_);
|
||||
const int hit = zoneAtPoint(layout, zones.data(), static_cast<int>(zones.size()),
|
||||
di->mouse_x, di->mouse_y);
|
||||
if (hit == selectedZone_) return false; // no change -> no redraw
|
||||
selectedZone_ = hit;
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,113 @@
|
||||
#include "core/namespaces.h"
|
||||
// reasampler_embed.h — the S6 embedded TCP/MCP UI shell. Implements REAPER's
|
||||
// IReaperUIEmbedInterface (vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h +
|
||||
// reaper_vst3_interfaces.h) so the instrument draws a compact keymap/level strip INLINE in
|
||||
// the track/mixer control panel — the same Cockos surface REAPER's own embedded FX use.
|
||||
//
|
||||
// VERIFIED CONTRACT (against reaper_plugin_fx_embed.h + reaper_vst3_interfaces.h):
|
||||
// * VST3 exposes this by having the IEditController answer queryInterface for
|
||||
// IReaperUIEmbedInterface (iid {0x049bf9e7,0xbc74ead0,0xc4101e86,0x7f725981}). Our
|
||||
// SingleComponentEffect IS the edit controller, so the processor's queryInterface hands
|
||||
// REAPER a reference to this object.
|
||||
// * The single method is embed_message(int msg, TPtrInt parm2, TPtrInt parm3). msg is a
|
||||
// REAPER_FXEMBED_WM_* value (aliased to Win32 WM_*):
|
||||
// - WM_IS_SUPPORTED (0x0000): return 1 (supported+available), -1, or 0.
|
||||
// - WM_CREATE (0x0001) / WM_DESTROY (0x0002): embed begin/end; return ignored.
|
||||
// - WM_PAINT (0x000F): parm2 = REAPER_FXEMBED_IBitmap* (alias LICE_IBitmap) to draw
|
||||
// into; parm3 = REAPER_FXEMBED_DrawInfo* (context TCP=1/MCP=2, width/height, mouse,
|
||||
// flags). Return 1 if drawing occurred, 0 otherwise.
|
||||
// - WM_GETMINMAXINFO (0x0024): parm3 = SizeHints*; return 1 if filled.
|
||||
// - mouse WM_* (0x0200..0x020A): parm3 = DrawInfo*; return RETNOTIFY_INVALIDATE
|
||||
// (0x1000000) to force a redraw. Capture is auto-managed by the host.
|
||||
// * There is NO plugin-owned window/HWND here (unlike the IPlugView editor): REAPER hands
|
||||
// a LICE bitmap per paint; we only draw into it and read mouse coords from DrawInfo.
|
||||
//
|
||||
// RT DISCIPLINE (S6 constraint): all embed messages arrive on REAPER's UI thread; nothing
|
||||
// here runs in process(). It reads the same live state the editor reads (bank over the
|
||||
// bridge + the processor's performance map) with the same off-audio-thread accessors — no
|
||||
// new locks visible to process, read-only over the bank. Windows-only (D5), guarded so a
|
||||
// non-Windows build stays compilable.
|
||||
//
|
||||
// The strip's LAYOUT + HIT-TEST is pure (embed_strip.h, unit-tested); this shell marshals
|
||||
// REAPER's messages to/from it and draws with the same LICE idiom as reasampler_editor.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects)
|
||||
|
||||
// REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is
|
||||
// pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host
|
||||
// interface header. Its iid is DEFINEd (DEF_CLASS_IID) in reasampler_embed.cpp.
|
||||
namespace Steinberg {
|
||||
#include "reaper_vst3_interfaces.h"
|
||||
} // namespace Steinberg
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
// Implements IReaperUIEmbedInterface. Lifetime is OWNED by the processor (the processor
|
||||
// holds the sole unique_ptr and hands out AddRef'd references from queryInterface); the
|
||||
// back-pointer to the processor is therefore always valid while this lives.
|
||||
class ReaSamplerEmbed : public Steinberg::IReaperUIEmbedInterface {
|
||||
public:
|
||||
explicit ReaSamplerEmbed(ReaSamplerProcessor* processor) : processor_(processor) {}
|
||||
|
||||
// The one embed entry point. Routes each REAPER_FXEMBED_WM_* message; see the header
|
||||
// note above for the per-message contract. UI thread only.
|
||||
Steinberg::TPtrInt embed_message(int msg, Steinberg::TPtrInt parm2,
|
||||
Steinberg::TPtrInt parm3) override;
|
||||
|
||||
// FUnknown: this object's lifetime is owned by the processor, not the host refcount, so
|
||||
// AddRef/release are no-ops (the processor's unique_ptr governs destruction) and
|
||||
// queryInterface answers only FUnknown + IReaperUIEmbedInterface. This mirrors how the
|
||||
// SDK's OBJ refcount would otherwise churn; here the owning processor guarantees the
|
||||
// object outlives every borrowed reference REAPER holds during embedding.
|
||||
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
|
||||
void** obj) override;
|
||||
Steinberg::uint32 PLUGIN_API addRef() override { return 1000; }
|
||||
Steinberg::uint32 PLUGIN_API release() override { return 1000; }
|
||||
|
||||
private:
|
||||
#ifdef _WIN32
|
||||
// Draw the current strip into REAPER's supplied LICE bitmap. Returns true if it drew.
|
||||
bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo);
|
||||
// Handle a mouse-down inside the strip: map to a zone and select it (S6: selection at
|
||||
// most — no new editing semantics). Returns true if the selection changed (the caller
|
||||
// then asks REAPER to invalidate).
|
||||
bool onMouseDown(Steinberg::TPtrInt drawInfo);
|
||||
#endif
|
||||
|
||||
// Snapshot the live bank + the instrument's performance map for the next paint, exactly
|
||||
// as the editor's refreshSampleList does (bridge read + processor accessors, UI thread).
|
||||
void refresh();
|
||||
|
||||
// The S9 dirty-guard over refresh() (the S6 flagged follow-up): read the cheap bank-
|
||||
// generation stamp; do the EXPENSIVE bank-blob bridge read (refresh()) only when the
|
||||
// generation changed since the last paint (or on the first paint) — the strip re-read
|
||||
// per paint was wasteful now that a generation counter exists. The performance map (a
|
||||
// cheap in-process accessor, edited by the editor independently of bank content) is
|
||||
// ALWAYS refreshed so a zone edit still reflects immediately. UI thread only.
|
||||
void maybeRefresh();
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
// The bank generation last folded into samples_ (S9 dirty-guard). -1 forces the first
|
||||
// maybeRefresh() to do a full read (no generation can be negative — parseBankGeneration
|
||||
// yields >= 0 — so -1 is an "unprimed" sentinel distinct from a real generation 0).
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
// Snapshotted for the current paint (refreshed each paint off the audio thread).
|
||||
std::vector<SampleChoice> samples_;
|
||||
PerformanceMap map_;
|
||||
// The zone the last click selected (local/visual only — S6 selection constraint; the
|
||||
// processor's editor-shared selection is NOT updated from here); -1 = none.
|
||||
// Drives the strip's highlight.
|
||||
int selectedZone_ = -1;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
// reasampler_uid.h — the FOREVER-FROZEN VST3 class-UID constants, SDK-FREE.
|
||||
//
|
||||
// Split out of reasampler_vst.h (S-GA-DropFX) so the PURE extension side can derive the
|
||||
// class-ID string a .vstpreset file carries (instrument_drop::vstClassIdHex) WITHOUT
|
||||
// including the VST3 SDK: reasampler_vst.h needs Steinberg::FUID (SDK), but the UID VALUES
|
||||
// are plain integer macros. This header owns the values + the channel selection; nothing
|
||||
// else. reasampler_vst.h includes it to build the runtime FUID; instrument_drop includes it
|
||||
// to render the 32-char hex string. ONE source of truth — the frozen constants are written
|
||||
// exactly once, here.
|
||||
//
|
||||
// A class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates the
|
||||
// instrument records the UID, so changing it orphans every saved instance. Minted once;
|
||||
// do not regenerate. See reasampler_vst.h for the full channel-isolation story (S18).
|
||||
|
||||
#include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — the one channel bit
|
||||
|
||||
// STABLE class UID (S-NAME-1). Minted at the S1 spike (2026-07-26), locked. FROZEN FOREVER.
|
||||
#define REASAMPLER_PROC_UID_1 0x5E45A11E
|
||||
#define REASAMPLER_PROC_UID_2 0x9C7B4D6A
|
||||
#define REASAMPLER_PROC_UID_3 0xB1E3F208
|
||||
#define REASAMPLER_PROC_UID_4 0x4A6C1D9F
|
||||
|
||||
// BETA class UID (S18). Minted once (2026-07-26), locked FROM THIS WAVE per Daniel's
|
||||
// fast-track (fork S18-F1: mint now, not at first beta release). FROZEN FOREVER — the same
|
||||
// permanent lock as the stable UID; do not regenerate even though no beta VST has shipped.
|
||||
#define REASAMPLER_PROC_UID_BETA_1 0xCCFFEB3A
|
||||
#define REASAMPLER_PROC_UID_BETA_2 0x4FF532A6
|
||||
#define REASAMPLER_PROC_UID_BETA_3 0x9E181798
|
||||
#define REASAMPLER_PROC_UID_BETA_4 0x4256955F
|
||||
|
||||
// The channel-selected UID macros — exactly one class UID per binary. The factory's
|
||||
// INLINE_UID (compile-time brace init) and the runtime FUID in reasampler_vst.h both source
|
||||
// these, as does the extension's vstClassIdHex (the .vstpreset class-ID string), so the
|
||||
// binary identity and the preset-file identity cannot diverge.
|
||||
#if REASAMPLER_CHANNEL_IS_BETA
|
||||
#define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_BETA_1
|
||||
#define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_BETA_2
|
||||
#define REASAMPLER_ACTIVE_UID_3 REASAMPLER_PROC_UID_BETA_3
|
||||
#define REASAMPLER_ACTIVE_UID_4 REASAMPLER_PROC_UID_BETA_4
|
||||
#else
|
||||
#define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_1
|
||||
#define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_2
|
||||
#define REASAMPLER_ACTIVE_UID_3 REASAMPLER_PROC_UID_3
|
||||
#define REASAMPLER_ACTIVE_UID_4 REASAMPLER_PROC_UID_4
|
||||
#endif
|
||||
@@ -0,0 +1,53 @@
|
||||
#include "core/namespaces.h"
|
||||
// reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument
|
||||
// (Phase S). One place for the plugin's class UID, name, vendor, and version so the
|
||||
// processor, factory, and editor agree.
|
||||
//
|
||||
// A class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates this
|
||||
// instrument records the UID, so changing it orphans every saved instance. Minted once;
|
||||
// do not regenerate.
|
||||
//
|
||||
// CHANNEL ISOLATION (S18, beta-in-isolation — the instrument-side companion to V4). Just
|
||||
// as V4 gave the extension a per-channel ext-state namespace / command-id family / dock
|
||||
// ident, S18 gives the VST3 instrument a per-channel PLUGIN IDENTITY: its class UID, its
|
||||
// on-disk filename, and its display name all fork by the ONE channel bit
|
||||
// (REASAMPLER_CHANNEL_IS_BETA, from version_generated.h). ONE class per binary — the bit
|
||||
// selects which UID compiles into the single DEF_CLASS2, so a beta build carries only the
|
||||
// beta identity and can never present the stable one (mirrors V4's fully-isolated-binary
|
||||
// philosophy). The two UIDs below are BOTH frozen forever; the filename + display name
|
||||
// derive from app_version's vstOutputName()/vstPluginName() (this header owns only the
|
||||
// binary UID identity — the string identity lives in the pure module).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
|
||||
#include "shell/instrument/reasampler_uid.h" // the FROZEN UID macros + channel selection (SDK-free values)
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Vendor identity (S-NAME-1, SETTLED 2026-07-26). Shared across channels — V4 kept the
|
||||
// lane-name prefix shared, so shared-where-V4-shares is the default (the channel is carried
|
||||
// by the UID + filename + display fork, not the vendor block).
|
||||
inline constexpr const char* kVendorName = "ReaSampler";
|
||||
inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/reasampler";
|
||||
inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com";
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// The two FOREVER-FROZEN VST3 class UIDs — one per channel — live in reasampler_uid.h
|
||||
// (SDK-free, so the extension's pure instrument_drop can render the .vstpreset class-ID
|
||||
// string from the SAME constants without pulling the VST3 SDK). A saved REAPER project
|
||||
// records the UID of the instance it instantiated and rebinds by it on reopen, so each is
|
||||
// a permanent commitment. The channel bit selects which one this binary's factory registers
|
||||
// — one class per binary, never both. The UID selection is the ONLY channel #ifdef in the
|
||||
// VST shell (an INLINE_UID needs literal brace-init tokens, so it cannot route through
|
||||
// app_version's runtime string accessors — reasampler_uid.h owns the binary UID fork,
|
||||
// app_version owns the string fork).
|
||||
|
||||
// The runtime FUID for the class this binary registers — the channel-selected UID.
|
||||
static const Steinberg::FUID kReaSamplerProcessorUID(REASAMPLER_ACTIVE_UID_1,
|
||||
REASAMPLER_ACTIVE_UID_2,
|
||||
REASAMPLER_ACTIVE_UID_3,
|
||||
REASAMPLER_ACTIVE_UID_4);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "core/namespaces.h"
|
||||
// vst_entry.cpp — the VST3 module class factory (Phase S1). Enumerates the one class
|
||||
// this module offers (the ReaSampler instrument) via the SDK's factory macros. The
|
||||
// Windows module exports — GetPluginFactory (here, via BEGIN_FACTORY) and
|
||||
// InitDll/ExitDll (from the SDK's dllmain.cpp) — are how REAPER discovers and loads a
|
||||
// VST3.
|
||||
//
|
||||
// VERIFIED (corrects §1a's "experienced estimate" flags on export names + macros,
|
||||
// against vendor/vst3sdk/public.sdk/source/main/):
|
||||
// * Windows exports: InitDll / ExitDll (SMTG_EXPORT_SYMBOL, in dllmain.cpp) +
|
||||
// GetPluginFactory (SMTG_EXPORT_SYMBOL IPluginFactory* PLUGIN_API, emitted by the
|
||||
// BEGIN_FACTORY macro). The plug-in must provide InitModule/DeinitModule — supplied
|
||||
// here by linking moduleinit.cpp (the SDK's default one-time init/term).
|
||||
// * Factory macros: BEGIN_FACTORY(vendor,url,email,flags) / DEF_CLASS2(...) /
|
||||
// END_FACTORY — exact spellings from pluginfactory.h.
|
||||
// * Instrument subcategory string: "Instrument|Synth|Sampler"
|
||||
// (PlugType::kInstrumentSynthSampler, ivstaudioprocessor.h).
|
||||
// * classFlags = 0 for a SingleComponentEffect (non-distributable), matching the
|
||||
// AGain example.
|
||||
|
||||
#include "public.sdk/source/main/pluginfactory.h"
|
||||
|
||||
#include "pluginterfaces/vst/ivstaudioprocessor.h" // kVstAudioEffectClass, PlugType
|
||||
|
||||
#include "core/version/app_version.h" // vstPluginName / appVersion — the channel-derived identity
|
||||
#include "ext_keys.h" // kProjExtNamespace — the pairing-surface assertion target
|
||||
#include "reasampler_processor.h"
|
||||
#include "shell/instrument/reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*)
|
||||
|
||||
// CHANNEL PAIRING INVARIANT (S18). The instrument's PLUGIN identity forks by the ONE channel
|
||||
// bit (REASAMPLER_CHANNEL_IS_BETA — the class UID selected in reasampler_vst.h, the filename
|
||||
// + display name in app_version). Its DATA identity forks by the SAME bit, one layer down:
|
||||
// ext_keys.h's kProjExtNamespace() delegates to app_version::extStateNamespace(), so a beta
|
||||
// binary reads "reasampler_beta". Both derive from that one bit, so a beta VST can only ever
|
||||
// talk to the beta extension.
|
||||
//
|
||||
// The guard below pins the two forks together so a refactor cannot split them. It asserts
|
||||
// that the CLASS UID this factory registers (REASAMPLER_ACTIVE_UID_1, selected by the #if in
|
||||
// reasampler_vst.h) is the UID that matches THIS binary's channel bit. If someone edited that
|
||||
// #if to pick the wrong branch — registering the stable UID in a beta build, or vice versa —
|
||||
// the instrument's identity would diverge from the namespace ext_keys reads (a beta-named
|
||||
// plugin presenting the stable UID, or reading the stable banks under a beta identity). That
|
||||
// is exactly the silent split the invariant forbids, and it breaks the build here instead.
|
||||
// (The namespace itself is a runtime accessor — .c_str() on a channel-selected string — so
|
||||
// the couplable compile-time fact is the UID selection, not the namespace value; the
|
||||
// app_version_tests pin the namespace string per channel.)
|
||||
#if REASAMPLER_CHANNEL_IS_BETA
|
||||
static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_BETA_1 &&
|
||||
REASAMPLER_ACTIVE_UID_2 == REASAMPLER_PROC_UID_BETA_2 &&
|
||||
REASAMPLER_ACTIVE_UID_3 == REASAMPLER_PROC_UID_BETA_3 &&
|
||||
REASAMPLER_ACTIVE_UID_4 == REASAMPLER_PROC_UID_BETA_4,
|
||||
"S18: a beta build must register the BETA class UID that pairs with the beta "
|
||||
"extension's ext-state namespace — the UID selection and the channel bit split");
|
||||
#else
|
||||
static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_1 &&
|
||||
REASAMPLER_ACTIVE_UID_2 == REASAMPLER_PROC_UID_2 &&
|
||||
REASAMPLER_ACTIVE_UID_3 == REASAMPLER_PROC_UID_3 &&
|
||||
REASAMPLER_ACTIVE_UID_4 == REASAMPLER_PROC_UID_4,
|
||||
"S18: a stable build must register the STABLE class UID that pairs with the "
|
||||
"stable ext-state namespace — the UID selection and the channel bit split");
|
||||
#endif
|
||||
|
||||
BEGIN_FACTORY(reasampler::vst::kVendorName, reasampler::vst::kVendorUrl,
|
||||
reasampler::vst::kVendorEmail, Steinberg::PFactoryInfo::kNoFlags)
|
||||
|
||||
// The display name and version are channel-derived from app_version — sourced here, not
|
||||
// as literals. DEF_CLASS2 expands inside GetPluginFactory() and PClassInfo2's constructor
|
||||
// copies the char* into its own fixed buffer at that runtime call, so .c_str() on the
|
||||
// accessors' static-storage strings is valid (no dangling — the refs outlive the copy).
|
||||
// vstPluginName(): "ReaSampler 9000" / "ReaSampler 9000 beta" (live literals in
|
||||
// app_version.cpp). appVersion(): the configured version string / that string plus
|
||||
// "-beta" (the -beta render V4 already yields on beta).
|
||||
DEF_CLASS2(INLINE_UID(REASAMPLER_ACTIVE_UID_1, REASAMPLER_ACTIVE_UID_2,
|
||||
REASAMPLER_ACTIVE_UID_3, REASAMPLER_ACTIVE_UID_4),
|
||||
Steinberg::PClassInfo::kManyInstances, // cardinality
|
||||
kVstAudioEffectClass, // component category (fixed)
|
||||
reasampler::vstPluginName().c_str(), // plug-in display name (channel-derived)
|
||||
0, // single-component => 0
|
||||
Steinberg::Vst::PlugType::kInstrumentSynthSampler, // subcategory
|
||||
reasampler::appVersion().c_str(), // plug-in version (channel: -beta render)
|
||||
kVstVersionString, // VST3 SDK version (fixed)
|
||||
reasampler::vst::ReaSamplerProcessor::createInstance)
|
||||
|
||||
END_FACTORY
|
||||
@@ -0,0 +1,330 @@
|
||||
#include "core/namespaces.h"
|
||||
// draw_kit — the LICE/SWELL shell half of the drawing kit. See draw_kit.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. SHELL layer: it is the only kit file that
|
||||
// touches LICE + SWELL. All colors come from the pure `theme` module; all geometry from
|
||||
// the pure `component_geometry` module. DAW-verified, not unit-tested.
|
||||
|
||||
#include "shell/panel/draw_kit.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "core/ui/bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure)
|
||||
|
||||
// SWELL / LICE. On Windows use native Win32 (windows.h first); on mac/linux SWELL is
|
||||
// provided by the host. Mirrors bank_panel.cpp's include discipline.
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include "swell/swell.h"
|
||||
#endif
|
||||
#include "wdltypes.h"
|
||||
#include "lice/lice.h"
|
||||
#include "lice/lice_text.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// --- KitColor <-> LICE boundary ----------------------------------------------
|
||||
|
||||
// The one place a pure KitColor becomes a LICE_pixel. Verified packing: LICE_RGBA(r,g,b,a)
|
||||
// (lice.h:57). The theme owns the color; the shell owns the packing. Declared in draw_kit.h
|
||||
// so shell translation units (bank_panel) can use it without duplicating the LICE_RGBA pack.
|
||||
LICE_pixel toLice(const KitColor& c) {
|
||||
return LICE_RGBA(c.r, c.g, c.b, c.a);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// The draw alpha the LICE primitives take (0..1), from the KitColor's 8-bit alpha. Used so
|
||||
// a disabled surface (alpha 0.4) composites at the right opacity — LICE_FillRect etc. take
|
||||
// a float alpha argument separate from the pixel's own alpha byte.
|
||||
float drawAlpha(const KitColor& c) { return c.a / 255.0f; }
|
||||
|
||||
// --- Font set (owned by the kit) ---------------------------------------------
|
||||
|
||||
struct KitFonts {
|
||||
LICE_CachedFont title;
|
||||
LICE_CachedFont label;
|
||||
LICE_CachedFont valueMono;
|
||||
LICE_CachedFont micro;
|
||||
bool ready = false;
|
||||
};
|
||||
|
||||
KitFonts g_fonts;
|
||||
|
||||
// Creates one HFONT and hands it to a cached font with OWNS_HFONT so the cached font frees
|
||||
// it (lice_text.h:41). Negative lfHeight = point-ish pixel height (Win32 convention). The
|
||||
// face is chosen here so a change is one line.
|
||||
void loadFont(LICE_CachedFont& dst, int pxHeight, int weight, const char* face) {
|
||||
HFONT hf = CreateFont(-pxHeight, 0, 0, 0, weight, FALSE, FALSE, FALSE,
|
||||
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
|
||||
DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, face);
|
||||
if (!hf) return; // dst stays with no HFONT; DrawText on it renders nothing (safe)
|
||||
dst.SetFromHFont(hf, LICE_FONT_FLAG_OWNS_HFONT);
|
||||
dst.SetBkMode(TRANSPARENT);
|
||||
}
|
||||
|
||||
LICE_CachedFont* fontFor(Font f) {
|
||||
if (!g_fonts.ready) return nullptr;
|
||||
switch (f) {
|
||||
case Font::Title: return &g_fonts.title;
|
||||
case Font::Label: return &g_fonts.label;
|
||||
case Font::ValueMono: return &g_fonts.valueMono;
|
||||
case Font::Micro: return &g_fonts.micro;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
UINT alignFlag(Align a) {
|
||||
switch (a) {
|
||||
case Align::Left: return DT_LEFT;
|
||||
case Align::Center: return DT_CENTER;
|
||||
case Align::Right: return DT_RIGHT;
|
||||
}
|
||||
return DT_LEFT;
|
||||
}
|
||||
|
||||
// A 1px inner highlight on the top edge and shadow on the bottom edge — the vwnd trick
|
||||
// that gives a flat fill dimension (§2.2). Lightens the top row, darkens the bottom row.
|
||||
void innerEdges(LICE_IBitmap* bmp, const KitBox& b, float alpha) {
|
||||
if (b.width < 2 || b.height < 2) return;
|
||||
const LICE_pixel hi = LICE_RGBA(255, 255, 255, 255);
|
||||
const LICE_pixel lo = LICE_RGBA(0, 0, 0, 255);
|
||||
// Top inner highlight (subtle) and bottom inner shadow (subtle), inset 1px from the
|
||||
// vertical edges so corners read clean.
|
||||
LICE_Line(bmp, b.x + 1, b.y, b.x + b.width - 2, b.y, hi, 0.10f * alpha, 0, false);
|
||||
LICE_Line(bmp, b.x + 1, b.y + b.height - 1, b.x + b.width - 2, b.y + b.height - 1,
|
||||
lo, 0.22f * alpha, 0, false);
|
||||
}
|
||||
|
||||
// The kit's core surface fill: a top-down micro-gradient (a few percent lighter at the
|
||||
// top) + the inner highlight/shadow. Used by fillSurface and the component draws.
|
||||
void fillGradient(LICE_IBitmap* bmp, const KitBox& b, const KitColor& top,
|
||||
const KitColor& bottom) {
|
||||
if (b.empty()) return;
|
||||
const float a = drawAlpha(top);
|
||||
// LICE_GradRect wants initial R/G/B/A (0..1) and per-axis deltas. Verified signature
|
||||
// lice.h:466 — ir..ia are the top-left color; drdy..dady ramp DOWN the height so the
|
||||
// bottom row reaches `bottom`. No horizontal ramp (drdx.. = 0).
|
||||
const float ir = top.r / 255.0f, ig = top.g / 255.0f, ib = top.b / 255.0f;
|
||||
const float dr = (bottom.r - top.r) / 255.0f;
|
||||
const float dg = (bottom.g - top.g) / 255.0f;
|
||||
const float db = (bottom.b - top.b) / 255.0f;
|
||||
const float h = static_cast<float>(b.height);
|
||||
LICE_GradRect(bmp, b.x, b.y, b.width, b.height,
|
||||
ir, ig, ib, a,
|
||||
0.0f, 0.0f, 0.0f, 0.0f, // no per-x ramp
|
||||
dr / h, dg / h, db / h, 0.0f, // per-y ramp: top -> bottom
|
||||
LICE_BLIT_MODE_COPY);
|
||||
innerEdges(bmp, b, a);
|
||||
}
|
||||
|
||||
// A surface color and its gradient partner (a few percent lighter at the top). Elevation
|
||||
// reads as a subtle top-lightening of the same hue.
|
||||
void gradientPair(const KitColor& base, KitColor& top, KitColor& bottom) {
|
||||
top = base;
|
||||
// Lighten the top ~7% (clamped by the theme's own values staying < 255 in practice).
|
||||
auto lighten = [](int v) { int r = v + (v * 7) / 100 + 4; return r > 255 ? 255 : r; };
|
||||
top.r = static_cast<unsigned char>(lighten(base.r));
|
||||
top.g = static_cast<unsigned char>(lighten(base.g));
|
||||
top.b = static_cast<unsigned char>(lighten(base.b));
|
||||
bottom = base;
|
||||
}
|
||||
|
||||
RECT toRect(const KitBox& b) {
|
||||
return RECT{b.x, b.y, b.x + b.width, b.y + b.height};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- Font lifecycle ----------------------------------------------------------
|
||||
|
||||
void kitFontsInit() {
|
||||
if (g_fonts.ready) return; // idempotent
|
||||
// §3.1 type scale: title ~15px semibold, label ~12px, value-mono ~12px tabular,
|
||||
// micro ~10px. Segoe UI (universal on the Windows target); Consolas for numerics.
|
||||
loadFont(g_fonts.title, 15, FW_SEMIBOLD, "Segoe UI");
|
||||
loadFont(g_fonts.label, 12, FW_NORMAL, "Segoe UI");
|
||||
loadFont(g_fonts.valueMono, 12, FW_NORMAL, "Consolas");
|
||||
loadFont(g_fonts.micro, 10, FW_NORMAL, "Segoe UI");
|
||||
g_fonts.ready = true;
|
||||
}
|
||||
|
||||
void kitFontsShutdown() {
|
||||
if (!g_fonts.ready) return; // idempotent
|
||||
// LICE_CachedFont's destructor frees its OWNS_HFONT HFONT. Re-assigning an empty font
|
||||
// via SetFromHFont(nullptr) would leak nothing but also do nothing useful; instead we
|
||||
// mark not-ready and let the fonts release their HFONTs when g_fonts is reset. Because
|
||||
// g_fonts is a static instance (not re-created), free the HFONTs explicitly by handing
|
||||
// each a null font, which OWNS semantics clean up the prior HFONT (lice_text.h:41).
|
||||
g_fonts.title.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
|
||||
g_fonts.label.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
|
||||
g_fonts.valueMono.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
|
||||
g_fonts.micro.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
|
||||
g_fonts.ready = false;
|
||||
}
|
||||
|
||||
// --- Text --------------------------------------------------------------------
|
||||
|
||||
void text(LICE_IBitmap* bmp, const KitBox& box, const char* str,
|
||||
Font font, const KitColor& color, Align align) {
|
||||
if (!bmp || !str || box.empty()) return;
|
||||
LICE_CachedFont* f = fontFor(font);
|
||||
if (!f) return; // before init or font-create failed: draw nothing (safe)
|
||||
f->SetTextColor(toLice(color));
|
||||
f->SetBkMode(TRANSPARENT);
|
||||
RECT rc = toRect(box);
|
||||
f->DrawText(bmp, str, -1, &rc,
|
||||
alignFlag(align) | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
|
||||
}
|
||||
|
||||
void text(LICE_IBitmap* bmp, const KitBox& box, const char* str,
|
||||
Font font, Role role, Align align) {
|
||||
text(bmp, box, str, font, roleColor(role), align);
|
||||
}
|
||||
|
||||
// --- Surfaces + components ----------------------------------------------------
|
||||
|
||||
void fillSurface(LICE_IBitmap* bmp, const KitBox& box, Role role, InteractionState state) {
|
||||
if (!bmp || box.empty()) return;
|
||||
const KitColor base = roleColorState(role, state);
|
||||
KitColor top, bottom;
|
||||
gradientPair(base, top, bottom);
|
||||
fillGradient(bmp, box, top, bottom);
|
||||
}
|
||||
|
||||
void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label,
|
||||
InteractionState state, bool warn) {
|
||||
const KitBox& b = button.box;
|
||||
if (!bmp || b.empty()) return;
|
||||
|
||||
const Role surfaceRole = warn ? Role::Warn : Role::BgCell;
|
||||
const KitColor base = roleColorState(surfaceRole, state);
|
||||
KitColor top, bottom;
|
||||
gradientPair(base, top, bottom);
|
||||
|
||||
// Rounded surface: fill the interior gradient, then an AA rounded border. Corner
|
||||
// radius scales gently with height, clamped so tiny buttons stay legible.
|
||||
fillGradient(bmp, b, top, bottom);
|
||||
const int radius = b.height >= 20 ? 5 : (b.height >= 12 ? 3 : 2);
|
||||
const KitColor borderCol =
|
||||
(state == InteractionState::Active || state == InteractionState::Focus)
|
||||
? roleColor(Role::AccentPrimary)
|
||||
: roleColor(Role::LineHairline);
|
||||
LICE_RoundRect(bmp, static_cast<float>(b.x), static_cast<float>(b.y),
|
||||
static_cast<float>(b.width - 1), static_cast<float>(b.height - 1),
|
||||
radius, toLice(borderCol), drawAlpha(borderCol), 0, true);
|
||||
|
||||
if (label && *label) {
|
||||
// Active fill is the accent — draw its label in the base bg for contrast; else
|
||||
// text/primary (disabled dims via the state on the surface, label stays primary
|
||||
// but the whole control reads recessed).
|
||||
const Role textRole = (state == InteractionState::Active)
|
||||
? Role::BgBase
|
||||
: Role::TextPrimary;
|
||||
text(bmp, b, label, Font::Label, textRole, Align::Center);
|
||||
}
|
||||
}
|
||||
|
||||
void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state) {
|
||||
if (!bmp || geom.track.empty()) return;
|
||||
|
||||
// Track groove: the cell surface, recessed (pressed-ish) so it reads as a channel.
|
||||
fillSurface(bmp, geom.track, Role::BgCell, InteractionState::Pressed);
|
||||
|
||||
// Filled portion up to the handle: the accent (hover/dragging brighten it).
|
||||
if (!geom.filled.empty()) {
|
||||
const InteractionState fillState =
|
||||
(state == InteractionState::Hover || state == InteractionState::Dragging)
|
||||
? InteractionState::Hover
|
||||
: InteractionState::Active;
|
||||
KitColor top, bottom;
|
||||
gradientPair(roleColorState(Role::AccentPrimary, fillState), top, bottom);
|
||||
fillGradient(bmp, geom.filled, top, bottom);
|
||||
}
|
||||
|
||||
// Handle: a raised knob honoring state.
|
||||
if (!geom.handle.empty()) {
|
||||
const KitButtonBox knob{geom.handle};
|
||||
drawButton(bmp, knob, nullptr, state, /*warn=*/false);
|
||||
}
|
||||
}
|
||||
|
||||
void drawListRow(LICE_IBitmap* bmp, const ListRowBox& row, const char* label,
|
||||
int thumbWidth, InteractionState state) {
|
||||
const KitBox& b = row.box;
|
||||
if (!bmp || b.empty()) return;
|
||||
|
||||
// Row surface: bg/cell transformed by state (hover lightens, active = accent).
|
||||
fillSurface(bmp, b, Role::BgCell, state);
|
||||
|
||||
// Focus ring: a 1px text/primary rectangle, distinct from the accent selection fill.
|
||||
if (state == InteractionState::Focus) {
|
||||
const KitColor ring = roleColor(Role::TextPrimary);
|
||||
LICE_DrawRect(bmp, b.x, b.y, b.width - 1, b.height - 1,
|
||||
toLice(ring), drawAlpha(ring), 0);
|
||||
}
|
||||
|
||||
// Label in the width after the reserved thumbnail inset. Active rows draw the label in
|
||||
// bg/base for contrast against the accent fill; else text/primary.
|
||||
if (label && *label) {
|
||||
const int inset = thumbWidth > 0 ? thumbWidth + 6 : 6;
|
||||
KitBox labelBox{b.x + inset, b.y, b.width - inset - 6, b.height};
|
||||
if (!labelBox.empty()) {
|
||||
const Role tr = (state == InteractionState::Active) ? Role::BgBase
|
||||
: Role::TextPrimary;
|
||||
text(bmp, labelBox, label, Font::Label, tr, Align::Left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) {
|
||||
if (!bmp || box.empty()) return;
|
||||
|
||||
const LICE_pixel midCol = toLice(roleColor(Role::LineHairline));
|
||||
const LICE_pixel waveCol = toLice(roleColor(Role::AccentPrimary));
|
||||
|
||||
if (env.empty()) {
|
||||
const int midY = box.y + box.height / 2;
|
||||
LICE_Line(bmp, box.x + 2, midY, box.x + box.width - 2, midY,
|
||||
midCol, 1.0f, 0, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const int channels = static_cast<int>(env.size());
|
||||
const int bandH = box.height / channels;
|
||||
const int innerW = waveformColumnCount(box); // columns: box.x+2 .. box.x+2+innerW-1
|
||||
|
||||
for (int ch = 0; ch < channels; ++ch) {
|
||||
const ChannelEnvelope& bins = env[static_cast<std::size_t>(ch)];
|
||||
const int bandTop = box.y + ch * bandH;
|
||||
const int midY = bandTop + bandH / 2;
|
||||
const double halfSpan = (bandH / 2) - 2;
|
||||
|
||||
LICE_Line(bmp, box.x + 2, midY, box.x + box.width - 2, midY,
|
||||
midCol, 1.0f, 0, false);
|
||||
|
||||
if (bins.empty() || innerW <= 0) continue;
|
||||
|
||||
// Render one filled vertical span per pixel column. peaks::columnMinMax merges
|
||||
// all bins that project to column `col` under the exact same partition as
|
||||
// computeEnvelope used to build the envelope, so every pixel column is covered
|
||||
// with no gaps regardless of the bins-to-pixels ratio. With one bin per column
|
||||
// (kWaveformOversample == 1) each span covers the true min/max of exactly the
|
||||
// frames that fall in that column. Same dB display compression everywhere
|
||||
// (bank_grid, pure).
|
||||
for (int col = 0; col < innerW; ++col) {
|
||||
const MinMax mm = columnMinMax(bins, innerW, col);
|
||||
const int x = box.x + 2 + col;
|
||||
int yMax = midY - static_cast<int>(
|
||||
compressAmplitudeForDisplay(mm.max) * halfSpan);
|
||||
int yMin = midY - static_cast<int>(
|
||||
compressAmplitudeForDisplay(mm.min) * halfSpan);
|
||||
if (yMax < bandTop) yMax = bandTop;
|
||||
if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
|
||||
LICE_Line(bmp, x, yMin, x, yMax, waveCol, 1.0f, 0, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// draw_kit — the LICE-facing SHELL half of the shared drawing kit (Phase L, L1). This is
|
||||
// the ONE source of drawing for the whole system: every surface (bank_panel now; the VST
|
||||
// editor + embed strip at L3) fills, buttons, rows, sliders, waveforms, and — above all —
|
||||
// draws TEXT through this kit, so a control looks identical everywhere because it is the
|
||||
// same kit function. It replaces the flat LICE_FillRect blocks and raw-GDI DrawTextA with
|
||||
// gradient/AA surfaces (the vwnd micro-gradient + inner highlight/shadow trick) and cached
|
||||
// anti-aliased text (LICE_CachedFont), honoring the interaction-state model.
|
||||
//
|
||||
// PURE/SHELL SPLIT (CLAUDE.md §load-bearing): this file is SHELL — it touches LICE and
|
||||
// SWELL (HFONT). All palette decisions come from the pure `theme` module (role -> KitColor);
|
||||
// all layout/hit-test from the pure `component_geometry` / mode_switch / etc. modules. This
|
||||
// file only turns those pure answers into LICE calls. It is DAW-verified, not unit-tested.
|
||||
//
|
||||
// FONT LIFECYCLE (owned here): the kit holds a small set of LICE_CachedFonts (title / label
|
||||
// / value-mono / micro). kitFontsInit() creates them once (from HFONTs handed off with
|
||||
// LICE_FONT_FLAG_OWNS_HFONT, so the cached font frees the HFONT itself — verified in
|
||||
// lice_text.h §SetFromHFont doc: "OWNS means LICE_IFont will clean up hfont on font change
|
||||
// or exit"). kitFontsShutdown() deletes the cached fonts. The consumer calls init on panel
|
||||
// open and shutdown on close/teardown. text() no-ops safely before init (defensive), so a
|
||||
// draw that races construction never crashes.
|
||||
//
|
||||
// DOUBLE-BUFFER DISCIPLINE (§3.5 "zero-jank"): every function here draws into the caller's
|
||||
// offscreen LICE_IBitmap; the caller BitBlt's once. Nothing here draws direct-to-DC.
|
||||
|
||||
#include "core/ui/component_geometry.h" // KitBox / SliderGeometry — the pure geometry the shell draws
|
||||
#include "core/audio/peaks.h" // Envelope — the waveform primitive's input
|
||||
#include "core/ui/theme.h" // Role / InteractionState / KitColor / TextClass
|
||||
|
||||
// LICE types at the boundary (this is the shell half). LICE_IBitmap is forward-declared
|
||||
// to keep the header light. LICE_pixel is a typedef (unsigned int) — not forward-declarable
|
||||
// — so the full lice.h is included only for the toLice() declaration; on Windows lice.h
|
||||
// pulls in <windows.h>, which is fine since draw_kit.h is shell-only and never included by
|
||||
// a pure module.
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
#include "lice/lice.h"
|
||||
class LICE_IBitmap;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The kit's four cached fonts (§3.1 type scale). Consumers pass a Font to text() to pick
|
||||
// the size/weight; the kit maps it to the matching LICE_CachedFont.
|
||||
enum class Font {
|
||||
Title, // ~15px semibold — region titles, headings
|
||||
Label, // ~12px regular — labels, body
|
||||
ValueMono, // ~12px tabular/mono — numbers (dB/ms/notes) that must not jitter
|
||||
Micro, // ~10px dim — units, counts, keybinding sub-labels
|
||||
};
|
||||
|
||||
// Horizontal text alignment for text(). Vertical is always centered in the rect (the kit's
|
||||
// single-line convention); a caller wanting multi-line composes rows itself.
|
||||
enum class Align { Left, Center, Right };
|
||||
|
||||
// --- KitColor → LICE_pixel conversion ----------------------------------------
|
||||
|
||||
// The one place a pure KitColor becomes a LICE_pixel. Declared here so any shell
|
||||
// translation unit that already includes draw_kit.h can use it without duplicating
|
||||
// the LICE_RGBA packing. Defined in draw_kit.cpp.
|
||||
LICE_pixel toLice(const KitColor& c);
|
||||
|
||||
// --- Font lifecycle (owned by the kit) ---------------------------------------
|
||||
|
||||
// Creates the four cached fonts once. Idempotent: a second call before shutdown is a no-op
|
||||
// (the kit already holds live fonts). Safe to call on every panel open. Uses the platform
|
||||
// UI sans (Segoe UI) for title/label/micro and a tabular mono (Consolas) for value-mono;
|
||||
// the exact HFONT is created here, so a face change is a one-line edit. NO-OP-SAFE: if font
|
||||
// creation fails, text() degrades to drawing nothing rather than crashing.
|
||||
void kitFontsInit();
|
||||
|
||||
// Deletes the cached fonts (which free their owned HFONTs — LICE_FONT_FLAG_OWNS_HFONT).
|
||||
// Idempotent. The consumer calls this on panel close / extension shutdown.
|
||||
void kitFontsShutdown();
|
||||
|
||||
// --- Text (the single biggest "temple os -> modern" lever) -------------------
|
||||
|
||||
// Draws a single line of AA cached-font text in `color` inside `box`, horizontally aligned
|
||||
// per `align` and vertically centered, clipped with an end-ellipsis. This REPLACES the
|
||||
// GDI SetTextColor + DrawText path. No-op (safe) before kitFontsInit() or on a null bitmap.
|
||||
void text(LICE_IBitmap* bmp, const KitBox& box, const char* str,
|
||||
Font font, const KitColor& color, Align align);
|
||||
|
||||
// Convenience overload: text in a palette ROLE's color (the common case — the shell almost
|
||||
// always wants text/primary or text/dim, not a raw color).
|
||||
void text(LICE_IBitmap* bmp, const KitBox& box, const char* str,
|
||||
Font font, Role role, Align align);
|
||||
|
||||
// --- Surfaces + components ----------------------------------------------------
|
||||
|
||||
// The kit's foundational fill: a micro-gradient (a few percent lighter at the top, via
|
||||
// LICE_GradRect) plus a 1px inner top-highlight and bottom-shadow — the vwnd trick that
|
||||
// kills the flat look (§2.2). Every button/row/cell fills through this so elevation reads
|
||||
// without a border. `role` picks the surface color; `state` transforms it per the
|
||||
// interaction model (hover lightens, pressed darkens, disabled desaturates, etc.).
|
||||
void fillSurface(LICE_IBitmap* bmp, const KitBox& box, Role role, InteractionState state);
|
||||
|
||||
// A rounded, gradient-filled button with the inner highlight/shadow and a centered label,
|
||||
// honoring the interaction state. `warn == true` swaps the surface to the warn role (for
|
||||
// byte-deleting verbs like prune/delete) — the only place warn is drawn. A degenerate box
|
||||
// is a no-op.
|
||||
void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label,
|
||||
InteractionState state, bool warn);
|
||||
|
||||
// A horizontal slider: the track groove, the accent-filled portion up to the handle, and
|
||||
// the handle (a raised knob honoring state — hover/dragging brighten it). `geom` is the
|
||||
// pure SliderGeometry the caller computed; the kit only draws it. Degenerate geom is a no-op.
|
||||
void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state);
|
||||
|
||||
// A selectable list row: the row surface (rest/hover/active/focus via state), an optional
|
||||
// leading thumbnail area reserved at `thumbWidth` px (0 for none — the caller draws the
|
||||
// thumbnail into the returned-by-convention left inset), and a left-aligned label in the
|
||||
// remaining width. Focus draws a 1px text/primary ring distinct from the accent selection
|
||||
// fill. A degenerate row is a no-op.
|
||||
void drawListRow(LICE_IBitmap* bmp, const ListRowBox& row, const char* label,
|
||||
int thumbWidth, InteractionState state);
|
||||
|
||||
// waveformColumnCount — declared in component_geometry.h (already included above). Returns
|
||||
// the drawable column count inside `box` (box.width minus the fixed 2px insets each side).
|
||||
// Callers pass this value directly as the `binCount` argument to peaks::computeEnvelope;
|
||||
// overbinning (more bins than columns) costs memory and CPU without changing a rendered
|
||||
// pixel — peaks::columnMinMax's exact partition already makes the draw gap-free.
|
||||
|
||||
// Multiplier kept at 1 (no oversampling). kWaveformOversample is present only so existing
|
||||
// call sites `kWaveformOversample * waveformColumnCount(box)` compile unchanged; a value of
|
||||
// 1 means they request exactly one bin per column, which is correct. The gap-free render
|
||||
// comes from peaks::columnMinMax's exact partition, NOT from extra bins.
|
||||
inline constexpr int kWaveformOversample = 1;
|
||||
|
||||
// A waveform envelope drawn as a min/max plot over the bg/panel surface: a midline per
|
||||
// channel and one accent vertical span PER PIXEL COLUMN, each column covering the true
|
||||
// extremes of every bin that projects to it (peaks::columnMinMax — gap-free at any
|
||||
// bins-to-pixels ratio because columnMinMax partitions bins exactly as computeEnvelope
|
||||
// does, so every pixel column is always covered). The ONE waveform shape in the system:
|
||||
// the dock-panel thumbnail, the browser cards, and the editor hero all render through
|
||||
// this. `box` is the draw region; `env` is the per-channel min/max envelope from
|
||||
// peaks::computeEnvelope, sized to waveformColumnCount(box) bins (clamped to frame count).
|
||||
// An empty env draws just the midline. The caller fills the surface first (or passes a
|
||||
// box already filled); this draws only the wave + midline.
|
||||
void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,276 @@
|
||||
#include "core/namespaces.h"
|
||||
// usage_scan.cpp — see usage_scan.h. The REAPER reads behind the pS-usage prune
|
||||
// protection; every decision is in the pure sample_usage module, this TU only reads.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers
|
||||
// (CLAUDE.md §contract). Every REAPER symbol used here is verified against
|
||||
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||
// * EnumProjExtState(proj, extname, idx, keyOut, sz, valOut, sz) -> bool (~1272)
|
||||
// * GetProjExtState(proj, extname, key, valOut, sz) -> int (~2591)
|
||||
// * CountTracks / GetTrack / GetMasterTrack (track scan)
|
||||
// * TrackFX_GetCount(MediaTrack*) / TrackFX_GetRecCount(MediaTrack*) (~7283/7570)
|
||||
// * TrackFX_GetNamedConfigParm(MediaTrack*, int, parm, buf, sz) -> bool (~7377)
|
||||
// * CountMediaItems / GetMediaItem (~423/1964)
|
||||
// * CountTakes(MediaItem*) / GetMediaItemTake(MediaItem*, int) (~471/2029)
|
||||
// * GetMediaItemTrack(MediaItem*) (~2133)
|
||||
// * TakeFX_GetCount / TakeFX_GetNamedConfigParm (~6710/6774)
|
||||
// * guidToString (via track_guid::guidString)
|
||||
|
||||
#include "shell/persist/usage_scan.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "core/version/app_version.h" // vstPluginName / vstOutputName (channel name needles)
|
||||
#include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix
|
||||
#include "core/wire/instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex
|
||||
#include "core/wire/sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions)
|
||||
#include "shell/capture/track_guid.h" // guidString — the ONE canonical GUID key formatter
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjExtState
|
||||
#define REAPERAPI_WANT_GetProjExtState
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetMasterTrack
|
||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||
#define REAPERAPI_WANT_TrackFX_GetRecCount
|
||||
#define REAPERAPI_WANT_TrackFX_GetNamedConfigParm
|
||||
#define REAPERAPI_WANT_CountMediaItems
|
||||
#define REAPERAPI_WANT_GetMediaItem
|
||||
#define REAPERAPI_WANT_CountTakes
|
||||
#define REAPERAPI_WANT_GetMediaItemTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTrack
|
||||
#define REAPERAPI_WANT_TakeFX_GetCount
|
||||
#define REAPERAPI_WANT_TakeFX_GetNamedConfigParm
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// The three UPPERCASED channel needles identityMatches (pure, sample_usage) checks
|
||||
// every FX identity string against. One instance drives the whole scan.
|
||||
struct FxIdentityNeedles {
|
||||
std::string uidHexUpper; // 32-hex VST3 class UID (may not appear on all builds)
|
||||
std::string outputNameUpper; // "REASAMPLER_9000" — the .vst3 filename base fx_ident embeds
|
||||
std::string nameUpper; // "REASAMPLER 9000" — factory display name
|
||||
};
|
||||
|
||||
// A named-config-parm getter abstracted over the FX-chain kind: track FX and take FX
|
||||
// share the identical identity walk (fx_ident + original_name + container recursion),
|
||||
// differing only in which REAPER getter reads the parm.
|
||||
using FxParmGetter =
|
||||
std::function<std::string(int fxId, const char* parm)>;
|
||||
|
||||
// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a
|
||||
// ReaSampler 9000. BOTH fx_ident and original_name are checked on BOTH chain kinds (a
|
||||
// renamed instance may keep its original_name; fx_ident carries the module path — the
|
||||
// primary identification net is the module filename base via fx_ident, which holds even
|
||||
// after a user renames the FX instance). Containers are walked via
|
||||
// the documented container_count / container_item.X addressing (v7.06+); on a chain
|
||||
// kind or REAPER version without containers the parm read returns empty and recursion
|
||||
// is a no-op. `depth` bounds pathological nesting. fx_ident is queried per FX — chain
|
||||
// enumeration is chunk-level, so OFFLINE instances match too (load-bearing: a
|
||||
// Design-View-parked instance must keep protecting its holds).
|
||||
bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId,
|
||||
const FxIdentityNeedles& id, int depth) {
|
||||
if (identityMatches(parm(fxId, "fx_ident"), id.uidHexUpper, id.nameUpper,
|
||||
id.outputNameUpper) ||
|
||||
identityMatches(parm(fxId, "original_name"), id.uidHexUpper, id.nameUpper,
|
||||
id.outputNameUpper))
|
||||
return true;
|
||||
const std::string countStr = parm(fxId, "container_count");
|
||||
if (countStr.empty()) return false; // not a container; no children to miss
|
||||
if (depth <= 0) {
|
||||
// This node IS a container but we have exhausted our descent budget. We cannot
|
||||
// prove that none of its children is a ReaSampler 9000 instance — treat the
|
||||
// incomplete walk as a positive identification (the protect direction). This is
|
||||
// defense-in-depth: kMaxContainerDepth = 32 should prevent reaching this branch
|
||||
// in any real project, but if it IS reached the fail-safe fires rather than
|
||||
// silently missing a live nested instance.
|
||||
return true;
|
||||
}
|
||||
const int n = std::atoi(countStr.c_str());
|
||||
for (int k = 0; k < n; ++k) {
|
||||
const std::string item =
|
||||
parm(fxId, ("container_item." + std::to_string(k)).c_str());
|
||||
if (item.empty()) continue;
|
||||
const int childId = std::atoi(item.c_str());
|
||||
if (childId <= 0) continue;
|
||||
if (fxSubtreeHasInstance(parm, childId, id, depth - 1)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Raised from 8 to 32 (defense in depth against truncation). Real-world FX containers
|
||||
// are typically 2–4 levels deep; 32 is unreachable in practice while remaining finite.
|
||||
// Even at 32, the truncation→protect-all guard below is the primary protection.
|
||||
constexpr int kMaxContainerDepth = 32;
|
||||
|
||||
std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) {
|
||||
char buf[2048] = {0};
|
||||
if (!TrackFX_GetNamedConfigParm(tr, fxId, parm, buf, static_cast<int>(sizeof(buf))))
|
||||
return {};
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
std::string takeFxParm(MediaItem_Take* take, int fxId, const char* parm) {
|
||||
char buf[2048] = {0};
|
||||
if (!TakeFX_GetNamedConfigParm(take, fxId, parm, buf, static_cast<int>(sizeof(buf))))
|
||||
return {};
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// True if `tr` hosts >= 1 ReaSampler 9000 anywhere: normal chain, record/input chain
|
||||
// (index | 0x1000000), containers recursively.
|
||||
bool trackHasInstance(MediaTrack* tr, const FxIdentityNeedles& id) {
|
||||
const FxParmGetter parm = [tr](int fxId, const char* p) {
|
||||
return trackFxParm(tr, fxId, p);
|
||||
};
|
||||
const int n = TrackFX_GetCount(tr);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (fxSubtreeHasInstance(parm, i, id, kMaxContainerDepth)) return true;
|
||||
}
|
||||
const int rec = TrackFX_GetRecCount(tr);
|
||||
for (int i = 0; i < rec; ++i) {
|
||||
if (fxSubtreeHasInstance(parm, 0x1000000 + i, id, kMaxContainerDepth))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just active — a
|
||||
// non-active take's instance still exists in the project and reactivates with the
|
||||
// take). The SAME identity walk as the track path: fx_ident + original_name + container
|
||||
// recursion (an unrecognized exotic still lands in the pure protect-all net — records
|
||||
// with zero identified instances protect everything rather than nothing).
|
||||
bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) {
|
||||
const int takes = CountTakes(item);
|
||||
for (int t = 0; t < takes; ++t) {
|
||||
MediaItem_Take* take = GetMediaItemTake(item, t);
|
||||
if (!take) continue;
|
||||
const FxParmGetter parm = [take](int fxId, const char* p) {
|
||||
return takeFxParm(take, fxId, p);
|
||||
};
|
||||
const int n = TakeFX_GetCount(take);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (fxSubtreeHasInstance(parm, i, id, kMaxContainerDepth)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Growing GetProjExtState read (the persist.cpp idiom): the usage record scales with
|
||||
// the hold count, so a fixed buffer risks a truncated decode. Returns nullopt when the
|
||||
// key cannot be read WHOLE — absent-after-enumeration (rv <= 0) or pathologically large
|
||||
// (> 16 MB give-up). The caller only queries keys the enumeration just listed, so a
|
||||
// nullopt here is a PRESENT-BUT-UNREADABLE record: it folds to abortPrune (fail-safe —
|
||||
// silently reduced protection is the delete direction).
|
||||
std::optional<std::string> readExtStateValue(ReaProject* proj, const char* key) {
|
||||
for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) {
|
||||
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
|
||||
const int rv = GetProjExtState(proj, kProjExtNamespace(), key, buf.data(), cap);
|
||||
if (rv <= 0) return std::nullopt;
|
||||
std::string s(buf.data());
|
||||
if (static_cast<int>(s.size()) + 1 < cap) return s;
|
||||
// else possibly truncated -> grow and retry
|
||||
}
|
||||
return std::nullopt; // > 16 MB — unreadable whole, never "absent"
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
|
||||
ReaProject* proj = static_cast<ReaProject*>(projOpaque);
|
||||
UsageScanResult result;
|
||||
|
||||
// 1. Enumerate the rsusage_* keys and read+decode each record. Key names first
|
||||
// (values via the growing reader — EnumProjExtState's fixed val buffer could
|
||||
// truncate a large record). A nullopt element = present-but-unreadable/
|
||||
// undecodable -> the pure fold ABORTS the prune.
|
||||
std::vector<std::string> usageKeys;
|
||||
{
|
||||
const std::string prefix = kProjExtUsageKeyPrefix; // hoisted: one alloc, not N
|
||||
char keyBuf[256];
|
||||
for (int idx = 0;; ++idx) {
|
||||
keyBuf[0] = '\0';
|
||||
if (!EnumProjExtState(proj, kProjExtNamespace(), idx, keyBuf,
|
||||
static_cast<int>(sizeof(keyBuf)), nullptr, 0))
|
||||
break;
|
||||
const std::string key(keyBuf);
|
||||
if (key.compare(0, prefix.size(), prefix) == 0) usageKeys.push_back(key);
|
||||
}
|
||||
}
|
||||
if (usageKeys.empty()) return result; // no instance ever published — skip the scan
|
||||
|
||||
std::vector<std::optional<UsageRecord>> decoded;
|
||||
decoded.reserve(usageKeys.size());
|
||||
for (std::size_t ki = 0; ki < usageKeys.size(); ++ki) {
|
||||
const std::string& key = usageKeys[ki];
|
||||
const std::optional<std::string> value = readExtStateValue(proj, key.c_str());
|
||||
if (!value) {
|
||||
decoded.push_back(std::nullopt); // unreadable -> abort (pure fold)
|
||||
result.offendingKeys.push_back(key);
|
||||
continue;
|
||||
}
|
||||
const std::optional<UsageRecord> rec = decodeUsageRecord(*value);
|
||||
if (!rec) result.offendingKeys.push_back(key);
|
||||
decoded.push_back(rec); // undecodable nullopt -> abort
|
||||
}
|
||||
|
||||
// 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen needle set drives
|
||||
// every match; a track needs only ONE instance to keep all its records live.
|
||||
FxIdentityNeedles id;
|
||||
id.uidHexUpper = toUpperAscii(vstClassIdHex());
|
||||
id.outputNameUpper = toUpperAscii(vstOutputName());
|
||||
id.nameUpper = toUpperAscii(vstPluginName());
|
||||
std::unordered_set<std::string> liveTrackGuids;
|
||||
bool anyLive = false;
|
||||
|
||||
if (MediaTrack* master = GetMasterTrack(proj)) {
|
||||
if (trackHasInstance(master, id)) {
|
||||
liveTrackGuids.insert(guidString(master));
|
||||
anyLive = true;
|
||||
}
|
||||
}
|
||||
const int trackCount = CountTracks(proj);
|
||||
for (int i = 0; i < trackCount; ++i) {
|
||||
MediaTrack* tr = GetTrack(proj, i);
|
||||
if (!tr) continue;
|
||||
if (trackHasInstance(tr, id)) {
|
||||
liveTrackGuids.insert(guidString(tr));
|
||||
anyLive = true;
|
||||
}
|
||||
}
|
||||
// Take-FX instances: attributed to the owning track (the VST-side getReaperParent(1)
|
||||
// resolves the same track), and they set anyLive for the empty-guid fallback.
|
||||
const int itemCount = CountMediaItems(proj);
|
||||
for (int i = 0; i < itemCount; ++i) {
|
||||
MediaItem* item = GetMediaItem(proj, i);
|
||||
if (!item) continue;
|
||||
if (itemHasInstance(item, id)) {
|
||||
if (MediaTrack* tr = GetMediaItemTrack(item)) {
|
||||
liveTrackGuids.insert(guidString(tr));
|
||||
}
|
||||
anyLive = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The pure fold decides: abort on any unreadable record; protect-all when zero
|
||||
// instances were identified; otherwise the per-record liveness rule.
|
||||
const UsageFoldResult fold = foldUsageRecords(decoded, liveTrackGuids, anyLive);
|
||||
result.abortPrune = fold.abortPrune;
|
||||
result.heldPaths = fold.heldPaths;
|
||||
// offendingKeys already populated above (unreadable + undecodable entries);
|
||||
// clear it on success so callers see it only when abortPrune is set.
|
||||
if (!result.abortPrune) result.offendingKeys.clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for
|
||||
// the pure core, the fail-safe folds, and the full design note). At prune-scan time it
|
||||
// answers ONE question: which project-relative bank paths are held by a LIVE ReaSampler
|
||||
// 9000 instance — or must the prune ABORT because a usage record could not be read?
|
||||
//
|
||||
// Three reads, no writes (the prune scan's READ-ONLY contract holds):
|
||||
// 1. Enumerate every "rsusage_<guid>" key in the "reasampler" ext-state namespace
|
||||
// (EnumProjExtState) and decode each record (sample_usage wire). A key that is
|
||||
// present but cannot be read or decoded folds to abortPrune (fail-safe: an
|
||||
// unreadable record may protect anything, so the prune halts and deletes nothing).
|
||||
// 2. Enumerate every ReaSampler 9000 FX instance in the project — all tracks
|
||||
// (master included), normal + record/input chains, FX containers recursively, and
|
||||
// take FX (same container recursion) — matching each FX's fx_ident AND
|
||||
// original_name via the pure sample_usage::identityMatches (class-UID hex, module
|
||||
// filename base, display name; see the matcher note there).
|
||||
// 3. Fold with the pure liveness rule (sample_usage::foldUsageRecords /
|
||||
// usageHeldPaths): a record counts iff its publishing track still hosts >= 1
|
||||
// instance; a record with no track context counts while any instance exists; and
|
||||
// when records exist but ZERO instances were identified anywhere, EVERY record's
|
||||
// paths are protected (the identity-failure net — a matcher failure must never
|
||||
// degrade toward delete).
|
||||
//
|
||||
// The result feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans, so
|
||||
// `referenced` = bank references ∪ live-instance holds — a held capture can never be
|
||||
// an orphan, and BANK_PRUNE_FOLDER (the only deletion authority) can never delete it.
|
||||
// abortPrune propagates through PruneScan/PruneReport to the action, which halts.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). The
|
||||
// header stays REAPER-free (`proj` is the opaque ReaProject* the persist seam already
|
||||
// passes around as void*).
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The scan outcome. When abortPrune is true a present rsusage_* record could not be
|
||||
// read or decoded — the caller MUST halt the prune (delete nothing). offendingKeys
|
||||
// names the exact "rsusage_<guid>" keys that triggered the abort so the action can
|
||||
// print them for operator recovery (clear via ReaScript:
|
||||
// reaper.SetProjExtState(0, "reasampler", "<key>", "")
|
||||
// for each offending key). heldPaths on abort is the protect-all set (every readable
|
||||
// record's paths) — meaningful only as a belt-and-braces fallback; the abort flag is
|
||||
// the authoritative signal. Otherwise heldPaths is every project-relative path held by
|
||||
// a live ReaSampler 9000 instance, de-duped, in record order — empty in the common
|
||||
// no-records case (the FX enumeration is skipped entirely).
|
||||
struct UsageScanResult {
|
||||
bool abortPrune = false;
|
||||
std::vector<std::string> offendingKeys; // non-empty iff abortPrune
|
||||
std::vector<std::string> heldPaths;
|
||||
};
|
||||
|
||||
// Scan `proj` (nullptr = active project). READ-ONLY: no ext-state write, no project
|
||||
// mutation.
|
||||
UsageScanResult liveInstanceHeldPaths(void* proj);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,678 @@
|
||||
#include "core/namespaces.h"
|
||||
// view.cpp — REAPER-facing Design View shell (Phase D2). See view.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
//
|
||||
// The tree arithmetic (I_FOLDERDEPTH -> FolderTree) lives in the pure view_tree
|
||||
// module so it is unit-tested outside the DAW; this file owns only the REAPER
|
||||
// reads/writes and the snapshot-before-park ordering.
|
||||
|
||||
#include "shell/view/view.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "shell/capture/item_read.h"
|
||||
#include "core/view/lane_keys.h"
|
||||
#include "shell/capture/track_guid.h"
|
||||
#include "core/view/view_tree.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||
#define REAPERAPI_WANT_TrackFX_GetOffline
|
||||
#define REAPERAPI_WANT_TrackFX_SetOffline
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#define REAPERAPI_WANT_TrackList_AdjustWindows
|
||||
#define REAPERAPI_WANT_UpdateArrange
|
||||
#define REAPERAPI_WANT_UpdateTimeline
|
||||
// Lane minting (D2 Wave 3): enumerate a track's items and read/write item-side lane
|
||||
// state to assign each item to its mode's managed lane.
|
||||
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaItemInfo_Value
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// Track fixed-lane mode value (I_FREEMODE=2). See SDK: 0=normal, 1=free item
|
||||
// positioning, 2=fixed lanes.
|
||||
constexpr int kFreeModeFixedLanes = 2;
|
||||
|
||||
// C_LANESCOLLAPSED display value (char*). SDK: 1=lanes collapsed,
|
||||
// 2=track displays as non-fixed-lanes but hidden lanes exist. Value 2 is the lever that
|
||||
// makes a tool-split track read like a NORMAL single-lane track showing only the playing
|
||||
// lane — the inactive/silenced managed lanes are present but not drawn as separate rows.
|
||||
constexpr int kLanesDisplayAsNormal = 2;
|
||||
|
||||
// C_LANESETTINGS bit (char* bitmask). SDK: &32=hide lane buttons. We OR this in (never
|
||||
// clobber the whole mask) to strip the per-lane button chrome from a tool-split track, so
|
||||
// it reads as an ordinary track. We deliberately do NOT set &1 (auto-remove empty lanes at
|
||||
// bottom): a managed lane whose item is later deleted would be silently removed out from
|
||||
// under the ownership index. The lazy-mint decision already avoids ever minting an empty
|
||||
// lane, so &1 buys nothing and risks a reconcile hazard.
|
||||
constexpr int kLaneSettingsHideButtons = 32;
|
||||
|
||||
// Drives a TOOL-SPLIT track's display transparent: C_LANESCOLLAPSED=2 (render like a normal
|
||||
// single-lane track showing only the playing lane) + OR C_LANESETTINGS &32 (hide lane
|
||||
// buttons). Both are char* params driven through the double API, same convention as
|
||||
// C_LANEPLAYS:N. C_LANESETTINGS is read-modify-write so any pre-existing bit is preserved.
|
||||
//
|
||||
// MANAGED-VS-MANUAL BOUNDARY (load-bearing): these are TRACK-LEVEL settings that affect the
|
||||
// whole track including a user's own manual comp lanes. Every caller gates this on the
|
||||
// tool-driven transition INTO fixed lanes (freeMode != 2 before the flip), so a track the
|
||||
// user already had in fixed-lane mode never reaches it and the user's comp-lane display
|
||||
// prefs are never stomped. Idempotent: a re-run finds the track already at I_FREEMODE==2,
|
||||
// the transition branch is skipped, and these writes do not fire again.
|
||||
void applyTransparentLaneDisplay(MediaTrack* tr) {
|
||||
SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED",
|
||||
static_cast<double>(kLanesDisplayAsNormal));
|
||||
const int settings = static_cast<int>(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS"));
|
||||
SetMediaTrackInfo_Value(tr, "C_LANESETTINGS",
|
||||
static_cast<double>(settings | kLaneSettingsHideButtons));
|
||||
}
|
||||
|
||||
// The parmname for each planner Flag. All four are documented bool*/int* track
|
||||
// info params driven through the double-valued Get/SetMediaTrackInfo_Value API.
|
||||
const char* flagParm(Flag f) {
|
||||
switch (f) {
|
||||
case Flag::ShowInTcp: return "B_SHOWINTCP";
|
||||
case Flag::ShowInMixer: return "B_SHOWINMIXER";
|
||||
case Flag::MainSend: return "B_MAINSEND";
|
||||
case Flag::FxEnable: return "I_FXEN";
|
||||
}
|
||||
return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet
|
||||
}
|
||||
|
||||
// Reads the arrange-ordered track list and their I_FOLDERDEPTH, keyed by GUID.
|
||||
// The master track is NOT enumerated by GetTrack (index space is the non-master
|
||||
// tracks), so it can never enter the tree — the master-untouched invariant holds
|
||||
// by construction. Also caches the MediaTrack* per GUID so later apply steps
|
||||
// resolve a GUID back to its handle without a second linear scan.
|
||||
std::vector<TrackFolderEntry> readFolderEntries(
|
||||
ReaProject* proj,
|
||||
std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||
std::vector<TrackFolderEntry> entries;
|
||||
int count = CountTracks(proj);
|
||||
entries.reserve(static_cast<std::size_t>(count));
|
||||
handleByGuid.reserve(static_cast<std::size_t>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
MediaTrack* tr = GetTrack(proj, i);
|
||||
if (!tr) continue;
|
||||
std::string guid = guidString(tr);
|
||||
if (guid.empty()) continue;
|
||||
int depth = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"));
|
||||
entries.push_back(TrackFolderEntry{guid, depth});
|
||||
handleByGuid.emplace_back(guid, tr);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
|
||||
const std::string& guid) {
|
||||
for (const auto& kv : handleByGuid) {
|
||||
if (kv.first == guid) return kv.second;
|
||||
}
|
||||
return nullptr; // stale/deleted GUID — pruned by being skipped
|
||||
}
|
||||
|
||||
// Captures a track's prior driven-flag state BEFORE it is parked. Reads only the
|
||||
// four owned flags + per-FX offline; never B_MUTE/I_SOLO, never the master (not
|
||||
// reachable here). ints preserve whatever REAPER reported (defensive per D1's
|
||||
// TrackSnapshot contract).
|
||||
TrackSnapshot snapshotTrack(MediaTrack* tr) {
|
||||
TrackSnapshot snap;
|
||||
snap.showInTcp = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP"));
|
||||
snap.showInMixer = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINMIXER"));
|
||||
snap.mainSend = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
|
||||
snap.fxEnable = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));
|
||||
|
||||
int fxCount = TrackFX_GetCount(tr);
|
||||
snap.fxOffline.reserve(static_cast<std::size_t>(fxCount));
|
||||
for (int fx = 0; fx < fxCount; ++fx) {
|
||||
snap.fxOffline.push_back(TrackFX_GetOffline(tr, fx) ? 1 : 0);
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
// Applies the planner's scalar-flag writes. B_* are bool* params, I_FXEN is int*,
|
||||
// all driven through the double API — marshal the plan's int value to double.
|
||||
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags) {
|
||||
for (const TrackFlagOp& op : flags) {
|
||||
SetMediaTrackInfo_Value(tr, flagParm(op.flag), static_cast<double>(op.value));
|
||||
}
|
||||
}
|
||||
|
||||
// Parks a track's FX offline: the pure park plan leaves fxOffline empty by design;
|
||||
// the shell expands it from the live FX count and offlines every slot.
|
||||
void parkFxOffline(MediaTrack* tr) {
|
||||
int fxCount = TrackFX_GetCount(tr);
|
||||
for (int fx = 0; fx < fxCount; ++fx) {
|
||||
TrackFX_SetOffline(tr, fx, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Restores per-FX offline from the snapshot verbatim — each slot back to its
|
||||
// captured value, never a blanket "online". Bounds-checked against the live FX
|
||||
// count in case the plugin chain changed while parked (prune-safe).
|
||||
//
|
||||
// HAZARD (deferred, PLAN "reconcile on delete/restructure"): the remap is by
|
||||
// slot INDEX, not plugin identity. If the FX chain changed while the track was
|
||||
// parked, snapshot slot k is restored onto whatever plugin now occupies slot k —
|
||||
// the bounds-check guards against out-of-range, not against a reshuffled chain.
|
||||
// Acceptable for D2; full identity-based reconciliation is future hardening.
|
||||
void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
|
||||
int fxCount = TrackFX_GetCount(tr);
|
||||
for (const FxOfflineOp& op : fxOffline) {
|
||||
if (op.fxIndex < 0 || op.fxIndex >= fxCount) continue;
|
||||
TrackFX_SetOffline(tr, op.fxIndex, op.offline);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Managed-lane application (D2 Wave 2) ------------------------------------
|
||||
//
|
||||
// The pure planner emits LanePlayOps keyed by (trackGuid, laneKey) where laneKey is
|
||||
// the lane's DURABLE name (lane_keys convention: "reasampler:<mode>"). REAPER's
|
||||
// C_LANEPLAYS:N is keyed by the lane's CURRENT ORDINAL, which renumbers on reorder.
|
||||
// So before applying, we build the ordinal<->key reconcile for a track by reading each
|
||||
// lane's P_LANENAME:n; the write then targets the correct current ordinal for a given
|
||||
// durable key even after a reorder (design point #2). A lane whose name lacks the
|
||||
// managed prefix is manual and never appears in this map, so it can never be driven.
|
||||
|
||||
// Reads lane index `laneIdx`'s durable name off track `tr` (P_LANENAME:n). Empty if
|
||||
// the lane is unnamed or the param is unavailable (non-fixed-lane track).
|
||||
std::string laneName(MediaTrack* tr, int laneIdx) {
|
||||
char parm[32];
|
||||
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
|
||||
char buf[512] = {0};
|
||||
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Maps each MANAGED lane's durable key -> its current ordinal on `tr`, by walking the
|
||||
// track's I_NUMFIXEDLANES lanes and reading each name. Manual (unprefixed/unnamed)
|
||||
// lanes are omitted, so a key absent from the map is a lane the tool must not drive.
|
||||
std::map<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
|
||||
std::map<std::string, int> byKey;
|
||||
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
|
||||
for (int lane = 0; lane < numLanes; ++lane) {
|
||||
std::optional<std::string> key = managedLaneKey(laneName(tr, lane));
|
||||
if (key) byKey.emplace(*key, lane); // first ordinal wins if names collide
|
||||
}
|
||||
return byKey;
|
||||
}
|
||||
|
||||
// Drives one managed lane on `tr` to `lanePlays` (C_LANEPLAYS value) via the
|
||||
// TRACK-SIDE C_LANEPLAYS:N write. Track-side C_LANEPLAYS:N alone produces the
|
||||
// hide+silence effect for all items on lane N — no per-item write is needed or
|
||||
// possible (item-side C_LANEPLAYS is marked read-only in the SDK).
|
||||
// B_FIXEDLANE_HIDDEN is READ-ONLY (SDK) — hide/show follows from C_LANEPLAYS=0/1,
|
||||
// never written directly. Non-destructive: only reversible play/show flags; no item
|
||||
// is moved or deleted.
|
||||
//
|
||||
// DAW-VERIFY: confirm that track-side C_LANEPLAYS:N alone hides+silences all items
|
||||
// on lane N without a per-item write. (SDK marks item-side C_LANEPLAYS as read-only;
|
||||
// the track-side write is the documented mechanism.)
|
||||
void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) {
|
||||
char parm[32];
|
||||
std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx);
|
||||
SetMediaTrackInfo_Value(tr, parm, static_cast<double>(lanePlays));
|
||||
}
|
||||
|
||||
// Applies the plan's managed-lane ops. Groups ops by track, resolves each op's durable
|
||||
// laneKey to the track's current ordinal (skipping any key not present on the live
|
||||
// track — a stale/renamed/deleted managed lane is pruned, never mis-driven), enables
|
||||
// fixed-lane mode on any track that carries a managed lane, and drives C_LANEPLAYS.
|
||||
// UpdateTimeline() is called ONCE at the end (SDK: required after I_FREEMODE changes).
|
||||
// Returns true if any track's I_FREEMODE was (re)set to fixed lanes (⇒ needs timeline
|
||||
// refresh). MANAGED lanes only — plan.lanes never contains a manual lane (pure planner
|
||||
// gates on the ownership index), and a manual lane's name never resolves to a key here,
|
||||
// so the invariant is enforced twice.
|
||||
bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
|
||||
const std::vector<LanePlayOp>& lanes) {
|
||||
if (lanes.empty()) return false;
|
||||
|
||||
// Group op indices by track guid so we read each track's lane map once.
|
||||
std::map<std::string, std::vector<const LanePlayOp*>> byTrack;
|
||||
for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op);
|
||||
|
||||
bool touchedFreeMode = false;
|
||||
for (const auto& [guid, ops] : byTrack) {
|
||||
MediaTrack* tr = resolve(handleByGuid, guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
|
||||
// Ensure fixed-lane mode is on before driving lane play state. A track carrying
|
||||
// a managed lane must be in I_FREEMODE=2; set it only if not already, and flag
|
||||
// that a timeline refresh is owed. Every track reaching this loop is already in the
|
||||
// managed-lane ownership index (planToggle only emits ops for managed lanes), so a
|
||||
// track here is one the TOOL split — a re-assert of fixed-lane mode is a tool-driven
|
||||
// (re)split and must carry the same transparent display, mirroring applyMintPlan's
|
||||
// transition branch. It is never a user's untouched manual-fixed-lane track.
|
||||
const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
|
||||
if (freeMode != kFreeModeFixedLanes) {
|
||||
SetMediaTrackInfo_Value(tr, "I_FREEMODE",
|
||||
static_cast<double>(kFreeModeFixedLanes));
|
||||
applyTransparentLaneDisplay(tr); // tool-managed track ⇒ read like a normal track
|
||||
touchedFreeMode = true;
|
||||
}
|
||||
|
||||
// Reconcile durable keys -> current ordinals on THIS track, then drive each op.
|
||||
const std::map<std::string, int> ordinals = managedLaneOrdinals(tr);
|
||||
for (const LanePlayOp* op : ops) {
|
||||
auto it = ordinals.find(op->laneKey);
|
||||
if (it == ordinals.end()) continue; // key not live on this track — prune
|
||||
applyLanePlays(tr, it->second, op->lanePlays);
|
||||
}
|
||||
}
|
||||
return touchedFreeMode;
|
||||
}
|
||||
|
||||
// -- Managed-lane minting (D2 Wave 3) ----------------------------------------
|
||||
//
|
||||
// Mints one managed fixed lane per mode on any track that now holds content of MORE
|
||||
// THAN ONE mode, and assigns each item to its mode's managed lane. The DECISION —
|
||||
// which tracks split, which lanes to mint, which item goes where — is the pure
|
||||
// planLaneMinting; this shell only reads live per-item mode+lane state, calls the
|
||||
// decision, and applies the resulting REAPER + ownership-index writes.
|
||||
|
||||
// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h):
|
||||
// itemGuid(it) and itemLaneName(tr, it). view.cpp no longer carries its own copies.
|
||||
|
||||
// Maps every item GUID on `tr` to its MediaItem* handle, in one pass. The assign pass
|
||||
// resolves plan item GUIDs back to handles through this map rather than re-scanning the
|
||||
// track per item (avoids the quadratic that a per-item find would incur).
|
||||
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
|
||||
std::map<std::string, MediaItem*> byGuid;
|
||||
const int itemCount = CountTrackMediaItems(tr);
|
||||
for (int i = 0; i < itemCount; ++i) {
|
||||
MediaItem* it = GetTrackMediaItem(tr, i);
|
||||
if (!it) continue;
|
||||
std::string ig = itemGuid(it);
|
||||
if (!ig.empty()) byGuid.emplace(std::move(ig), it);
|
||||
}
|
||||
return byGuid;
|
||||
}
|
||||
|
||||
// Resolves the mode one item's content belongs to, from the model's membership index.
|
||||
// An item tagged into exactly one mode returns that mode; an untagged item is an
|
||||
// Arrange member by default (mirrors leafBelongsToMode's untagged rule). A show-both or
|
||||
// multi-mode item resolves to its first mode id — such items are unusual for lane
|
||||
// content, and the pure decision only needs A mode per item; the managed-lane it lands
|
||||
// on is that mode's lane. Never returns empty for a real item.
|
||||
std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) {
|
||||
const std::set<std::string> modes = model.membership().modesOf(itemGuid);
|
||||
if (modes.empty()) return kArrangeModeId; // untagged ⇒ Arrange default
|
||||
return *modes.begin();
|
||||
}
|
||||
|
||||
// Builds the per-track LaneItem picture the pure decision consumes. For each track and
|
||||
// each item: resolve the item's mode from membership, and — only on a track already in
|
||||
// fixed-lane mode — read whether it sits on a MANUAL lane (exempt). On a non-fixed-lane
|
||||
// track no item is on a manual lane (isOnManualLane returns false for the empty name),
|
||||
// so the manual read is skipped entirely there.
|
||||
std::vector<LaneTrack> readLaneTracks(
|
||||
const ViewModeModel& model,
|
||||
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||
std::vector<LaneTrack> tracks;
|
||||
tracks.reserve(handleByGuid.size());
|
||||
for (const auto& [guid, tr] : handleByGuid) {
|
||||
LaneTrack lt;
|
||||
lt.trackGuid = guid;
|
||||
|
||||
const bool fixedLane =
|
||||
static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
|
||||
|
||||
const int itemCount = CountTrackMediaItems(tr);
|
||||
lt.items.reserve(static_cast<std::size_t>(itemCount));
|
||||
for (int i = 0; i < itemCount; ++i) {
|
||||
MediaItem* it = GetTrackMediaItem(tr, i);
|
||||
if (!it) continue;
|
||||
const std::string ig = itemGuid(it);
|
||||
if (ig.empty()) continue;
|
||||
LaneItem li;
|
||||
li.guid = ig;
|
||||
li.modeId = itemModeFromMembership(model, ig);
|
||||
// Manual-lane exemption: only meaningful on a fixed-lane track. The shared
|
||||
// pure predicate decides; on a normal track it returns false regardless of
|
||||
// name, so we pass an empty name and skip the P_LANENAME read.
|
||||
const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
|
||||
li.onManualLane = isOnManualLane(fixedLane, ln);
|
||||
lt.items.push_back(std::move(li));
|
||||
}
|
||||
tracks.push_back(std::move(lt));
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
// Assigns item `it` to the managed lane whose durable key resolves to a current ordinal
|
||||
// on `tr` (via managedLaneOrdinals). Idempotent: writes I_FIXEDLANE only when it differs
|
||||
// from the item's current lane, so a re-run does not thrash the item or the undo state.
|
||||
// Returns true iff a write actually changed the item's lane. Non-destructive: only the
|
||||
// reversible I_FIXEDLANE flag is written — the item is never moved in time or across
|
||||
// tracks. (I_FIXEDLANE is settable per SDK: "fine to call with setNewValue".)
|
||||
bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) {
|
||||
const int current = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
|
||||
if (current == laneOrdinal) return false; // already there — no-op
|
||||
SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast<double>(laneOrdinal));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Applies the pure LaneMintPlan to the live project. For each track that must split:
|
||||
// enables fixed lanes, ensures the lane count, stamps each managed lane's durable name,
|
||||
// records ownership in the model, then assigns each item to its mode's lane by resolving
|
||||
// the durable key to the lane's current ordinal. Returns true if ANY project write
|
||||
// changed state (⇒ the caller keeps the Undo block and refreshes the timeline).
|
||||
//
|
||||
// MANAGED-LANES-ONLY: the plan only ever names lanes with the managed prefix and only
|
||||
// ever assigns managed-eligible items (manual-lane items were reported exempt and are
|
||||
// absent from the plan). We only ever GROW I_NUMFIXEDLANES to fit the managed lanes and
|
||||
// stamp names on the lanes we mint — a user's existing manual lanes keep their ordinals
|
||||
// below/around ours and are never renamed or reassigned.
|
||||
bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
|
||||
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||
bool changed = false;
|
||||
|
||||
// Group mints + assigns by track so each track is set up once.
|
||||
std::map<std::string, std::vector<const LaneMint*>> mintsByTrack;
|
||||
for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m);
|
||||
std::map<std::string, std::vector<const LaneAssign*>> assignsByTrack;
|
||||
for (const LaneAssign& a : plan.assigns) assignsByTrack[a.trackGuid].push_back(&a);
|
||||
|
||||
for (const LaneMintPlan::TrackSplit& split : plan.splits) {
|
||||
MediaTrack* tr = resolve(handleByGuid, split.trackGuid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
|
||||
// Enable fixed-lane mode if not already (SDK: UpdateTimeline() owed after). The
|
||||
// pre-write freeMode read is ALSO the managed-vs-manual boundary signal: a track that
|
||||
// was NOT in fixed-lane mode here is one the TOOL is splitting now, so the tool owns
|
||||
// its lane display and drives it transparent. A track already at I_FREEMODE==2 (user
|
||||
// had fixed lanes, or a prior tool run) skips this branch — its C_LANESCOLLAPSED /
|
||||
// C_LANESETTINGS are left exactly as the user set them.
|
||||
const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
|
||||
if (freeMode != kFreeModeFixedLanes) {
|
||||
SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast<double>(kFreeModeFixedLanes));
|
||||
applyTransparentLaneDisplay(tr); // tool-split track ⇒ read like a normal track
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Ensure enough lanes for the managed set WITHOUT shrinking: a track may already
|
||||
// carry the user's manual lanes, so only GROW the count, never reduce it (which
|
||||
// would delete a user lane). The managed lanes we mint occupy the tail ordinals.
|
||||
// laneCount tracks the live I_NUMFIXEDLANES as we grow it: read ONCE here, then
|
||||
// each mint appends at laneCount and bumps it. No per-mint I_NUMFIXEDLANES re-read
|
||||
// is needed — nextOrdinal and laneCount are the same running value.
|
||||
int laneCount = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
|
||||
|
||||
// Which managed keys are already present on this track (durable-name reconcile).
|
||||
std::map<std::string, int> present = managedLaneOrdinals(tr);
|
||||
|
||||
// Mint each managed lane that is not already present, appending at the tail so an
|
||||
// existing manual lane is never overwritten. Record ownership in the model.
|
||||
for (const LaneMint* m : mintsByTrack[split.trackGuid]) {
|
||||
model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId); // ownership
|
||||
if (present.count(m->laneKey)) continue; // already minted — idempotent
|
||||
|
||||
// Append at the current tail ordinal, grow the tracked count, stamp its name.
|
||||
const int laneIdx = laneCount++;
|
||||
SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast<double>(laneCount));
|
||||
char parm[32];
|
||||
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
|
||||
std::vector<char> name(m->laneKey.begin(), m->laneKey.end());
|
||||
name.push_back('\0');
|
||||
GetSetMediaTrackInfo_String(tr, parm, name.data(), true);
|
||||
present.emplace(m->laneKey, laneIdx); // now resolvable for the assign pass
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Assign each item to its mode's managed lane, resolving the durable key to the
|
||||
// lane's current ordinal on THIS track. A key not present (shouldn't happen — we
|
||||
// just minted them all) is skipped rather than mis-assigned. Item handles are
|
||||
// resolved through a one-pass GUID map (avoids re-scanning the track per item).
|
||||
const std::map<std::string, int> ordinals = managedLaneOrdinals(tr);
|
||||
const std::map<std::string, MediaItem*> itemsByGuid = itemHandlesByGuid(tr);
|
||||
for (const LaneAssign* a : assignsByTrack[split.trackGuid]) {
|
||||
auto ord = ordinals.find(a->laneKey);
|
||||
if (ord == ordinals.end()) continue; // key not live — prune, never mis-assign
|
||||
auto handle = itemsByGuid.find(a->itemGuid);
|
||||
if (handle == itemsByGuid.end()) continue; // stale item GUID — prune
|
||||
if (assignItemToLane(tr, handle->second, ord->second)) changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
|
||||
// Reject an unregistered target before touching the project (no partial apply).
|
||||
if (!model.modes().contains(targetModeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||
FolderTree tree = buildFolderTree(entries);
|
||||
|
||||
// Reconcile orphaned model state BEFORE planning: prune snapshots whose track was
|
||||
// deleted from the project (its GUID no longer appears in the live enumeration).
|
||||
// handleByGuid holds every currently-enumerated track GUID, so its keys are the
|
||||
// authoritative live set. Membership is intentionally NOT pruned (undo-delete
|
||||
// restores the same GUID — see ViewModeModel::reconcile). Because reapply-on-load
|
||||
// routes through applyMode, this also reconciles on project open.
|
||||
std::set<std::string> liveGuids;
|
||||
for (const auto& kv : handleByGuid) liveGuids.insert(kv.first);
|
||||
model.reconcile(liveGuids);
|
||||
|
||||
TogglePlan plan = model.planToggle(tree, targetModeId);
|
||||
|
||||
Undo_BeginBlock2(proj);
|
||||
|
||||
// PARK: snapshot BEFORE mutating, store into the model (so restore survives a
|
||||
// save-while-parked), then apply the park writes + expand the FX-offline loop.
|
||||
for (const TrackPlan& tp : plan.park) {
|
||||
// Every op in a TrackPlan targets the same track; take the guid from the
|
||||
// first flag op (the pure park plan always emits the four flag ops).
|
||||
if (tp.flags.empty()) continue;
|
||||
const std::string& guid = tp.flags.front().guid;
|
||||
MediaTrack* tr = resolve(handleByGuid, guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
|
||||
// Snapshot ONCE, at the first park. If a snapshot already exists the track is
|
||||
// still parked from a prior apply, and its live flags are the PARKED (hidden)
|
||||
// values — recapturing here would overwrite the true pre-park state with zeros,
|
||||
// so a later restore would restore the track to hidden and it would vanish for
|
||||
// good. Re-applying the park flags to an already-parked track is idempotent and
|
||||
// fine; only the snapshot must not be recaptured. Restore clears the snapshot,
|
||||
// so the next genuine park recaptures fresh state.
|
||||
if (model.snapshot(guid) == nullptr)
|
||||
model.storeSnapshot(guid, snapshotTrack(tr));
|
||||
applyFlags(tr, tp.flags);
|
||||
parkFxOffline(tr);
|
||||
}
|
||||
|
||||
// RESTORE: apply the snapshot-sourced flag + per-FX offline writes verbatim,
|
||||
// then drop the now-consumed snapshot so a re-park recaptures fresh state.
|
||||
for (const TrackPlan& tp : plan.restore) {
|
||||
if (tp.flags.empty()) continue;
|
||||
const std::string& guid = tp.flags.front().guid;
|
||||
MediaTrack* tr = resolve(handleByGuid, guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
|
||||
applyFlags(tr, tp.flags);
|
||||
restoreFxOffline(tr, tp.fxOffline);
|
||||
model.clearSnapshot(guid);
|
||||
}
|
||||
|
||||
// MANAGED LANES (D2 item-level projection): drive C_LANEPLAYS so the active mode's
|
||||
// managed lane plays+shows and every inactive-mode managed lane is silenced+hidden.
|
||||
// plan.lanes carries MANAGED lanes only (the pure planner gates on the ownership
|
||||
// index); applyLaneOps additionally resolves each op's durable key against the live
|
||||
// track's lane names, so a manual lane — which never carries the managed prefix —
|
||||
// can never be driven. Empty for a D1-only project (no fixed lanes), leaving D1
|
||||
// behavior byte-identical. UpdateTimeline() is owed only if a track's I_FREEMODE
|
||||
// was (re)set to fixed lanes (SDK requirement); deferred to the refresh block below.
|
||||
const bool laneModeChanged = applyLaneOps(handleByGuid, plan.lanes);
|
||||
|
||||
// PARENT VISIBILITY (never parked): visibleTracks() marks a parent visible when
|
||||
// a descendant leaf is visible in the target mode OR the parent belongs to the
|
||||
// mode by its own membership (untagged folder → Arrange default). Recomputed
|
||||
// every toggle rather than snapshotted. Drive only the two visibility flags;
|
||||
// never touch B_MAINSEND/I_FXEN/FX-offline on a parent.
|
||||
std::set<std::string> visible = model.visibleTracks(tree, targetModeId);
|
||||
for (const FolderNode& node : tree.nodes) {
|
||||
if (!node.isParent) continue;
|
||||
MediaTrack* tr = resolve(handleByGuid, node.guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
double show = visible.count(node.guid) ? 1.0 : 0.0;
|
||||
SetMediaTrackInfo_Value(tr, "B_SHOWINTCP", show);
|
||||
SetMediaTrackInfo_Value(tr, "B_SHOWINMIXER", show);
|
||||
}
|
||||
|
||||
// Build the undo label from the ACTUAL target mode's display name, so activating
|
||||
// Arrange doesn't leave an "activate Design view" undo point (and vice versa).
|
||||
// The target is guaranteed registered (checked at entry), so query() is non-null;
|
||||
// fall back to the id defensively if that ever changes.
|
||||
const Mode* targetMode = model.modes().query(targetModeId);
|
||||
const std::string undoLabel =
|
||||
"ReaSampler: activate " +
|
||||
(targetMode ? targetMode->displayName : targetModeId) + " view";
|
||||
|
||||
model.setActiveMode(targetModeId);
|
||||
|
||||
// Force REAPER to rebuild the TCP + MCP so visibility/park changes appear now,
|
||||
// not on the user's next TCP interaction. TrackList_AdjustWindows(false) does the
|
||||
// major (full) relayout required when tracks appear/disappear from the panels;
|
||||
// UpdateArrange() repaints the arrange view. Both are documented for exactly this
|
||||
// "you changed track-info flags, now refresh the panels" case.
|
||||
TrackList_AdjustWindows(false);
|
||||
UpdateArrange();
|
||||
|
||||
// A fixed-lane mode change (I_FREEMODE -> 2) requires UpdateTimeline() to take
|
||||
// visible effect (SDK). Call it only when we actually toggled a track into fixed
|
||||
// lanes this apply; the C_LANEPLAYS writes themselves are picked up by the arrange
|
||||
// refresh above.
|
||||
if (laneModeChanged) UpdateTimeline();
|
||||
|
||||
Undo_EndBlock2(proj, undoLabel.c_str(), -1);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
|
||||
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||
// The minting decision is now folder-tree / visibility aware: it needs the tree to
|
||||
// detect a content-bearing folder derived-visible in >1 mode (which must lane-separate
|
||||
// its own media even when that media is single-mode). Build it exactly as applyMode does.
|
||||
const FolderTree tree = buildFolderTree(entries);
|
||||
|
||||
// Build the live per-track item picture and run the PURE decision. A track visible in
|
||||
// exactly one mode produces no split; a track visible in >1 mode while carrying its own
|
||||
// media (own items span modes, OR a folder derived-visible across modes) produces mints
|
||||
// + assignments. Manual-lane items are reported exempt inside readLaneTracks; show-both
|
||||
// tracks are skipped inside the decision.
|
||||
const std::vector<LaneTrack> tracks = readLaneTracks(model, handleByGuid);
|
||||
const LaneMintPlan plan = planLaneMinting(model, tree, tracks);
|
||||
if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick
|
||||
|
||||
// Wrap the structural mutation in ONE Undo block (unlike the invisible membership
|
||||
// tag). Only opened when the plan is non-empty; applyMintPlan reports whether any
|
||||
// write actually changed state so we can label the undo meaningfully.
|
||||
Undo_BeginBlock2(proj);
|
||||
const bool changed = applyMintPlan(model, plan, handleByGuid);
|
||||
|
||||
if (!changed) {
|
||||
// The plan was non-empty but every REAPER write was already satisfied. Close the
|
||||
// block with no description so REAPER discards the empty undo point rather than
|
||||
// flooding history with a no-change entry every detection tick.
|
||||
Undo_EndBlock2(proj, "", 0);
|
||||
|
||||
// BUT the arrange still needs a redraw. On the detect-tick caller (bankPanelRefresh)
|
||||
// mintManagedLanes runs only when this tick just tagged new content, and a NON-EMPTY
|
||||
// plan means that content sits on a managed-split track. The idempotent no-op path is
|
||||
// reached when a freshly-inserted item ALREADY landed on the active mode's playing
|
||||
// lane (REAPER places a new item on the playing lane; the active mode's lane IS the
|
||||
// playing lane, so assignItemToLane sees I_FIXEDLANE unchanged and writes nothing).
|
||||
// The item is correctly placed and confined, but the arrange was never told to
|
||||
// repaint it onto the lane — so it stayed invisible until a manual mode toggle forced
|
||||
// applyMode's refresh. Force the redraw here so the item appears immediately without a
|
||||
// toggle. UpdateArrange() only repaints (no I_FREEMODE transition happened on this
|
||||
// path, so UpdateTimeline is not owed); it is NOT a project mutation, so it stays
|
||||
// outside the undo block and adds no history entry. On the action caller (doMoveItems)
|
||||
// this is a harmless repaint immediately before its own reapplyActiveMode() refresh.
|
||||
UpdateArrange();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reapply the active mode's lane visibility so the freshly-minted lanes take their
|
||||
// correct play/show state immediately: the active mode's lane plays+shows, every
|
||||
// other managed lane hides+silences. Reusing planToggle's lane ops keeps the drive
|
||||
// logic in one place; applyLaneOps also (re)asserts I_FREEMODE and drives C_LANEPLAYS.
|
||||
// NOTE: applyMode is NOT reused here — it would re-park/restore whole tracks and
|
||||
// recompute parent visibility, which the minting tick must not do (it only just
|
||||
// changed item lanes). Driving lane play state directly is the minimal correct step.
|
||||
const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId());
|
||||
applyLaneOps(handleByGuid, togglePlan.lanes);
|
||||
|
||||
// I_FREEMODE was (re)set to fixed lanes on at least one track (the plan minted a
|
||||
// split), so a timeline refresh is owed (SDK). Repaint the arrange too so the new
|
||||
// lane layout appears immediately.
|
||||
UpdateTimeline();
|
||||
UpdateArrange();
|
||||
|
||||
Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", -1);
|
||||
return true;
|
||||
}
|
||||
|
||||
void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) {
|
||||
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||
readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here)
|
||||
|
||||
// Walk every track's lanes; for each lane whose durable name carries the managed
|
||||
// prefix, record it MANAGED-for-its-mode in the ownership index. This is a pure READ
|
||||
// of REAPER state (no lane is created, no I_FREEMODE/I_NUMFIXEDLANES/I_FIXEDLANE is
|
||||
// written) plus an index write — self-healing classification from the source of
|
||||
// truth (the durable name) without re-minting or mass-tagging. A lane lacking the
|
||||
// prefix is left alone (manual by default), so a user's own lanes stay off the index.
|
||||
for (const auto& [guid, tr] : handleByGuid) {
|
||||
const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
|
||||
if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed
|
||||
|
||||
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
|
||||
for (int lane = 0; lane < numLanes; ++lane) {
|
||||
const std::string name = laneName(tr, lane);
|
||||
std::optional<std::string> key = managedLaneKey(name);
|
||||
if (!key) continue; // manual/unnamed lane — leave off the index
|
||||
std::optional<std::string> mode = modeIdFromLaneName(name);
|
||||
if (!mode) continue; // prefix-only/illegal name — skip defensively
|
||||
|
||||
// UNREGISTERED-MODE GUARD: the durable name encodes a mode id, but that mode
|
||||
// may no longer be a registered Mode (e.g. a mode removed from the registry
|
||||
// after the project was saved with lanes minted for it). Recording it MANAGED
|
||||
// would make the toggle planner drive a lane keyed to a mode that can never be
|
||||
// the active mode — the lane would stay silenced+hidden forever, orphaning its
|
||||
// items with no way for the user to reach them. So we do NOT record it: the
|
||||
// lane is left off the ownership index and thus treated as manual-by-default
|
||||
// (never driven). Its durable name is preserved on the track, so if the mode is
|
||||
// ever re-registered a later reconcile recovers the ownership cleanly.
|
||||
if (!model.modes().contains(*mode)) continue;
|
||||
model.lanes().setManaged(guid, *key, *mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,96 @@
|
||||
#include "core/namespaces.h"
|
||||
#pragma once
|
||||
// view — the REAPER-facing shell of the Design View feature (Phase D2). It is the
|
||||
// mirror of the capture shell: the ViewModeModel (pure, D1) holds the mode/
|
||||
// membership/snapshot state and emits the toggle plan; this shell reads the live
|
||||
// project's folder tree, snapshots the tracks it is about to park, runs the model's
|
||||
// planner, and applies the resulting flag + per-FX-offline writes to REAPER.
|
||||
//
|
||||
// It includes view_mode_model (pure) but NO REAPER headers — the .cpp is the one
|
||||
// REAPER-facing translation unit (CLAUDE.md §contract: only main.cpp defines the
|
||||
// API pointers; every other .cpp gets them extern). Callers (persist, actions)
|
||||
// depend on this seam without dragging the SDK into their include sites.
|
||||
//
|
||||
// Hard invariants this shell enforces (CONTEXT.md §Design View, precision
|
||||
// invariants) — verified in self-review, never crossed:
|
||||
// * Never touches the master track's visibility (SDK forbids B_SHOWINTCP/
|
||||
// B_SHOWINMIXER on master); the master is never a node in the tree.
|
||||
// * Never reads or writes B_MUTE / I_SOLO on any track.
|
||||
// * Manages ALL leaves via the mode system: an untagged leaf is an Arrange member,
|
||||
// so it is fully parked in non-Arrange modes and restored in Arrange, identically
|
||||
// to a tagged leaf. show-both is the always-visible escape; parents are
|
||||
// visibility-only (derived); the master is never touched.
|
||||
// * Snapshots every to-be-parked track's prior flags BEFORE parking, storing
|
||||
// them into the model so restore is faithful and survives a save-while-parked.
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "core/view/view_mode_model.h"
|
||||
|
||||
// REAPER's opaque project handle. Forward-declared to keep this header SDK-free;
|
||||
// the .cpp includes reaper_plugin_functions.h and sees the real class.
|
||||
class ReaProject;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Applies `targetModeId` to the live project `proj`:
|
||||
// 1. Reads the arrange-ordered track list, builds the FolderTree from
|
||||
// I_FOLDERDEPTH (via the pure buildFolderTree helper).
|
||||
// 2. Runs model.planToggle(tree, targetModeId).
|
||||
// 3. For each track about to be PARKED: snapshots its current B_SHOWINTCP /
|
||||
// B_SHOWINMIXER / B_MAINSEND / I_FXEN and per-FX offline state, stores the
|
||||
// snapshot into the model, THEN applies the park writes (expanding the
|
||||
// per-FX offline loop from TrackFX_GetCount, which the pure plan leaves empty).
|
||||
// 4. For each track to RESTORE: applies the plan's snapshot-sourced flag + per-FX
|
||||
// offline writes verbatim.
|
||||
// 5. For each PARENT (folder) node: drives B_SHOWINTCP / B_SHOWINMIXER to 1 if the
|
||||
// parent is in model.visibleTracks(tree, targetModeId), else 0 — derived from
|
||||
// membership, never parked/snapshotted. Only the two visibility flags.
|
||||
// 6. Sets the model's active mode to `targetModeId`.
|
||||
// All track mutations are wrapped in Undo_BeginBlock2 / Undo_EndBlock2.
|
||||
//
|
||||
// Returns false (no mutation, active mode unchanged) if `targetModeId` is not a
|
||||
// registered mode. `proj` may be nullptr to mean REAPER's current project.
|
||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
|
||||
|
||||
// Mints managed fixed lanes for any track in `proj` that is VISIBLE IN MORE THAN ONE
|
||||
// MODE while carrying its own media, and assigns each item to its mode's managed lane
|
||||
// (Phase D2 Wave 3; visibility trigger added by the folder-media fix).
|
||||
// 1. Enumerates every track + its items; resolves each item's mode from the model's
|
||||
// membership (untagged ⇒ Arrange) and reads whether it currently sits on a MANUAL
|
||||
// lane (exempt). Builds the FolderTree (I_FOLDERDEPTH) so derived visibility counts.
|
||||
// 2. Runs the pure planLaneMinting decision (model + tree aware). A track visible in
|
||||
// exactly one mode is left whole-track-parked (D1) — NOT lane-split. A track visible
|
||||
// in >1 mode while carrying own media splits: its own items span modes, OR it is a
|
||||
// content-bearing folder derived-visible across modes. show-both tracks never split.
|
||||
// 3. For each track that must split: enables fixed-lane mode (I_FREEMODE=2), ensures
|
||||
// enough fixed lanes (I_NUMFIXEDLANES), stamps each managed lane's durable name
|
||||
// (P_LANENAME:n), records the lane MANAGED-for-its-mode in the model's ownership
|
||||
// index, and assigns each managed-eligible item to its mode's lane (I_FIXEDLANE).
|
||||
// Manual lanes and the items on them are NEVER minted-over or reassigned.
|
||||
// 4. Reapplies the active mode's lane visibility so the just-minted lanes take their
|
||||
// correct play/show state immediately (the active mode's lane plays; others hide).
|
||||
// The whole structural mutation is wrapped in ONE Undo_BeginBlock2/EndBlock2 — but only
|
||||
// when the plan is non-empty (no undo point for a tick that mints nothing).
|
||||
//
|
||||
// Returns true if any lane was minted this call (⇒ the caller may want a repaint).
|
||||
// `proj` may be nullptr to mean REAPER's current project. READ of the membership index
|
||||
// only; the sole model mutation is recording new managed-lane ownership.
|
||||
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj);
|
||||
|
||||
// Reconciles the model's lane-ownership index against the live project's lanes on
|
||||
// project open (Phase D2 Wave 3). REAPER's durable P_LANENAME is the source of truth for
|
||||
// lane identity across sessions (design point #2): a lane whose name carries the managed
|
||||
// prefix is tool-managed and owned by the mode encoded in that name. This walks every
|
||||
// track's lanes and records each managed-named lane MANAGED-for-its-mode in the index —
|
||||
// self-healing a saved project's classification WITHOUT re-minting (it never creates a
|
||||
// lane, changes I_FREEMODE/I_NUMFIXEDLANES, or reassigns an item) and WITHOUT mass-
|
||||
// tagging (it never touches membership). A lane without the managed prefix is left
|
||||
// untouched (manual by default). Reload's active-mode lane visibility is then reapplied
|
||||
// by the caller's applyMode, mirroring D1's reapply-on-open.
|
||||
//
|
||||
// `proj` may be nullptr to mean REAPER's current project. The only model mutation is
|
||||
// recording managed ownership recovered from durable lane names.
|
||||
void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj);
|
||||
|
||||
} // namespace reasampler
|
||||
Reference in New Issue
Block a user