Merge 12 comment-reduction tracks: cut source comment volume ~42% tree-wide
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
// action_registry.cpp — shared registration plumbing (Q-W4) + the registration
|
||||
// table (Q-W6). See action_registry.h. Needs no REAPER API pointers: rec->Register
|
||||
// is a member call on the dispatch struct REAPER hands the entry point.
|
||||
// action_registry.cpp — see action_registry.h. Needs no REAPER API pointers:
|
||||
// rec->Register is a member call on the dispatch struct REAPER hands the entry point.
|
||||
|
||||
#include "shell/actions/action_registry.h"
|
||||
|
||||
@@ -17,16 +16,12 @@ namespace {
|
||||
using version::channelActionName;
|
||||
using version::channelCommandId;
|
||||
|
||||
// Durable store of composed, channel-qualified strings (ids + labels). A std::deque
|
||||
// never invalidates references on push_back, so a c_str() handed to REAPER (a
|
||||
// command_id at register, a gaccel desc for its lifetime) stays valid until process
|
||||
// exit. Memoized by suffix so register and the mirror-unregister get the SAME id
|
||||
// pointer for a given action.
|
||||
// std::deque never invalidates references on push_back, so a c_str() handed to
|
||||
// REAPER stays valid until process exit. Memoized by suffix so register and
|
||||
// mirror-unregister get the SAME id pointer.
|
||||
std::deque<std::string> g_strStore;
|
||||
|
||||
// One registered table row: the row data plus the registry-owned registration
|
||||
// artifacts (interned id, minted cmd, gaccel storage REAPER holds a pointer to).
|
||||
// A std::deque so element addresses never move after push_back — REAPER keeps each
|
||||
// std::deque so element addresses never move after push_back — REAPER keeps each
|
||||
// &accel until the mirror-unregister.
|
||||
struct TableEntry {
|
||||
ActionTableRow row;
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
#pragma once
|
||||
// action_registry — shared registration plumbing + the Q-W6 registration TABLE.
|
||||
//
|
||||
// Two layers, one TU:
|
||||
//
|
||||
// * The Q-W4 plumbing (channelIdFor / registerAction): the durable interned-string
|
||||
// store the action families register through, so a composed command id keeps ONE
|
||||
// stable pointer from register to the mirror-unregister, and the
|
||||
// register-a-command_id-then-gaccel sequence has one implementation. The
|
||||
// design_view / bank / ingest families still register row-by-row through this.
|
||||
//
|
||||
// * The Q-W6 registration TABLE (ActionTableRow + registerActionTable /
|
||||
// actionTableHandleCommand / actionTableCommandId / unregisterActionTable): the
|
||||
// data-driven home of main.cpp's own action family (capture scopes, panel toggle,
|
||||
// insert, batch, realtime, recapture, version). One row = one action (FOREVER-
|
||||
// STABLE id suffix, display phrase, flat function-pointer handler); registration
|
||||
// iterates the rows, hookcommand dispatch walks the same rows, and unload
|
||||
// mirror-unregisters from them — adding an action touches the table only (OCP).
|
||||
// Handlers are plain function pointers (a static dispatch walk, no std::function,
|
||||
// no virtual — the §3 performance guardrail); gaccel + interned-id storage is
|
||||
// owned here for the module lifetime, so REAPER's held pointers stay valid and
|
||||
// the '-command_id' unregister re-presents the IDENTICAL pointer registered.
|
||||
//
|
||||
// Includes reaper_plugin.h (gaccel_register_t / reaper_plugin_info_t full defs);
|
||||
// only the action-family TUs and main.cpp include this header.
|
||||
// action_registry — shared REAPER registration plumbing, plus a data-driven action
|
||||
// table (ActionTableRow) so adding an action means adding one row, not touching
|
||||
// register/dispatch/unregister separately (OCP). Interned command-id/label strings
|
||||
// persist for the module lifetime: REAPER holds those pointers, and an unregister
|
||||
// must re-present the SAME one. Handlers are flat function pointers, never
|
||||
// std::function/virtual (hot-path-adjacent dispatch discipline).
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
@@ -30,27 +12,19 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Returns the channel-qualified command id for `suffix`, interning it once for the
|
||||
// process lifetime. Called by BOTH registerAction and each family's unregister path,
|
||||
// so a '-command_id' presents the IDENTICAL string pointer registered earlier.
|
||||
// Interns the channel-qualified command id for `suffix` once per process, so a
|
||||
// '-command_id' unregister presents the IDENTICAL pointer registered earlier.
|
||||
const char* channelIdFor(const char* suffix);
|
||||
|
||||
// Mints a command id from a channel-qualified SUFFIX and registers its gaccel
|
||||
// (Actions-list entry with a channel-qualified label PHRASE). Returns the command id
|
||||
// (0 on failure). Both the composed id and label are interned durably — REAPER holds
|
||||
// the desc pointer, and the id must survive to the mirror-unregister. The gaccel
|
||||
// storage itself is caller-owned (file-scope in the family TU).
|
||||
// Mints a command id from `suffix`, registers its gaccel with label `phrase`.
|
||||
// Returns the id (0 on failure); gaccel storage is caller-owned.
|
||||
int registerAction(reaper_plugin_info_t* rec, const char* suffix,
|
||||
gaccel_register_t& accel, const char* phrase);
|
||||
|
||||
// --- The registration table (Q-W6) -------------------------------------------
|
||||
// --- The registration table ---------------------------------------------------
|
||||
|
||||
// One bindable action. `suffix` and `phrase` are the channel-AGNOSTIC pieces (the
|
||||
// registry composes the full id/label via channelCommandId / channelActionName);
|
||||
// both must have static storage duration (string literals, or a pure static table
|
||||
// like captureActionTable()). `run` fires when the minted command does; `arg` is an
|
||||
// opaque per-row value passed through to it (e.g. a captureActionTable row index, or
|
||||
// a bool-like flag), so sibling actions can share one handler without captures.
|
||||
// `suffix`/`phrase` are channel-agnostic and must have static storage duration.
|
||||
// `arg` is an opaque per-row value so sibling actions can share one handler.
|
||||
struct ActionTableRow {
|
||||
const char* suffix; // FOREVER-STABLE command-id suffix — never change shipped
|
||||
const char* phrase; // Actions-list display phrase (after the channel prefix)
|
||||
@@ -58,26 +32,19 @@ struct ActionTableRow {
|
||||
int arg = 0; // opaque per-row handler argument
|
||||
};
|
||||
|
||||
// Registers every row (command_id -> gaccel, via the same interning plumbing as
|
||||
// registerAction) in table order. Rows are COPIED into registry-owned storage whose
|
||||
// element addresses never move (REAPER holds each gaccel pointer until unload).
|
||||
// Call once at load; a failed command_id mint (cmd 0) leaves that row inert but
|
||||
// still mirror-unregistered on unload (harmless, matches the pre-table behavior).
|
||||
// Rows are copied into registry-owned storage whose addresses never move (REAPER
|
||||
// holds each gaccel pointer until unload).
|
||||
void registerActionTable(reaper_plugin_info_t* rec, const ActionTableRow* rows,
|
||||
std::size_t count);
|
||||
|
||||
// Dispatches one fired command: fires the matching row's handler and returns true;
|
||||
// false when the command belongs to no table row (caller's hookcommand keeps
|
||||
// looking, per the claim-only contract). A flat walk over the registered rows.
|
||||
bool actionTableHandleCommand(int command);
|
||||
|
||||
// The minted command id for `suffix` (0 when unregistered / mint failed). For the
|
||||
// callers that need a raw command id outside dispatch — e.g. the toggleaction
|
||||
// checked-state hook resolving TOGGLE_BANK_PANEL once at load.
|
||||
// 0 when unregistered / mint failed — for callers needing a raw id outside dispatch
|
||||
// (e.g. the toggleaction checked-state hook).
|
||||
int actionTableCommandId(const char* suffix);
|
||||
|
||||
// Mirror-unregisters every table row (reverse table order): '-gaccel' with the same
|
||||
// held storage, '-command_id' with the SAME interned pointer used at register.
|
||||
// Mirror-unregisters every table row (reverse order): '-gaccel' with the held
|
||||
// storage, '-command_id' with the SAME interned pointer used at register.
|
||||
void unregisterActionTable(reaper_plugin_info_t* rec);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
// bank_actions.cpp — the multi-bank bindable action family (Phase B3; Q-W4 split of
|
||||
// actions.cpp). See bank_actions.h.
|
||||
// bank_actions.cpp — see bank_actions.h.
|
||||
//
|
||||
// Q-W4 dedupe / Q-W6 seam: each mutating handler is a THIN UX SKIN — text prompts
|
||||
// (promptBankName), name resolution, and console feedback — over the promptless
|
||||
// bankOp* inner verbs homed in shell/bank_ops (model op + persistBankOp, one bank op
|
||||
// = one Ctrl-Z), driven against this family's registered session. The book's rules
|
||||
// (pool privileges, collapse-by-hash, active-fallback-to-pool) all live in
|
||||
// bank_book; these handlers only drive the verbs and react to the boolean.
|
||||
// Each mutating handler is a thin UX skin — text prompts, name resolution, console
|
||||
// feedback — over the promptless bankOp* verbs in shell/bank_ops (model op +
|
||||
// persistBankOp, one bank op = one Ctrl-Z). Pool privileges / collapse-by-hash /
|
||||
// active-fallback-to-pool live in bank_book; handlers only drive the verbs.
|
||||
//
|
||||
// REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index
|
||||
// return a reference INTO the book's internal vector, which a create/delete can
|
||||
// reallocate. No handler here caches a BankModel& (or a Bank*) across a structural
|
||||
// mutation — each resolves ids to strings up front and re-resolves after any
|
||||
// create/delete. Move/copy pass ids (not references) straight to the verbs.
|
||||
// REFERENCE-INVALIDATION GUARDRAIL: book().activeIndex() / bank()->index return a
|
||||
// reference INTO the book's internal vector, which a create/delete can reallocate.
|
||||
// No handler caches a BankModel&/Bank* across a structural mutation — ids are
|
||||
// resolved to strings up front and re-resolved after any create/delete.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers (CLAUDE.md §contract).
|
||||
// The action ids are minted from FOREVER-STABLE strings; user keybindings key off
|
||||
// them, so they must never change after ship.
|
||||
// main.cpp owns the API pointers; this TU gets them extern. Action ids are minted
|
||||
// from FOREVER-STABLE strings — never change one after ship.
|
||||
|
||||
#include "shell/actions/bank_actions.h"
|
||||
|
||||
@@ -42,11 +36,9 @@ namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// FOREVER-STABLE multi-bank action-id SUFFIXES (Phase V, V4). The channel family prefix is
|
||||
// prepended at register via channelCommandId (as with the Design View family) — stable
|
||||
// rebuilds the shipped id, beta the isolated one. NEVER change a shipped suffix.
|
||||
// Each suffix + the stable prefix must byte-match the pre-V4 shipped literal exactly
|
||||
// (e.g. "BANK_REMOVE_SELECTED" -> "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED").
|
||||
// FOREVER-STABLE action-id SUFFIXES: the channel prefix is prepended at register
|
||||
// (channelCommandId); NEVER change a shipped suffix — user keybindings key off the
|
||||
// composed id.
|
||||
constexpr const char* kIdBankCreate = "BANK_CREATE";
|
||||
constexpr const char* kIdBankRename = "BANK_RENAME";
|
||||
constexpr const char* kIdBankDelete = "BANK_DELETE";
|
||||
@@ -58,14 +50,10 @@ constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED";
|
||||
constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED";
|
||||
constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT";
|
||||
constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT";
|
||||
// Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so
|
||||
// in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion),
|
||||
// and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id.
|
||||
constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER";
|
||||
|
||||
// The live session the actions read (name resolution, member counts, prune) and
|
||||
// pass to the bankOp* verbs by reference (bankHandleCommand guards it non-null
|
||||
// before any handler runs). Set once by bankRegisterActions; not owned here.
|
||||
// Not owned here; set once by bankRegisterActions. bankHandleCommand guards it
|
||||
// non-null before any handler runs.
|
||||
ReaSamplerSession* g_session = nullptr;
|
||||
|
||||
int g_cmdBankCreate = 0;
|
||||
@@ -96,25 +84,17 @@ gaccel_register_t g_accelBankPoolFull{};
|
||||
gaccel_register_t g_accelBankBanksFull{};
|
||||
gaccel_register_t g_accelBankPruneFolder{};
|
||||
|
||||
// Resolves a user-typed bank reference (a display name) to a bank id, scanning the
|
||||
// book's banks in ordinal order. Exact match on displayName; "Pool" resolves the pool.
|
||||
// Returns "" when no bank carries that name. Kept in the action layer (not the model)
|
||||
// — it is UI name-resolution, not a model rule. First-match is unambiguous BY
|
||||
// CONSTRUCTION: the model enforces unique display names (trimmed + case-insensitive),
|
||||
// so at most one bank can carry a given name — no duplicate can shadow another here.
|
||||
// Resolves a user-typed display name to a bank id ("" if none matches). UI name
|
||||
// resolution, not a model rule — kept here rather than the model. Unambiguous by
|
||||
// construction: the model enforces unique display names.
|
||||
std::string bankIdByDisplayName(const std::string& name) {
|
||||
for (const Bank& b : g_session->book().banks())
|
||||
if (b.displayName == name) return b.id;
|
||||
return {};
|
||||
}
|
||||
|
||||
// -- Action bodies (thin UX skins over the bankOp* verbs) -------------------
|
||||
|
||||
// Create a named bank: prompt for a display name; the verb mints a stable GUID id,
|
||||
// creates it in the model, persists. The new bank is NOT auto-activated (create and
|
||||
// activate are distinct acts — mirrors capture/placement separation). The model
|
||||
// rejects a duplicate display name (trimmed + case-insensitive, incl. "Pool"); the
|
||||
// create then fails and the user is told the name is taken.
|
||||
// The new bank is NOT auto-activated (create and activate are distinct acts,
|
||||
// mirroring capture/placement separation).
|
||||
void doBankCreate() {
|
||||
std::string name;
|
||||
if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return;
|
||||
@@ -126,9 +106,8 @@ void doBankCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename a bank: prompt for which bank (by current display name) and the new name.
|
||||
// The pool is un-renamable (the model rejects it). Two prompts keep the bindable form
|
||||
// self-contained; the panel renames in place on a tab.
|
||||
// Two prompts (which bank, then the new name) keep this bindable form
|
||||
// self-contained; the panel renames in place on a tab instead.
|
||||
void doBankRename() {
|
||||
std::string which;
|
||||
if (!promptBankName("ReaSampler: rename bank", "Bank to rename (current name):", "",
|
||||
@@ -142,18 +121,13 @@ void doBankRename() {
|
||||
std::string newName;
|
||||
if (!promptBankName("ReaSampler: rename bank", "New name:", which, newName)) return;
|
||||
if (!bankOpRename(*g_session, id, newName)) {
|
||||
// The verb rejects the pool (un-renamable) or a name already used by another
|
||||
// bank (unique display names, trimmed + case-insensitive).
|
||||
ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable, "
|
||||
"or another bank already uses that name).\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail:
|
||||
// prompt for the bank; if it holds members, a YESNO ShowMessageBox names evacuate as
|
||||
// the alternative before dropping them (a plain delete orphans those members' files
|
||||
// until prune — CONTEXT.md §delete). An empty bank deletes with no prompt. The richer
|
||||
// panel confirm (naming evacuate inline, with a one-click evacuate) lives in the panel.
|
||||
// If the bank holds members, confirm first (a plain delete orphans those members'
|
||||
// files until prune); an empty bank deletes with no prompt.
|
||||
void doBankDelete() {
|
||||
std::string which;
|
||||
if (!promptBankName("ReaSampler: delete bank", "Bank to delete:", "", which)) return;
|
||||
@@ -162,15 +136,13 @@ void doBankDelete() {
|
||||
ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str());
|
||||
return;
|
||||
}
|
||||
// Pool early-out: the pool is un-deletable (the model rejects it). Catch it here,
|
||||
// BEFORE the non-empty confirm, so typing "Pool" never shows a misleading
|
||||
// "delete anyway?" prompt for an operation the model will refuse regardless.
|
||||
// Catch the pool BEFORE the non-empty confirm, so typing "Pool" never shows a
|
||||
// misleading "delete anyway?" for an operation the model will refuse regardless.
|
||||
if (id == kPoolBankId) {
|
||||
ShowConsoleMsg("ReaSampler: the pool cannot be deleted.\n");
|
||||
return;
|
||||
}
|
||||
// Read member count BEFORE deleting (the Bank* is invalidated by the delete; we do
|
||||
// not cache it — resolve size to an int up front).
|
||||
// Read member count before deleting — the Bank* is invalidated by the delete.
|
||||
const Bank* b = g_session->book().bank(id);
|
||||
if (!b) return; // race-safe: id resolved above but re-check
|
||||
const std::size_t members = b->index.size();
|
||||
@@ -184,16 +156,14 @@ void doBankDelete() {
|
||||
const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 4);
|
||||
if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544)
|
||||
}
|
||||
// S9: bump only when the deleted bank held samples — dropping them changes what a live
|
||||
// instance referencing one could play. Deleting an EMPTY bank is purely organizational.
|
||||
// Bump only when the deleted bank held samples — dropping them changes what a
|
||||
// live instance referencing one could play.
|
||||
if (!bankOpDelete(*g_session, id, /*bumpGeneration=*/members > 0)) {
|
||||
ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Evacuate a named bank: move every member back to the pool (index-only, collapse by
|
||||
// hash), leaving the bank empty. The pool is un-evacuable (the verb rejects it). The
|
||||
// intended "keep the samples" companion to delete.
|
||||
// The "keep the samples" companion to delete: moves every member back to the pool.
|
||||
void doBankEvacuate() {
|
||||
std::string which;
|
||||
if (!promptBankName("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "",
|
||||
@@ -210,10 +180,8 @@ void doBankEvacuate() {
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool),
|
||||
// via the pure nextBankId helper. Activating a bank changes the CAPTURE TARGET (the
|
||||
// next capture lands in the newly-active bank — B2's book().activeIndex() seam) and
|
||||
// never touches the timeline. The verb persists so the active id travels with the .rpp.
|
||||
// Cycles the active bank (pool -> named -> ... -> pool). Activating changes the
|
||||
// CAPTURE TARGET only — never touches the timeline.
|
||||
void doBankActivateNext() {
|
||||
std::vector<std::string> ids;
|
||||
ids.reserve(g_session->book().size());
|
||||
@@ -223,20 +191,13 @@ void doBankActivateNext() {
|
||||
bankOpActivate(*g_session, target);
|
||||
}
|
||||
|
||||
// Activate the pool directly (the common "back to the default target" jump). Bindable
|
||||
// direct-by-id form; a general activate-bank-by-name/menu is a panel affordance.
|
||||
void doBankActivatePool() {
|
||||
bankOpActivate(*g_session, kPoolBankId);
|
||||
}
|
||||
|
||||
// Move or copy the panel's selected samples into a named destination bank (prompted
|
||||
// by display name). The SOURCE is the bank the selection lives in — the focused
|
||||
// region's displayed bank (bankPanelSelectedSourceBankId), which under B4's vertical
|
||||
// split is NOT necessarily the active/capture-target bank (active ≠ shown). Both are
|
||||
// index-only (files never relocate); the verb owns the verb-aware no-op guardrail and
|
||||
// destination collapse-by-hash. The panel's "move to bank" menu drives the same verb
|
||||
// with a menu-chosen destination — this bindable form is the same operation with a
|
||||
// text-prompt destination.
|
||||
// SOURCE is the bank the selection lives in (bankPanelSelectedSourceBankId), which is
|
||||
// NOT necessarily the active/capture-target bank — the vertical split can show a
|
||||
// different bank than the one active for capture.
|
||||
void doBankTransferSelected(bool copy) {
|
||||
const std::vector<std::string> selected = bankPanelSelectedSampleIds();
|
||||
if (selected.empty()) {
|
||||
@@ -253,7 +214,6 @@ void doBankTransferSelected(bool copy) {
|
||||
ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str());
|
||||
return;
|
||||
}
|
||||
// Source = the bank the selection lives in (the focused region's displayed bank).
|
||||
const std::string srcId = bankPanelSelectedSourceBankId();
|
||||
if (srcId == destId) {
|
||||
ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n");
|
||||
@@ -262,10 +222,8 @@ void doBankTransferSelected(bool copy) {
|
||||
bankOpTransfer(*g_session, selected, srcId, destId, copy);
|
||||
}
|
||||
|
||||
// Remove the panel's selected samples from the SOURCE bank (the focused region's
|
||||
// displayed bank — same source as move/copy). Index-only and non-destructive to the
|
||||
// file (orphaned until Phase R prune); silent, with the batched undo as recovery —
|
||||
// see bankOpRemove for the full contract.
|
||||
// Index-only and non-destructive to the file (orphaned until prune); silent, with
|
||||
// the batched undo as recovery.
|
||||
void doBankRemoveSelected() {
|
||||
const std::vector<std::string> selected = bankPanelSelectedSampleIds();
|
||||
if (selected.empty()) {
|
||||
@@ -307,8 +265,6 @@ void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session)
|
||||
"toggle pool full-height");
|
||||
g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull,
|
||||
"toggle banks full-height");
|
||||
// Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends
|
||||
// the confirm-and-delete step behind this SAME forever-stable id).
|
||||
g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder,
|
||||
"prune bank folder");
|
||||
}
|
||||
@@ -335,8 +291,8 @@ bool bankHandleCommand(int command) {
|
||||
int bankPruneCommandId() { return g_cmdBankPruneFolder; }
|
||||
|
||||
void bankUnregisterActions(reaper_plugin_info_t* rec) {
|
||||
// Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each
|
||||
// '-command_id' re-presents the same interned channel-qualified id (channelIdFor).
|
||||
// Reverse of registration order; each '-command_id' re-presents the same
|
||||
// interned id (channelIdFor).
|
||||
rec->Register("-gaccel", (void*)&g_accelBankPruneFolder);
|
||||
rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder));
|
||||
rec->Register("-gaccel", (void*)&g_accelBankBanksFull);
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
#pragma once
|
||||
// bank_actions — the multi-bank bindable action family (Phase B3; Q-W4 split of
|
||||
// actions.h). The bindable action set that drives the multi-bank workflow: create /
|
||||
// rename / delete / evacuate a bank, activate a bank (direct pool + cycle), move /
|
||||
// copy / remove the panel's selected samples, the two vertical-split full-height
|
||||
// toggles, and the Phase R prune action's registration + dispatch (its guarded body
|
||||
// lives in prune_action). Q-W4 dedupe: every mutating handler here is a THIN UX skin
|
||||
// (text prompts + console messages) over the promptless bankOp* verbs homed in
|
||||
// panel_bank_ops — one implementation home for each mutation, two UX skins (this
|
||||
// family prompts for which bank; the panel acts on a clicked tab).
|
||||
// bank_actions — the multi-bank bindable action family: create/rename/delete/
|
||||
// evacuate a bank, activate (direct pool + cycle), move/copy/remove the panel's
|
||||
// selected samples, the two vertical-split full-height toggles, and the prune
|
||||
// action's registration + dispatch (guarded body in prune_action). Every mutating
|
||||
// handler is a thin UX skin over the promptless bankOp* verbs in bank_ops (this
|
||||
// family prompts for a bank name; the panel acts on a clicked tab).
|
||||
//
|
||||
// Same registration/routing/unload contract as the Design View family
|
||||
// (design_view_actions); both share main.cpp's single hookcommand, and each family's
|
||||
// Handle claims only its own ids. This header is SDK-free.
|
||||
// Same registration/routing/unload contract as design_view_actions. SDK-free header.
|
||||
|
||||
// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef) so this
|
||||
// header stays SDK-free; the .cpp includes the real definition.
|
||||
struct reaper_plugin_info_t;
|
||||
struct reaper_plugin_info_t; // global scope, matches reaper_plugin.h's typedef
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// Registers the multi-bank family against `rec`. `session` is the live session (must
|
||||
// outlive registration). Call exactly once at load — pass the SAME session pointer
|
||||
// the Design View family receives.
|
||||
// `session` must outlive registration — pass the SAME pointer the Design View
|
||||
// family receives.
|
||||
void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
|
||||
|
||||
// Services one fired command for the multi-bank family. True iff it was one of this
|
||||
// family's ids (and handled); false otherwise so the caller's hookcommand keeps
|
||||
// looking. Safe for any command.
|
||||
bool bankHandleCommand(int command);
|
||||
|
||||
// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on
|
||||
// rec==nullptr (before the session is torn down).
|
||||
void bankUnregisterActions(reaper_plugin_info_t* rec);
|
||||
|
||||
// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0
|
||||
// before registration. The bank_panel prune button fires the action THROUGH this id
|
||||
// via Main_OnCommand (fork R-E: the button dispatches the command, it does not call
|
||||
// the session directly) so the panel affordance and the bindable action share one
|
||||
// guarded code path.
|
||||
// The bank_panel prune button fires THROUGH this id (Main_OnCommand) rather than
|
||||
// calling the session directly, so button and bindable action share one guarded path.
|
||||
int bankPruneCommandId();
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
// design_view_actions.cpp — the Design View action family (Phase D4; Q-W4 split of
|
||||
// actions.cpp). See design_view_actions.h.
|
||||
// design_view_actions.cpp — see design_view_actions.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). The action ids are minted from FOREVER-STABLE strings (the
|
||||
// same CEREBELLUM_REASAMPLER_ family prefix main.cpp uses); user keybindings key off
|
||||
// them, so they must never change after ship.
|
||||
// main.cpp owns the API pointers; this TU gets them extern. Action ids are minted
|
||||
// from FOREVER-STABLE strings — never change one after ship.
|
||||
//
|
||||
// Each action:
|
||||
// 1. mutates the session's ViewModeModel (membership tag/untag/show-both, or the
|
||||
// active mode via toggle/activate) — the pure D1 state,
|
||||
// 2. reapplies the active mode through the D2 view shell (applyMode) so the change
|
||||
// takes visible effect immediately (tagging a track into Design while in Arrange
|
||||
// parks it right away; a mode change re-partitions and re-parks in one step).
|
||||
// Each action mutates the session's ViewModeModel (membership tag/untag/show-both, or
|
||||
// the active mode via toggle/activate), then reapplies the active mode through the
|
||||
// view shell (applyMode) so the change takes visible effect immediately.
|
||||
//
|
||||
// Selection-driven mutations iterate the CURRENT REAPER track selection
|
||||
// (CountSelectedTracks/GetSelectedTrack — both ignore the master, which is correct:
|
||||
// the master is never tagged) and resolve each track to its canonical GUID key via
|
||||
// the shared guidString helper, so the keys match exactly what the D2 shell / view
|
||||
// tree key on (the cross-module key contract).
|
||||
// (CountSelectedTracks/GetSelectedTrack ignore the master, which is correct — the
|
||||
// master is never tagged) and resolve each track to its canonical GUID key so the
|
||||
// keys match what the view shell / view tree key on.
|
||||
|
||||
#include "shell/actions/design_view_actions.h"
|
||||
|
||||
@@ -55,11 +47,8 @@ using view::isOnManualLane;
|
||||
|
||||
namespace {
|
||||
|
||||
// FOREVER-STABLE action-id SUFFIXES (Phase V, V4). The channel family prefix is prepended
|
||||
// at register time via channelCommandId (app_version), so stable rebuilds the exact shipped
|
||||
// id ("CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE") and beta yields the isolated forever-family
|
||||
// id ("CEREBELLUM_REASAMPLER_BETA_VIEW_TOGGLE_MODE"). Each composed id is minted into a
|
||||
// persistent command id user keybindings key off — NEVER change a shipped suffix after ship.
|
||||
// FOREVER-STABLE action-id SUFFIXES: the channel prefix is prepended at register
|
||||
// (channelCommandId) — NEVER change a shipped suffix.
|
||||
constexpr const char* kIdToggleMode = "VIEW_TOGGLE_MODE";
|
||||
constexpr const char* kIdActivateArrange = "VIEW_ACTIVATE_ARRANGE";
|
||||
constexpr const char* kIdActivateDesign = "VIEW_ACTIVATE_DESIGN";
|
||||
@@ -67,17 +56,14 @@ constexpr const char* kIdTagDesign = "VIEW_TAG_DESIGN";
|
||||
constexpr const char* kIdTagArrange = "VIEW_TAG_ARRANGE";
|
||||
constexpr const char* kIdUntag = "VIEW_UNTAG";
|
||||
constexpr const char* kIdShowBoth = "VIEW_SHOW_BOTH";
|
||||
// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same
|
||||
// FOREVER-STABLE contract (suffix composed with the channel prefix) — NEVER change these.
|
||||
// Item-level mode moves — the item analog of the track tag family above.
|
||||
constexpr const char* kIdMoveItemsDesign = "VIEW_MOVE_ITEMS_DESIGN";
|
||||
constexpr const char* kIdMoveItemsArrange = "VIEW_MOVE_ITEMS_ARRANGE";
|
||||
constexpr const char* kIdUntagItems = "VIEW_UNTAG_ITEMS";
|
||||
|
||||
// The live session the actions mutate. Set once by designViewRegisterActions and
|
||||
// read by the hookcommand handler. Not owned here (main.cpp owns g_session).
|
||||
// Not owned here (main.cpp owns g_session).
|
||||
ReaSamplerSession* g_session = nullptr;
|
||||
|
||||
// Minted command ids (0 until registration succeeds). Compared in the handler.
|
||||
int g_cmdToggleMode = 0;
|
||||
int g_cmdActivateArrange = 0;
|
||||
int g_cmdActivateDesign = 0;
|
||||
@@ -102,9 +88,6 @@ gaccel_register_t g_accelMoveItemsDesign{};
|
||||
gaccel_register_t g_accelMoveItemsArrange{};
|
||||
gaccel_register_t g_accelUntagItems{};
|
||||
|
||||
// Collects the canonical GUID keys of the current track selection. Empty if nothing
|
||||
// is selected. CountSelectedTracks/GetSelectedTrack ignore the master (SDK), which is
|
||||
// exactly right — the master is never a tagged leaf.
|
||||
std::vector<std::string> selectedTrackGuids() {
|
||||
std::vector<std::string> guids;
|
||||
const int n = CountSelectedTracks(nullptr); // nullptr = active project
|
||||
@@ -118,22 +101,17 @@ std::vector<std::string> selectedTrackGuids() {
|
||||
return guids;
|
||||
}
|
||||
|
||||
// Reapplies the model's CURRENT active mode to the active project so a membership
|
||||
// mutation takes visible effect immediately (park/unpark/re-derive parents). Called
|
||||
// after every tag/untag/show-both. `proj = nullptr` -> REAPER's active project.
|
||||
// Reapplies the CURRENT active mode so a membership mutation takes visible effect
|
||||
// immediately (park/unpark/re-derive parents).
|
||||
void reapplyActiveMode() {
|
||||
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
|
||||
}
|
||||
|
||||
// Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to
|
||||
// decide whether an item's lane name is meaningful for the manual-lane read.
|
||||
constexpr int kFreeModeFixedLanes = 2;
|
||||
constexpr int kFreeModeFixedLanes = 2; // I_FREEMODE value; mirrors the shell's constant
|
||||
|
||||
// Collects the current media-item selection as the pure decision's input: each selected
|
||||
// item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned).
|
||||
// The manual-lane read follows the shared pure predicate exactly as the shell's readers
|
||||
// do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal
|
||||
// track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped.
|
||||
// Each selected item's GUID plus whether it sits on a MANUAL lane (EXEMPT — never
|
||||
// retagged/re-laned). The lane name is read only on a fixed-lane track; on a normal
|
||||
// track the shared predicate returns false for an empty name, so the read is skipped.
|
||||
// Items whose GUID cannot be read are dropped (an empty GUID must never be retagged).
|
||||
std::vector<RetagItem> selectedRetagItems() {
|
||||
std::vector<RetagItem> items;
|
||||
@@ -148,33 +126,18 @@ std::vector<RetagItem> selectedRetagItems() {
|
||||
MediaTrack* tr = GetMediaItemTrack(it);
|
||||
const bool fixedLane =
|
||||
tr && static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
|
||||
// Only read the lane name on a fixed-lane track; the pure predicate handles the
|
||||
// normal-track case (returns false) so we pass an empty name and skip the read.
|
||||
const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{};
|
||||
items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Persists both the bank and the Design-View model to the active project's ext
|
||||
// state. Called after every state-changing Design View action so the view model
|
||||
// is not lost across save/close/reopen. Marking the project dirty is correct —
|
||||
// a Design View mutation is a project-level change the user should be prompted
|
||||
// to save.
|
||||
//
|
||||
// When the membership index is non-empty AND the project is unsaved, we prompt
|
||||
// the user to Save-As before persisting — mirroring the flow capture uses.
|
||||
// Gate: if membership is empty (no tracks tagged), skip the prompt entirely;
|
||||
// saveToActiveProject will no-op for an unsaved project, which is correct.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save-As dialog and
|
||||
// blocks until the user dismisses it. The blocking behaviour and dialog
|
||||
// appearance can only be confirmed in a running REAPER (same caveat as capture).
|
||||
// Persists the bank + Design-View model after every state-changing action so the
|
||||
// view model is not lost across save/close/reopen. If membership is non-empty and
|
||||
// the project is unsaved, prompts Save-As first (mirrors the flow capture uses) —
|
||||
// DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed.
|
||||
void persistViewState() {
|
||||
if (!g_session->view().membership().empty()) {
|
||||
// At least one track is tagged — worth persisting. Check whether the
|
||||
// project is saved and, if not, prompt Save-As so saveToActiveProject
|
||||
// can write ext state. Mirrors capture's readRppPath idiom exactly.
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (proj) {
|
||||
auto readRppPath = [&]() -> std::string {
|
||||
@@ -184,15 +147,11 @@ void persistViewState() {
|
||||
};
|
||||
|
||||
if (readRppPath().empty()) {
|
||||
// Project is unsaved — prompt Save-As.
|
||||
Main_SaveProject(proj, true);
|
||||
// Re-read: still empty means the user cancelled.
|
||||
if (readRppPath().empty()) {
|
||||
if (readRppPath().empty()) { // still empty -> user cancelled
|
||||
ShowConsoleMsg(
|
||||
"ReaSampler: Design View state will not persist until "
|
||||
"the project is saved.\n");
|
||||
// The in-session tag state is left as-is — the mode change
|
||||
// already applied and remains valid for this session.
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -201,11 +160,8 @@ void persistViewState() {
|
||||
g_session->saveToActiveProject();
|
||||
}
|
||||
|
||||
// -- Action bodies ---------------------------------------------------------
|
||||
|
||||
// Toggle: cycle to the next mode in ordinal order (Arrange <-> Design with two
|
||||
// seeds; scales to cycle-through-all for >2 modes with no change here). applyMode
|
||||
// itself sets the model's active mode, so we only compute the target and apply.
|
||||
// Cycle to the next mode in ordinal order. applyMode itself sets the model's active
|
||||
// mode, so we only compute the target and apply.
|
||||
void doToggleMode() {
|
||||
const std::string target =
|
||||
nextModeId(g_session->view().modes(), g_session->view().activeModeId());
|
||||
@@ -223,9 +179,8 @@ void doActivateMode(const std::string& modeId) {
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
// Tag the selection's leaves into `modeId`, then reapply so the change is immediate.
|
||||
// tag() replaces any prior single-mode membership (a leaf lives in one mode; the
|
||||
// cross-mode case is show-both), matching the D1 contract.
|
||||
// cross-mode case is show-both).
|
||||
void doTag(const std::string& modeId) {
|
||||
for (const std::string& g : selectedTrackGuids())
|
||||
g_session->view().membership().tag(g, modeId);
|
||||
@@ -233,9 +188,8 @@ void doTag(const std::string& modeId) {
|
||||
persistViewState();
|
||||
}
|
||||
|
||||
// Untag the selection entirely (return each to the Arrange default). This is the
|
||||
// shared body behind both "Untag selected" and "Tag -> Arrange" (Arrange = the
|
||||
// absence of a tag), so the two actions are the same act by definition.
|
||||
// Shared body behind "Untag selected" and "Tag -> Arrange" — Arrange is the absence
|
||||
// of a tag, so the two actions are the same act.
|
||||
void doUntag() {
|
||||
for (const std::string& g : selectedTrackGuids())
|
||||
g_session->view().membership().untag(g);
|
||||
@@ -243,11 +197,8 @@ void doUntag() {
|
||||
persistViewState();
|
||||
}
|
||||
|
||||
// Toggle the per-track show-both pin for the selection. Read the CURRENT pin of each
|
||||
// track and flip it independently (a mixed selection converges toward "all on" then
|
||||
// "all off" only if uniform; per-track flip is the honest semantics of a toggle on a
|
||||
// multi-selection). show-both leaves are never parked (D1), so reapply reflects the
|
||||
// change immediately.
|
||||
// Flips each track's pin independently — the honest semantics of a toggle on a
|
||||
// multi-selection (a mixed selection converges toward uniform only if it already was).
|
||||
void doShowBoth() {
|
||||
MembershipIndex& m = g_session->view().membership();
|
||||
for (const std::string& g : selectedTrackGuids())
|
||||
@@ -256,19 +207,12 @@ void doShowBoth() {
|
||||
persistViewState();
|
||||
}
|
||||
|
||||
// -- Item-level mode moves (D2 Wave 3-B) -----------------------------------
|
||||
// Retag the current ITEM selection to `targetMode` (empty => untag -> Arrange
|
||||
// default). planItemRetag decides which items to retag (manual-lane items are
|
||||
// EXEMPT), upholding the managed-lanes-only invariant. Wrapped in ONE Undo block.
|
||||
//
|
||||
// Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default),
|
||||
// then re-drive the minting + apply path so each moved item lands on its target mode's
|
||||
// managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag
|
||||
// decides which selected items to retag (manual-lane items are EXEMPT — never retagged,
|
||||
// never re-laned), upholding the managed-lanes-only invariant even under this explicit
|
||||
// user action. The whole structural act is wrapped in ONE Undo block with a descriptive
|
||||
// label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it).
|
||||
//
|
||||
// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which
|
||||
// must NOT sit inside the Undo block, so we close the block first, then persist — the same
|
||||
// separation the track actions rely on (they persist outside applyMode's own block).
|
||||
// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog, which must NOT sit
|
||||
// inside the Undo block, so we close the block first, then persist.
|
||||
void doMoveItems(const std::string& targetMode) {
|
||||
const std::vector<RetagItem> selected = selectedRetagItems();
|
||||
const std::vector<ItemRetagOp> ops = planItemRetag(selected, targetMode);
|
||||
@@ -277,15 +221,12 @@ void doMoveItems(const std::string& targetMode) {
|
||||
MembershipIndex& membership = g_session->view().membership();
|
||||
|
||||
Undo_BeginBlock2(nullptr);
|
||||
// Apply the pure decision's membership writes: tag into targetMode, or untag.
|
||||
for (const ItemRetagOp& op : ops) {
|
||||
if (op.untag) membership.untag(op.guid);
|
||||
else membership.tag(op.guid, op.modeId);
|
||||
}
|
||||
// Re-drive the SAME minting/apply path auto-tag uses: mint/split lanes for any track
|
||||
// whose items now span modes and assign each moved item to its mode's managed lane,
|
||||
// then reassert the active mode's lane visibility. Manual lanes stay untouched
|
||||
// (mintManagedLanes reports their items exempt and never mints over them).
|
||||
// Re-drive the same minting/apply path auto-tag uses: mint/split lanes for any
|
||||
// track whose items now span modes, then reassert active-mode lane visibility.
|
||||
mintManagedLanes(g_session->view(), nullptr);
|
||||
reapplyActiveMode();
|
||||
|
||||
@@ -303,8 +244,6 @@ void doMoveItems(const std::string& targetMode) {
|
||||
void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
|
||||
g_session = session;
|
||||
|
||||
// command_id -> gaccel for each. The single hookcommand that routes these lives
|
||||
// in main.cpp (one hook per extension); designViewHandleCommand services them.
|
||||
g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode,
|
||||
"toggle Design View mode");
|
||||
g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange,
|
||||
@@ -320,7 +259,6 @@ void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* ses
|
||||
g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth,
|
||||
"show both for selected tracks");
|
||||
|
||||
// Item-level mode moves (D2 W3-B): the item analog of the track tag family.
|
||||
g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign,
|
||||
"move selected items -> Design");
|
||||
g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange,
|
||||
@@ -336,13 +274,10 @@ bool designViewHandleCommand(int command) {
|
||||
if (command == g_cmdActivateArrange) { doActivateMode(kArrangeModeId); return true; }
|
||||
if (command == g_cmdActivateDesign) { doActivateMode(kDesignModeId); return true; }
|
||||
if (command == g_cmdTagDesign) { doTag(kDesignModeId); return true; }
|
||||
// Tag -> Arrange and Untag are the same act (Arrange = the absence of a tag).
|
||||
if (command == g_cmdTagArrange) { doUntag(); return true; }
|
||||
if (command == g_cmdUntag) { doUntag(); return true; }
|
||||
if (command == g_cmdShowBoth) { doShowBoth(); return true; }
|
||||
|
||||
// Item-level moves. Move -> Arrange and Untag items collapse to the same act (an
|
||||
// empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above.
|
||||
if (command == g_cmdMoveItemsDesign) { doMoveItems(kDesignModeId); return true; }
|
||||
if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; }
|
||||
if (command == g_cmdUntagItems) { doMoveItems(std::string{}); return true; }
|
||||
@@ -351,11 +286,8 @@ bool designViewHandleCommand(int command) {
|
||||
}
|
||||
|
||||
void designViewUnregisterActions(reaper_plugin_info_t* rec) {
|
||||
// Mirror-unregister with '-'-prefixed strings, per the contract's unload rule.
|
||||
// gaccel first, then the command_id string (reverse of registration order — the item
|
||||
// moves registered last, so they tear down first).
|
||||
// Each '-command_id' re-presents the SAME interned, channel-qualified id (channelIdFor
|
||||
// returns the memoized pointer registered above), so the unregister matches exactly.
|
||||
// Reverse registration order; each '-command_id' re-presents the SAME interned
|
||||
// pointer channelIdFor returned above.
|
||||
rec->Register("-gaccel", (void*)&g_accelUntagItems);
|
||||
rec->Register("-command_id", (void*)channelIdFor(kIdUntagItems));
|
||||
rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange);
|
||||
|
||||
@@ -1,38 +1,26 @@
|
||||
#pragma once
|
||||
// design_view_actions — the Design View action family (Phase D4; Q-W4 split of
|
||||
// actions.h). Registers the bindable actions that drive the mode workflow and wires
|
||||
// them end-to-end: toggle/activate a mode, tag/untag/show-both the current track
|
||||
// selection, and the item-level mode moves (D2 W3-B). Each action mutates the
|
||||
// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies
|
||||
// the active mode through the view shell (D2) so the change takes effect immediately.
|
||||
//
|
||||
// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This
|
||||
// header is SDK-free; main.cpp calls register/handle/unregister and nothing else.
|
||||
// design_view_actions — the Design View bindable action family: toggle/activate a
|
||||
// mode, tag/untag/show-both the current track selection, and the item-level mode
|
||||
// moves. Each action mutates the session's ViewModeModel then reapplies the active
|
||||
// mode through the view shell so the change takes effect immediately. SDK-free
|
||||
// header; main.cpp calls register/handle/unregister and nothing else.
|
||||
|
||||
// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct
|
||||
// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real
|
||||
// definition. Declared before the namespace so it is the global type, not a
|
||||
// namespace-local shadow.
|
||||
// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef) so this
|
||||
// header stays SDK-free; the .cpp includes the real definition.
|
||||
struct reaper_plugin_info_t;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// Registers the Design View action family against `rec` (command_id + gaccel +
|
||||
// hookcommand-routing is owned by the caller's single hookcommand). `session` is the
|
||||
// live session the actions mutate; it must outlive the registration. Idempotent is
|
||||
// NOT promised — call exactly once at load, mirror-unregister once at unload.
|
||||
// `session` must outlive registration. Not idempotent — call exactly once at load,
|
||||
// mirror-unregister once at unload.
|
||||
void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
|
||||
|
||||
// Services one fired command. Returns true iff `command` is one of this module's
|
||||
// action ids (and it was handled); false otherwise so the caller's hookcommand keeps
|
||||
// looking (per the contract: claim only our own ids). Safe to call for any command.
|
||||
// True iff `command` is one of this module's ids (and handled); false otherwise so
|
||||
// the caller's hookcommand keeps looking.
|
||||
bool designViewHandleCommand(int command);
|
||||
|
||||
// Mirror-unregisters everything designViewRegisterActions registered, with the
|
||||
// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr.
|
||||
void designViewUnregisterActions(reaper_plugin_info_t* rec);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
// 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.
|
||||
// drag_out_win.cpp — see drag_out_win.h. Hand-rolled IDataObject/IDropSource rather
|
||||
// than a helper library: the object is tiny (one format, one medium) and the
|
||||
// copy-only guarantee must be structural and auditable in one place. No REAPER API
|
||||
// used here (pure OS/COM).
|
||||
|
||||
#include "shell/actions/drag_out_win.h"
|
||||
|
||||
@@ -29,8 +23,8 @@ namespace {
|
||||
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).
|
||||
// Convert each UTF-8 path to wide, normalizing '/' -> '\\' (the panel stores paths
|
||||
// slash-normalized; 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
|
||||
@@ -40,8 +34,7 @@ HGLOBAL buildHDrop(const std::vector<std::string>& paths) {
|
||||
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();
|
||||
if (!w.empty() && w.back() == L'\0') w.pop_back(); // re-added below
|
||||
for (wchar_t& c : w) if (c == L'/') c = L'\\';
|
||||
totalChars += w.size() + 1; // + the per-path NUL
|
||||
wide.push_back(std::move(w));
|
||||
@@ -200,10 +193,9 @@ private:
|
||||
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.
|
||||
// REAPER's main thread is already OLE-initialized (it hosts OLE drag targets); we
|
||||
// deliberately do NOT call OleInitialize — pairing OleUninitialize across a
|
||||
// REAPER-owned apartment is the kind of thing that bites.
|
||||
HGLOBAL hdrop = buildHDrop(absolutePaths);
|
||||
if (!hdrop) return false;
|
||||
|
||||
@@ -211,9 +203,8 @@ bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& abso
|
||||
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).
|
||||
// COPY-ONLY: the allowed-effects mask is DROPEFFECT_COPY alone. MOVE is never
|
||||
// offered, so no drop target can relocate (delete) the bank file.
|
||||
const HRESULT hr = DoDragDrop(data, source, DROPEFFECT_COPY, &effect);
|
||||
|
||||
source->Release();
|
||||
@@ -232,12 +223,9 @@ bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& abso
|
||||
|
||||
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).
|
||||
// SWELL_InitiateDragDropOfFileList initiates a copy-style file drag from the given
|
||||
// window. Unlike OLE it exposes no per-source effect mask, so the copy-only
|
||||
// guarantee here rests on SWELL's copy semantics rather than an explicit mask.
|
||||
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths) {
|
||||
if (absolutePaths.empty() || !panelHwnd) return false;
|
||||
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
#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.
|
||||
// drag_out_win — the OS/COM initiation half of native OS drag-out: hands a resolved
|
||||
// existing-file path list to the OS's drag-drop machinery so the user can drop bank
|
||||
// samples into Explorer / another app / another DAW. The pure gesture-boundary
|
||||
// decision + path-list assembly live in drag_out.*.
|
||||
//
|
||||
// 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.
|
||||
// COPY-ONLY is STRUCTURAL on Windows: DoDragDrop's effect mask is DROPEFFECT_COPY
|
||||
// alone — MOVE is never offered, so no target can pull a file out of the bank folder
|
||||
// (only prune deletes bank bytes). macOS/Linux route through SWELL's file-list drag,
|
||||
// which exposes no per-source effect mask, so there the copy-only guarantee rests on
|
||||
// SWELL's copy semantics rather than an explicit mask.
|
||||
//
|
||||
// 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.
|
||||
// NON-DESTRUCTIVE: initiating a drag reads nothing but the path list; a cancelled or
|
||||
// failed drag mutates no sample/index/selection.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -28,16 +20,10 @@ struct HWND__; // avoid dragging windows.h into every includer; the shell casts
|
||||
|
||||
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).
|
||||
// `absolutePaths` must already be resolved/existing/de-duped (drag_out::assemblePathList
|
||||
// output). No-op when empty. BLOCKING on Windows: OLE DoDragDrop runs its own modal
|
||||
// message loop until drop/cancel. Returns true iff the drop was accepted
|
||||
// (DROPEFFECT_COPY); the return is advisory — a failed drag surfaces no error.
|
||||
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// 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).
|
||||
// instrument_drop_win.cpp — see instrument_drop_win.h. main.cpp owns the API
|
||||
// pointers; this TU gets them extern via the WANT list.
|
||||
|
||||
#include "shell/actions/instrument_drop_win.h"
|
||||
|
||||
@@ -35,32 +33,24 @@ using wire::infoNamesFxHotspot;
|
||||
|
||||
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.
|
||||
// Writes `bytes` to a fresh uniquely-named .vstpreset in the OS temp dir; empty path
|
||||
// on any failure. The .vstpreset extension is load-bearing — TrackFX_SetPreset's
|
||||
// full-path form is documented for .vstpreset files (VST3). 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 the prune shell uses — see prune_fs.cpp's non-throwing scan 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.
|
||||
// Non-throwing: every std::filesystem call uses the error_code overload, and the
|
||||
// whole body is try/catch-wrapped so no exception crosses the REAPER callback
|
||||
// boundary. Returns the path object (not a narrow string) so the caller can pass
|
||||
// path.u8string() to TrackFX_SetPreset (UTF-8, not ACP-converted) and delete via the
|
||||
// same retained path — never a re-parsed narrow string.
|
||||
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.
|
||||
// PID in the name: two concurrent REAPER instances (stable + beta) cannot
|
||||
// collide in the shared temp dir.
|
||||
const std::string name =
|
||||
"reasampler_drop_" + std::to_string(GetCurrentProcessId()) +
|
||||
"_" + std::to_string(counter.fetch_add(1)) + ".vstpreset";
|
||||
@@ -85,10 +75,8 @@ std::filesystem::path writeTempPreset(const std::vector<std::uint8_t>& bytes) {
|
||||
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.
|
||||
// A non-empty info OR a non-null track means the point is over REAPER's own UI;
|
||||
// a null track with empty info means the pointer has left REAPER entirely.
|
||||
MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
|
||||
out.track = track;
|
||||
out.overReaperUi = (track != nullptr) || (info[0] != '\0');
|
||||
@@ -101,46 +89,32 @@ FxDropTarget resolveFxDropTarget(int screenX, int screenY) {
|
||||
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).
|
||||
// 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.)
|
||||
// Channel-correct FX name ("VST3:ReaSampler 9000[ beta]") sourced from the same
|
||||
// accessor the VST factory display name derives from, so the pairing invariant
|
||||
// (beta extension <-> beta VST) has no literal to drift.
|
||||
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.
|
||||
// Negative `instantiate` => always create a NEW instance. 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.
|
||||
// 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);
|
||||
std::filesystem::remove(presetPath, ec); // transient regardless of outcome
|
||||
|
||||
// All-or-nothing: if the preset apply fails, remove the FX instance we just
|
||||
// added so the track is left exactly as it was.
|
||||
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;
|
||||
@@ -149,11 +123,8 @@ bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>&
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,51 @@
|
||||
#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).
|
||||
// instrument_drop_win — the REAPER-facing shell half of drop-and-load: (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 and applies the dragged capture as
|
||||
// its component state via a temp .vstpreset + TrackFX_SetPreset (the earlier
|
||||
// "vst_chunk" named-config-parm write was silently unappliable for VST3 — don't
|
||||
// revert to it). The pure gesture decision lives in drag_out; the pure payload
|
||||
// construction in instrument_drop.
|
||||
//
|
||||
// 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.
|
||||
// LOAD-BEARING: an EXPLICIT user placement-of-the-player gesture — adds a READER of
|
||||
// the bank on a track, pointed at an already-captured sample. NEVER captures, NEVER
|
||||
// writes the bank, NEVER inserts a timeline item. The only writes are a new FX
|
||||
// instance + its component state, both wrapped in one undo block (one Ctrl-Z), plus
|
||||
// a transient .vstpreset 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.
|
||||
// Declared as a class (matching reaper_plugin.h) so the mangled name agrees, without
|
||||
// pulling in the SDK.
|
||||
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.
|
||||
// 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 windows). `overReaperUi` is true when the point is over REAPER's own UI
|
||||
// at all; `overFxHotspot` is true only for a genuine FX-bearing surface (decided by
|
||||
// the pure instrument_drop::infoNamesFxHotspot).
|
||||
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.
|
||||
// Adds a fresh ReaSampler 9000 instance to `track` and applies `presetBytes` as its
|
||||
// component state. Wraps add + apply in one REAPER undo block. All-or-nothing: if
|
||||
// the preset apply fails after a successful add, the FX instance is removed via
|
||||
// TrackFX_Delete before returning false, leaving the track exactly as it was.
|
||||
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.
|
||||
// Same all-or-nothing add+apply contract as performInstrumentDrop but does NOT open
|
||||
// its own undo block — the caller owns the undo grouping so persist + FX-add + apply
|
||||
// collapses to one Ctrl-Z. The shared inner half performInstrumentDrop wraps.
|
||||
bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// prune_action.cpp — the "Prune bank folder" action body (Phase R3; Q-W4 split of
|
||||
// actions.cpp). See prune_action.h for the contract this TU preserves.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
|
||||
// prune_action.cpp — see prune_action.h for the contract this TU preserves.
|
||||
// main.cpp owns the API pointers; this TU gets them extern.
|
||||
|
||||
#include "shell/actions/prune_action.h"
|
||||
|
||||
@@ -18,25 +15,17 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE
|
||||
// file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only),
|
||||
// then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest
|
||||
// (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then
|
||||
// on explicit Yes delete EXACTLY that set (session.pruneReclaim, which recomputes the
|
||||
// pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback).
|
||||
// Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing.
|
||||
//
|
||||
// The full (untruncated) orphan set is captured here for the delete; the dry-run's
|
||||
// truncated list is only the confirm's readout. No ext-state is written and no undo point
|
||||
// is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project
|
||||
// state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files.
|
||||
// The guarded DESTRUCTIVE step and the SOLE file-deletion entry in ReaSampler.
|
||||
// Dry-run FIRST (read-only); only when orphans exist, a blocking CONFIRM with the
|
||||
// manifest; on explicit Yes, delete EXACTLY that set (recomputed fresh — confirmed ∩
|
||||
// freshOrphans). Zero orphans => informational only, no confirm shown. No ext-state
|
||||
// write, no undo point (file deletion is not REAPER-undoable).
|
||||
void doBankPruneFolder(ReaSamplerSession& session) {
|
||||
const reclaim::PruneReport report = session.pruneDryRun();
|
||||
|
||||
// pS-usage FAIL-SAFE: a present instance-usage record could not be read — the
|
||||
// protected set is unknowable, so the prune HALTS outright (deletes nothing) rather
|
||||
// than proceed with degraded protection. Distinct from "no orphans": the user must
|
||||
// know the prune refused to run and why.
|
||||
// FAIL-SAFE: an unreadable instance-usage record makes the protected set
|
||||
// unknowable, so the prune HALTS outright rather than proceed with degraded
|
||||
// protection.
|
||||
if (report.abortedUnreadableUsage) {
|
||||
std::string msg =
|
||||
"ReaSampler prune: ABORTED -- one or more instance usage records could not "
|
||||
@@ -58,13 +47,10 @@ void doBankPruneFolder(ReaSamplerSession& session) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The EXACT set the delete will target — full, untruncated, so what the confirm
|
||||
// summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the
|
||||
// confirm so the confirm and the delete reason about the same enumeration.
|
||||
// The EXACT (untruncated) set the delete will target, captured before the confirm
|
||||
// so confirm and delete reason about the same enumeration.
|
||||
const std::vector<std::string> orphanSet = session.pruneOrphanSet();
|
||||
|
||||
// Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped
|
||||
// list (the same clip the R2 readout used), with a "N more not shown" tail when clipped.
|
||||
std::string msg =
|
||||
"ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) +
|
||||
" orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n"
|
||||
@@ -84,7 +70,6 @@ void doBankPruneFolder(ReaSamplerSession& session) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped).
|
||||
const reclaim::PruneDeletionResult del = session.pruneReclaim(orphanSet);
|
||||
|
||||
std::string done = "ReaSampler prune: reclaimed " +
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
#pragma once
|
||||
// prune_action — the "Prune bank folder" action body (Phase R3; Q-W4 split of
|
||||
// actions.cpp). This is the SOLE file-deletion action in ReaSampler, isolated in its
|
||||
// own TU so the deletion authority is one obvious module on the actions side (its
|
||||
// persist-side counterpart concentrates into prune_fs in Q-W5). Registration and
|
||||
// hookcommand routing for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay with the
|
||||
// bank family (bank_actions) — one registration flow, one guarded body here.
|
||||
// prune_action — the "Prune bank folder" action body: the SOLE file-deletion action
|
||||
// in ReaSampler, isolated in its own TU so the deletion authority is one obvious
|
||||
// module. Registration/dispatch for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay
|
||||
// with bank_actions; one guarded body here.
|
||||
//
|
||||
// Contract (preserve exactly): dry-run first; abort outright on unreadable usage
|
||||
// records (pS-usage fail-safe); confirm-with-manifest before any deletion; opens NO
|
||||
// undo point and writes NO ext state (file deletion is not REAPER-undoable). Routes
|
||||
// to persist's public session API only (pruneDryRun / pruneOrphanSet / pruneReclaim).
|
||||
// records (fail-safe); confirm-with-manifest before any deletion; opens NO undo
|
||||
// point and writes NO ext state (file deletion is not REAPER-undoable).
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// bank_ops.cpp — the promptless bank-verb seam (Q-W6 lift; see bank_ops.h for the
|
||||
// contract). The ONE implementation home of the bank verbs (create / rename /
|
||||
// delete / evacuate / activate / move / copy / remove): each mutates the given
|
||||
// session's book() then persists via persistBankOp() (one bank op = one Ctrl-Z; a
|
||||
// true index no-op opens NO undo point). It DOES mutate the bank BOOK — but only
|
||||
// the index/model + ext-state, never the arrange, never a sample file on disk
|
||||
// (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank).
|
||||
// REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL mutation any
|
||||
// Bank*/BankModel& is invalid — verbs take ids and resolve fresh per model call.
|
||||
// bank_ops.cpp — see bank_ops.h for the contract. The ONE implementation home of the
|
||||
// bank verbs: each mutates the given session's book() then persists via
|
||||
// persistBankOp() (one bank op = one Ctrl-Z; a true index no-op opens NO undo
|
||||
// point). Index/model + ext-state only — never the arrange, never a file on disk.
|
||||
// REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL mutation any Bank*/BankModel&
|
||||
// is invalid — verbs take ids and resolve fresh per model call.
|
||||
//
|
||||
// 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). DAW-verified, not unit tested.
|
||||
// main.cpp owns the API pointers; this TU gets them extern. DAW-verified, not unit tested.
|
||||
|
||||
#include "shell/bank_ops/bank_ops.h"
|
||||
|
||||
@@ -31,9 +26,7 @@ namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model
|
||||
// design: ids are caller-supplied and stable; the model stays pure and mints none).
|
||||
// Distinct from a track GUID by origin only — both are canonical guidToString output.
|
||||
// Ids are caller-supplied and stable; the model stays pure and mints none.
|
||||
std::string mintBankId() {
|
||||
GUID g{};
|
||||
genGuid(&g);
|
||||
@@ -44,35 +37,20 @@ std::string mintBankId() {
|
||||
|
||||
} // namespace
|
||||
|
||||
// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) —
|
||||
// one bank op = one Ctrl-Z.
|
||||
// WHY THIS WRAPS: a bank verb mutates ONLY our project ext-state, which REAPER's
|
||||
// undo system captures iff UNDO_STATE_MISCCFG is set (the SDK documents MISCCFG as
|
||||
// covering extensions' project ext-state). We pass exactly UNDO_STATE_MISCCFG, not
|
||||
// -1/UNDO_STATE_ALL — a bank verb touches no tracks/FX/items, so snapshotting them
|
||||
// would be both heavier and wrong. Persist runs INSIDE the block so the post-mutation
|
||||
// ext-state is the block's "after" image.
|
||||
//
|
||||
// WHY THIS WRAPS AND saveToActiveProject() DOES NOT: a bank verb mutates ONLY our
|
||||
// project ext-state (SetProjExtState under "reasampler"), which REAPER's undo system
|
||||
// captures iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK
|
||||
// documents MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h
|
||||
// ~1544, ~1199). We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the
|
||||
// item-move family does): a bank verb touches no tracks, FX, items, or envelopes, so
|
||||
// snapshotting them would be both heavier and semantically wrong. The persist runs
|
||||
// INSIDE the block so the post-mutation ext-state is the block's "after" image.
|
||||
//
|
||||
// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project saveToActiveProject()
|
||||
// no-ops (nothing is written to ext state). We must still CLOSE the block we opened,
|
||||
// but with an EMPTY label and a zero flag so REAPER DISCARDS the point instead of
|
||||
// recording a no-effect undo entry — mirroring view.cpp's empty-plan close. The
|
||||
// in-session model change stands and persists on the user's next save; it just earns
|
||||
// no undo point until there is a project to persist into (undo of an unsaved bank op
|
||||
// has nothing to roll back to anyway). The Begin/End must still be balanced, hence
|
||||
// the close-either-way. (Quiet persist by design — mirrors the CAPTURE path, NOT the
|
||||
// Design-View path; deliberately NO Save-As prompt.)
|
||||
// UNSAVED-PROJECT GUARDRAIL: on an unsaved/no-active project saveToActiveProject()
|
||||
// no-ops; we still CLOSE the block, but with an empty label + zero flag so REAPER
|
||||
// discards the point instead of recording a no-effect undo entry. Quiet persist by
|
||||
// design (mirrors capture, not Design-View) — deliberately no Save-As prompt.
|
||||
void persistBankOp(ReaSamplerSession& session, const char* label,
|
||||
bool bumpGeneration) {
|
||||
Undo_BeginBlock2(nullptr);
|
||||
// S9: bump the bank-generation counter INSIDE the block, before the persist, so the
|
||||
// fresh generation rides the same ext-state write (saveToActiveProject() stamps
|
||||
// bankGeneration()). Bumped only for content-changing verbs (the caller decides); a
|
||||
// pure-organizational verb passes false and leaves the counter be, so a
|
||||
// rename/activate does not needlessly refresh live instances.
|
||||
if (bumpGeneration) session.bumpBankGeneration();
|
||||
const bool persisted = session.saveToActiveProject();
|
||||
if (persisted)
|
||||
@@ -109,7 +87,6 @@ bool bankOpDelete(ReaSamplerSession& session, const std::string& bankId,
|
||||
|
||||
bool bankOpEvacuate(ReaSamplerSession& session, const std::string& bankId) {
|
||||
if (!session.book().evacuate(bankId)) return false; // pool is a destination, not a source
|
||||
// S9: evacuate moves members between banks (bank membership changes) -> bump.
|
||||
persistBankOp(session, "ReaSampler: evacuate bank", /*bumpGeneration=*/true);
|
||||
return true;
|
||||
}
|
||||
@@ -120,14 +97,10 @@ bool bankOpActivate(ReaSamplerSession& session, const std::string& bankId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// NO-OP GUARDRAIL — VERB-AWARE (a collapse means different things per verb):
|
||||
// * MOVE collapse: the source entry WAS removed (bank_book moveSample removes
|
||||
// unconditionally before the dest add collapses on hash), so the index DID
|
||||
// mutate — it counts toward opening an undo point.
|
||||
// * COPY collapse: the source is left intact AND the dest already held the hash,
|
||||
// so NOTHING changed — a true index no-op. It must NOT open an undo point.
|
||||
// Hence: copy counts only real gains; move counts gains OR collapses. Ids pass
|
||||
// straight to the model op — no BankModel& cached across the loop's mutations.
|
||||
// NO-OP GUARDRAIL, verb-aware: a MOVE collapse still removed the source entry (the
|
||||
// index DID mutate), but a COPY collapse left the source intact AND the dest already
|
||||
// held the hash (a true no-op) — so copy counts only real gains, move counts gains
|
||||
// OR collapses.
|
||||
bool bankOpTransfer(ReaSamplerSession& session,
|
||||
const std::vector<std::string>& sampleIds,
|
||||
const std::string& srcBankId, const std::string& destBankId,
|
||||
@@ -151,18 +124,14 @@ bool bankOpTransfer(ReaSamplerSession& session,
|
||||
}
|
||||
const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0);
|
||||
if (!mutated) return false; // nothing changed — no persist, no undo point
|
||||
// S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an
|
||||
// instance may reference) -> bump so assigned instances refresh hands-free.
|
||||
persistBankOp(session,
|
||||
copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)",
|
||||
/*bumpGeneration=*/true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Index-only, this-bank scope (fork R-A: the sole surfaced verb; RemoveScope::AllBanks
|
||||
// stays latent in the model). Non-destructive to the file: a last-reference remove
|
||||
// leaves the file on disk, orphaned until Phase R prune — remove NEVER deletes bytes
|
||||
// (the manifest is untouched). Silent: recoverability is the batched undo (R-B).
|
||||
// Index-only, this-bank scope; non-destructive to the file (a last-reference remove
|
||||
// leaves the file orphaned until prune). Silent: recoverability is the batched undo.
|
||||
bool bankOpRemove(ReaSamplerSession& session,
|
||||
const std::vector<std::string>& sampleIds,
|
||||
const std::string& srcBankId) {
|
||||
@@ -174,8 +143,6 @@ bool bankOpRemove(ReaSamplerSession& session,
|
||||
RemoveResult::Removed)
|
||||
++removed;
|
||||
if (removed == 0) return false; // every id already absent — no undo point
|
||||
// S9: a remove drops a sample from a bank (an instance referencing it must refresh —
|
||||
// it will resolve to silence, per the stale-id policy) -> bump.
|
||||
persistBankOp(session, "ReaSampler: remove sample(s)", /*bumpGeneration=*/true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
#pragma once
|
||||
// bank_ops — the promptless bank-verb seam (Q-W6 lift of the Q-W4 single-owner
|
||||
// verbs out of shell/panel/panel_bank_ops into a NON-UI home). Each verb is a model
|
||||
// op on the given session's BankBook + persistBankOp (undo-batched ext-state
|
||||
// persist) — NO prompts, NO message boxes, NO panel-state nudges, NO panel-global
|
||||
// reads. The two UX surfaces consume these as thin skins:
|
||||
// bank_ops — the promptless bank-verb seam. Each verb is a model op on the given
|
||||
// session's BankBook + persistBankOp (undo-batched ext-state persist) — NO prompts,
|
||||
// NO message boxes, NO panel-state nudges. Two UX surfaces consume these as thin
|
||||
// skins: shell/panel/panel_bank_ops (menu prompts/confirms/repaints) and
|
||||
// shell/actions/bank_actions (bindable family, text prompts/console feedback).
|
||||
//
|
||||
// * shell/panel/panel_bank_ops — the panel's menu handlers (prompts / confirms /
|
||||
// repaints), passing the panel's live session.
|
||||
// * shell/actions/bank_actions — the bindable family (text prompts / console
|
||||
// feedback), passing its registered session.
|
||||
//
|
||||
// The session arrives BY REFERENCE: there is exactly one session pointer question
|
||||
// per call site (the caller's), so a missing session can never be half-reported as
|
||||
// a model rejection from in here (the Q-W4 review's fail-safe-collapse concern).
|
||||
// Every verb returns whether the model accepted the mutation — a rejected op
|
||||
// persists nothing and opens no undo point.
|
||||
//
|
||||
// REAPER-facing (persist + undo blocks + GUID minting) but SDK-free in this header.
|
||||
// The session arrives BY REFERENCE, so a missing session can never be
|
||||
// half-reported as a model rejection from in here. Every verb returns whether the
|
||||
// model accepted the mutation — a rejected op persists nothing and opens no undo
|
||||
// point. REAPER-facing (persist + undo blocks + GUID minting) but SDK-free header.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -25,58 +17,47 @@ namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// Mints a stable GUID bank id, creates `name` in the book. Returns the new bank id,
|
||||
// or "" when the model rejects the name (duplicate, trimmed + case-insensitive).
|
||||
// Create is purely organizational — no generation bump.
|
||||
// Returns "" when the model rejects the name (duplicate, trimmed + case-insensitive).
|
||||
// Purely organizational — no generation bump.
|
||||
std::string bankOpCreate(ReaSamplerSession& session, const std::string& name);
|
||||
|
||||
// Renames `bankId`. False when the model rejects (pool un-renamable / name in use).
|
||||
// False when the model rejects (pool un-renamable / name in use).
|
||||
bool bankOpRename(ReaSamplerSession& session, const std::string& bankId,
|
||||
const std::string& newName);
|
||||
|
||||
// Deletes `bankId`. False when the model rejects (pool un-deletable). The caller
|
||||
// passes `bumpGeneration` from the member count it read BEFORE any evacuate/delete
|
||||
// (an evacuate-then-delete flow must still bump on the ORIGINAL membership).
|
||||
// False when the model rejects (pool un-deletable). `bumpGeneration` should be the
|
||||
// member count read BEFORE any evacuate/delete (an evacuate-then-delete flow must
|
||||
// still bump on the ORIGINAL membership).
|
||||
bool bankOpDelete(ReaSamplerSession& session, const std::string& bankId,
|
||||
bool bumpGeneration);
|
||||
|
||||
// Evacuates `bankId`'s members to the pool. False when the model rejects (the pool
|
||||
// itself). Bumps the generation (membership changed).
|
||||
// False when the model rejects (the pool itself). Bumps the generation.
|
||||
bool bankOpEvacuate(ReaSamplerSession& session, const std::string& bankId);
|
||||
|
||||
// Activates `bankId` as the capture target. False on an unknown id. No bump.
|
||||
// False on an unknown id. No bump.
|
||||
bool bankOpActivate(ReaSamplerSession& session, const std::string& bankId);
|
||||
|
||||
// Moves (copy=false) or copies (copy=true) `sampleIds` from `srcBankId` to
|
||||
// `destBankId` (index-only; files never relocate). Returns whether the index
|
||||
// actually mutated — the verb-aware no-op guardrail: a COPY collapse changes
|
||||
// nothing (no undo point); a MOVE collapse did remove the source entry (counts).
|
||||
// Persists ONE undo point ("move/copy sample(s)") only when mutated.
|
||||
// Index-only; files never relocate. Returns whether the index actually mutated — a
|
||||
// COPY collapse changes nothing (no undo point), a MOVE collapse did remove the
|
||||
// source entry (counts). Persists one undo point only when mutated.
|
||||
bool bankOpTransfer(ReaSamplerSession& session,
|
||||
const std::vector<std::string>& sampleIds,
|
||||
const std::string& srcBankId, const std::string& destBankId,
|
||||
bool copy);
|
||||
|
||||
// Removes `sampleIds` from `srcBankId` (index-only, this-bank scope; never deletes
|
||||
// bytes). Returns whether anything was removed; persists one undo point when so.
|
||||
// Index-only, this-bank scope; never deletes bytes. Persists one undo point when
|
||||
// anything was removed.
|
||||
bool bankOpRemove(ReaSamplerSession& session,
|
||||
const std::vector<std::string>& sampleIds,
|
||||
const std::string& srcBankId);
|
||||
|
||||
// Persists a completed bank-index verb as a single REAPER undo point (R-B).
|
||||
// Wraps the session persist (SetProjExtState) in a Begin/End block with
|
||||
// UNDO_STATE_MISCCFG so the bank op is one Ctrl-Z. On an unsaved / no-active project
|
||||
// the persist no-ops and the block is closed with an empty label + zero flag (REAPER
|
||||
// discards it). Callers must invoke this ONLY after a successful/effective mutation —
|
||||
// rejected ops (duplicate name, un-deletable pool, etc.) must return before reaching
|
||||
// here so no empty undo point is ever opened for a no-op.
|
||||
// Wraps the session persist in a Begin/End undo block (UNDO_STATE_MISCCFG) so the
|
||||
// bank op is one Ctrl-Z; on an unsaved/no-active project the block closes empty
|
||||
// (REAPER discards it). Call ONLY after an effective mutation.
|
||||
//
|
||||
// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a
|
||||
// live instance would PLAY — move / copy / remove / evacuate / delete-with-members. Leave
|
||||
// it false (the default) for a PURELY ORGANIZATIONAL verb — create / rename / activate /
|
||||
// reorder. The bump (when requested) happens INSIDE the block, BEFORE the persist, so
|
||||
// the stamped counter rides the same ext-state write and undo captures the pre/post
|
||||
// generation with the rest of the blob.
|
||||
// `bumpGeneration = true` for a verb that changes what a live instance would PLAY;
|
||||
// leave false for a purely organizational verb. The bump happens INSIDE the block,
|
||||
// before the persist, so the stamped counter rides the same ext-state write.
|
||||
void persistBankOp(ReaSamplerSession& session, const char* label,
|
||||
bool bumpGeneration = false);
|
||||
|
||||
|
||||
+109
-261
@@ -1,38 +1,22 @@
|
||||
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend) plus
|
||||
// the shared backend helpers (makeUniqueTag / stampCaptureSample — Q-W3 riders).
|
||||
// REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared
|
||||
// backend helpers (makeUniqueTag / stampCaptureSample).
|
||||
//
|
||||
// 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).
|
||||
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
|
||||
// the one TU that defines the API pointers; here they are extern.
|
||||
//
|
||||
// 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.
|
||||
// Drives the RENDER_* project settings via GetSetProjectInfo/_String (source-
|
||||
// selection bits come from the pure render_settings mapping), snapshots and
|
||||
// restores every setting it changes, triggers a render, then populates a Sample.
|
||||
// Source-agnostic: never reads the DAW selection itself, only the CaptureRequest
|
||||
// the caller resolved. RENDER_ADDTOPROJ&1 is cleared on every path — never
|
||||
// inserts into the arrange.
|
||||
//
|
||||
// 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.
|
||||
// RENDER PROGRESS WINDOW: triggering kActionRenderUsingMostRecentSettings (42230)
|
||||
// shows REAPER's offline-render progress dialog for the render's duration; no
|
||||
// RENDER_SETTINGS bit or GetSetProjectInfo desc suppresses it — inherent to
|
||||
// REAPER's offline render path. The dialog-free alternative is the realtime-
|
||||
// record backend, which captures the master bus to a temp track during playback
|
||||
// and never invokes the offline render pipeline.
|
||||
|
||||
#include "shell/capture/capture.h"
|
||||
|
||||
@@ -64,72 +48,50 @@ namespace reasampler::capture {
|
||||
|
||||
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.
|
||||
// The no-dialog render is the built-in action "File: Render project, using the
|
||||
// most recent render settings" — command id 42230. Stock main action id, not in
|
||||
// reaper_plugin_functions.h, confirmed against a running REAPER. Renders
|
||||
// headlessly using whatever RENDER_* settings are currently on the project —
|
||||
// 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.
|
||||
// RENDER_BOUNDSFLAG 0 = custom time bounds (we set STARTPOS/ENDPOS ourselves
|
||||
// for exact, unrounded bounds). SDK header ~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_TAILFLAG/TAILMS/NORMALIZE/TRIMEND are driven from the pure
|
||||
// tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block 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.
|
||||
// RENDER_DITHER &16 = disable all dither/noise-shaping (SDK header ~3050).
|
||||
// Float32 doesn't need dither, but an enabled project dither setting would
|
||||
// otherwise apply and break bit-identical repeats. Force off.
|
||||
constexpr double kDitherDisableAll = 16.0;
|
||||
|
||||
// --- WAV render sink configuration ------------------------------------------
|
||||
// 32-bit IEEE float: lossless, needs no dither, so identical inputs render
|
||||
// bit-identically and a dry capture nulls exactly against its source. 16/24-bit
|
||||
// int paths need dither for correctness, which is nondeterministic.
|
||||
//
|
||||
// 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.
|
||||
// GetSetProjectInfo_String("RENDER_FORMAT", ...) takes the BASE64-ENCODED sink
|
||||
// config, not raw bytes (SDK header ~3114) — raw bytes are silently rejected and
|
||||
// REAPER falls back to its project default format.
|
||||
//
|
||||
// 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).
|
||||
// Ground truth captured from a live REAPER set to WAV/32-bit float. Decodes to
|
||||
// 7 bytes: "evaw" (WAV fourcc, LE) + 0x20 (float bit-depth) + 0x00 0x00 (flags).
|
||||
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.
|
||||
// Int16/Int24 blobs aren't implemented — no live-captured ground truth exists;
|
||||
// do not guess the encoding. 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
|
||||
case WavBitDepth::Int16: return nullptr; // capture ground-truth blob first
|
||||
case WavBitDepth::Int24: return nullptr; // 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.
|
||||
// RENDER_* settings are project-GLOBAL; snapshot every value we touch and
|
||||
// restore on the way out via RAII so early returns can't leak a half-restored state.
|
||||
struct RenderSettingsSnapshot {
|
||||
ReaProject* proj = nullptr;
|
||||
|
||||
@@ -191,8 +153,6 @@ void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) {
|
||||
|
||||
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);
|
||||
@@ -221,30 +181,16 @@ struct ScopedRenderSettings {
|
||||
ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete;
|
||||
};
|
||||
|
||||
// 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
|
||||
|
||||
// --- Shared backend helpers (Q-W3 riders — see capture.h) --------------------
|
||||
|
||||
std::string makeUniqueTag(const std::string& prefix) {
|
||||
// Timestamp + PER-SESSION MONOTONIC counter (T1-11 fix). The timestamp alone
|
||||
// had one-second resolution: two captures of the same baseName within the same
|
||||
// wall-clock second derived the same file stem, so the second render silently
|
||||
// overwrote the first file and minted two Samples with colliding ids —
|
||||
// reachable in practice via batch capture. The counter (shared across both
|
||||
// backends — this is the one definition both call) makes every tag of a
|
||||
// session distinct regardless of timing. 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.
|
||||
// RESIDUAL (Q-W3 review follow-up): the counter is per-process, starting over
|
||||
// at 0 on every REAPER launch/extension reload, so two separate REAPER
|
||||
// instances (or a reload mid-session) can still mint the same timestamp+counter
|
||||
// pair in the same wall-clock second — a same-second cross-process collision
|
||||
// remains theoretically possible. Scoped to per-session deliberately: this fix
|
||||
// targets the reachable-in-practice single-process batch-capture case above.
|
||||
// Timestamp + per-session monotonic counter: the timestamp alone has one-second
|
||||
// resolution, so two captures of the same baseName within a second (batch
|
||||
// capture) collided on file stem and Sample id. This varies the file NAME, not
|
||||
// the audio bytes — bit-identical-repeat is about identical content per request.
|
||||
// Residual: the counter resets per-process, so a same-second collision across
|
||||
// two REAPER instances (or a mid-session reload) remains theoretically possible;
|
||||
// scoped deliberately to the reachable single-process case.
|
||||
static std::atomic<unsigned long long> counter{0};
|
||||
const std::time_t now = std::time(nullptr);
|
||||
return prefix + std::to_string(static_cast<long long>(now)) + "-" +
|
||||
@@ -259,26 +205,19 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||
s.trackGuids = req.trackGuids;
|
||||
s.channelCount = req.channelCount;
|
||||
|
||||
// Resolved sample rate: the request's pinned rate, else PROJECT_SRATE read
|
||||
// from the caller's project handle. PROJECT_SRATE can read 0 on a project that
|
||||
// never explicitly pinned a rate — the value stays 0 (the Sample zero-value)
|
||||
// rather than a bogus literal (the honest "unknown" both backends shared).
|
||||
// PROJECT_SRATE can read 0 on a project that never pinned a rate — stays 0
|
||||
// (honest "unknown") rather than a bogus literal.
|
||||
s.sampleRate = (req.sampleRate > 0)
|
||||
? req.sampleRate
|
||||
: static_cast<int>(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false));
|
||||
|
||||
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
|
||||
s.captureTempo = Master_GetTempo(); // BPM at capture time
|
||||
|
||||
// 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. `timeSigProj` is the CALLER's project
|
||||
// pin — offline passes nullptr (the active project); realtime pins the record's
|
||||
// own project (the T2-09 divergence, kept caller-visible as this argument).
|
||||
// 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.
|
||||
// Time signature effective at the capture's START time, so a sample captured
|
||||
// under 3/4 keeps a 3/4 read-out even if the project later switches to 4/4.
|
||||
// `timeSigProj` is the caller's project pin — offline passes nullptr (active
|
||||
// project); realtime pins the record's own project. tempoOut is ignored —
|
||||
// captureTempo already carries it.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
@@ -287,14 +226,10 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||
s.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
|
||||
// Content hash: WAV-aware FNV-1a over the finished 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/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 in dedup).
|
||||
// hashWavContent (not raw hashBytes) skips render-varying metadata chunks
|
||||
// (bext timestamp, iXML, LIST/INFO) so identical audio from two renders/records
|
||||
// collapses to the same hash, letting dedup find copies across banks.
|
||||
// Unreadable file leaves contentHash empty (bank_model treats "" as non-dedup).
|
||||
{
|
||||
const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
|
||||
if (!fileBytes.empty()) {
|
||||
@@ -308,16 +243,14 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||
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.
|
||||
// SourceMode::Realtime is refused here — that's the realtime backend's job —
|
||||
// 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).";
|
||||
"(realtime capture is the realtime backend).";
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -328,8 +261,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Current project (idx -1 == the active project tab). Verified: SDK header
|
||||
// line ~1264, EnumProjects(int idx, char*, int).
|
||||
// idx -1 == the active project tab.
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) {
|
||||
result.status = CaptureStatus::NoProject;
|
||||
@@ -337,131 +269,81 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Resolve the project directory from the .rpp file path.
|
||||
// Unsaved-project detection via EnumProjects(-1, buf, bufsz): the .rpp path
|
||||
// out-param is empty for a project that has never been saved — a reliable
|
||||
// unsaved sentinel. NOT GetProjectPathEx: that returns the recording path, not
|
||||
// the .rpp location, and is never empty even when unsaved (the original bug —
|
||||
// captures landed in REAPER's default media location instead of by the .rpp).
|
||||
//
|
||||
// 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.
|
||||
// Flow: read .rpp path; if empty, Main_SaveProject(proj, true) prompts
|
||||
// Save-As and blocks until dismissed; re-read; if still empty (cancelled),
|
||||
// refuse with NoProject and write nothing.
|
||||
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.
|
||||
// Project dir = parent of the .rpp; forward-slash-normalized 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
|
||||
// tag. Calling makeUniqueTag() twice would yield different values (the counter
|
||||
// advances per call — bug: id and filename diverge).
|
||||
// Compute the tag ONCE — calling makeUniqueTag() twice would let the file
|
||||
// stem and Sample.id diverge (the counter advances per call).
|
||||
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.
|
||||
// TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact
|
||||
// bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end
|
||||
// normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no
|
||||
// trim. NORMALIZE is driven here (not the determinism block below) so the
|
||||
// Auto surgical value isn't clobbered.
|
||||
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:
|
||||
// Source-selection bits for this mode (SDK header ~3041), all 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.
|
||||
// request 0 = "follow project"; PROJECT_SRATE is still readable via
|
||||
// GetSetProjectInfo even when PROJECT_SRATE_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.
|
||||
// Only pin RENDER_SRATE when known (>0) — a brand-new project can read 0 for
|
||||
// PROJECT_SRATE, and forcing RENDER_SRATE=0 would be a bogus literal; leave
|
||||
// it unset so REAPER follows its own project-rate default.
|
||||
if (effectiveSampleRate > 0) {
|
||||
GetSetProjectInfo(proj, "RENDER_SRATE",
|
||||
static_cast<double>(effectiveSampleRate), true);
|
||||
@@ -469,100 +351,67 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
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.
|
||||
// Load-bearing: never add the rendered file to the project as an item.
|
||||
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.
|
||||
// None/Manual -> disable-all (byte-identical to pre-tail); Auto -> surgical
|
||||
// trim-end (only &32768) + -72 dB TRIMEND — a fixed-threshold trailing-silence
|
||||
// trim scales/limits/fades nothing, so Auto stays deterministic. TRIMEND is
|
||||
// only consulted when the trim bit is set but written unconditionally for clarity.
|
||||
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.
|
||||
// format extension. paths.fileStem already owns the .wav suffix knowledge.
|
||||
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.
|
||||
// Int16/Int24 have no captured ground-truth 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+).";
|
||||
"(Float32 only; Int16/Int24 not yet supported).";
|
||||
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.
|
||||
// Main_OnCommand returns void, so a failed render is silent — stat the
|
||||
// expected output path to detect it.
|
||||
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.
|
||||
// Record the request's own bounds (exact) rather than re-measuring the file.
|
||||
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.
|
||||
// Same uniqueTag that named the file — calling makeUniqueTag() again could
|
||||
// yield a different value and desync Sample.id from 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.
|
||||
// startPpq/endPpq/lengthBeats left at 0 — PPQ mapping is a placement-time
|
||||
// concern; seconds are the authoritative source for the render and we don't
|
||||
// re-derive one bound from the other.
|
||||
s.wetDry = request.wetDry;
|
||||
s.lengthSeconds = request.endSeconds - request.startSeconds;
|
||||
s.tier = model::Tier::Scratch; // captures land in scratch by default
|
||||
// The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount
|
||||
// (request echo), resolved sampleRate (request rate else PROJECT_SRATE(proj) —
|
||||
// 0 stays 0 when the project never pinned a rate; we did not force RENDER_SRATE
|
||||
// either, so the render ran at REAPER's default), captureTempo, the capture-
|
||||
// start time signature (timeSigProj = nullptr => the active project — matching
|
||||
// the Master_GetTempo read, which is also active-project), the WAV-aware
|
||||
// contentHash of the rendered file, and createdTimestamp.
|
||||
s.tier = model::Tier::Scratch;
|
||||
stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath);
|
||||
// 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".
|
||||
// rootNote/loop left empty — a master/track/time-selection render isn't a
|
||||
// single played note, so no root note is derivable; loop points are set
|
||||
// later by an explicit user action.
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = s;
|
||||
@@ -571,7 +420,6 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
std::to_string(request.endSeconds) + "s] -> " +
|
||||
paths.relativePath;
|
||||
return result;
|
||||
// guard's dtor restores every RENDER_* setting here.
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
|
||||
+45
-90
@@ -1,36 +1,18 @@
|
||||
#pragma once
|
||||
// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split).
|
||||
// The shared capture seam: CaptureRequest/CaptureResult (types both backends
|
||||
// speak), OfflineRenderBackend, and the makeUniqueTag/stampCaptureSample helpers.
|
||||
// Realtime's async begin/tick/abort surface lives in capture_realtime_shell.h.
|
||||
//
|
||||
// This header declares the SHARED capture seam (Q-W6 split of the former fat
|
||||
// header — the realtime backend's async begin/tick/abort surface now lives in
|
||||
// capture_realtime_shell.h):
|
||||
// * CaptureRequest / CaptureResult — everything a capture needs and yields,
|
||||
// source-mode-agnostic; the types BOTH backends speak.
|
||||
// * OfflineRenderBackend — the deterministic default; a plain CONCRETE class
|
||||
// (the former ICaptureBackend interface was deleted in
|
||||
// Q-W3, T4-26 — it had one deriver and zero polymorphic
|
||||
// call sites; every construction site instantiates the
|
||||
// concrete type).
|
||||
// * makeUniqueTag / stampCaptureSample — the shared file-tag mint and the shared
|
||||
// finished-capture metadata stamp both backends call
|
||||
// (Q-W3 riders T1-11 / T2-09).
|
||||
//
|
||||
// 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 (the capture orchestration TUs) depend on the seam
|
||||
// without dragging the SDK into every include site.
|
||||
// REAPER-free on purpose (bank_model only) so callers can depend on the seam
|
||||
// without dragging the SDK into every include site; the .cpp is the REAPER TU.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract
|
||||
#include "core/capture/render_settings.h" // TailMode — the three-state tail contract
|
||||
|
||||
// MediaTrack / ReaProject are forward-declared (like track_guid.h) so this header
|
||||
// stays REAPER-free while RealtimeRecordBackend::begin can take the resolved source
|
||||
// MediaTrack* to tap and stampCaptureSample can take the project handles its reads
|
||||
// pin. The pointers are opaque here — never dereferenced in a pure/header context;
|
||||
// only the REAPER-facing capture TUs touch them.
|
||||
// Forward-declared, never dereferenced here — only the REAPER-facing .cpp touches these.
|
||||
class MediaTrack;
|
||||
class ReaProject;
|
||||
|
||||
@@ -38,71 +20,55 @@ namespace reasampler::capture {
|
||||
|
||||
using model::Sample;
|
||||
|
||||
// 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.
|
||||
// 32-bit float is the 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).
|
||||
// One capture, independent of source mode.
|
||||
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.
|
||||
// Sample-accurate render bounds in project seconds — 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.
|
||||
// 1.0 = fully wet, 0.0 = fully dry. Every current capture action sets 1.0;
|
||||
// true pre-FX dry isn't available via RENDER_SETTINGS (needs FX-bypass-around-render
|
||||
// or the realtime pre-FX path) so this stays a seam for that future work.
|
||||
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).
|
||||
// Track GUID(s) when source mode is track-scoped (SelectedTracks); empty otherwise.
|
||||
// The backend only copies these onto the Sample — it never reads selection itself.
|
||||
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.
|
||||
// Render tail (docs/product/capture-tail.md §The three tail states). None = exact
|
||||
// bounds, no added silence — the only mode valid for null-test/verify captures.
|
||||
// tailMs applies only to Manual (clamped to 8s by the pure mapping); Auto uses
|
||||
// the 8s 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).
|
||||
// 0 sampleRate => follow project rate.
|
||||
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.
|
||||
// Sanitized by capture_paths. uniqueTag (disambiguator) is supplied 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
|
||||
std::string uniqueTag;
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Every failure is an explicit code, never a thrown exception across the REAPER boundary.
|
||||
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)
|
||||
UnsupportedMode, // backend does not implement this source mode
|
||||
UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only)
|
||||
RenderFailed, // the render action ran but produced no output file
|
||||
TransportBusy, // realtime backend: transport already playing/recording — refused
|
||||
};
|
||||
@@ -113,44 +79,33 @@ struct CaptureResult {
|
||||
std::string message; // human-readable detail for the console log
|
||||
};
|
||||
|
||||
// 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).
|
||||
// Non-destructive: restores every RENDER_* setting it touches on every path.
|
||||
// A plain concrete class — the former ICaptureBackend interface was deleted
|
||||
// (Q-W3, T4-26): it had one deriver, zero polymorphic call sites, and the async
|
||||
// realtime backend deliberately never implemented it (see SEAM CHOICE below).
|
||||
// Deterministic offline-render backend: master mix / time selection / selected
|
||||
// tracks / selected items / razor area, all wet-only, optional tail. Source
|
||||
// selection + range are resolved by the caller and handed in via CaptureRequest —
|
||||
// the backend drives RENDER_* and never reads the DAW selection itself.
|
||||
// SourceMode::Realtime returns UnsupportedMode. Non-destructive: restores every
|
||||
// RENDER_* setting it touches on every path. Plain concrete class — see the
|
||||
// no-shared-interface note in capture_realtime_shell.h before adding one back.
|
||||
class OfflineRenderBackend {
|
||||
public:
|
||||
CaptureResult capture(const CaptureRequest& request);
|
||||
};
|
||||
|
||||
// --- Shared backend helpers (Q-W3 riders) ------------------------------------
|
||||
|
||||
// Mints the filesystem-safe disambiguating tag for one capture's file stem +
|
||||
// Sample id: "<prefix><unix-epoch-seconds>-<n>" where <n> is a PER-SESSION
|
||||
// MONOTONIC counter (T1-11 fix). The wall-clock second alone had a collision
|
||||
// window: two captures of the same baseName within one second derived the same
|
||||
// stem, so the second render silently overwrote the first file (reachable via
|
||||
// batch capture driving short renders back-to-back). The counter makes every tag
|
||||
// of a session distinct regardless of timing. `prefix` is the backend's family
|
||||
// marker ("" offline, "rt-" realtime).
|
||||
// Sample id: "<prefix><unix-epoch-seconds>-<n>", <n> a per-session monotonic
|
||||
// counter. Wall-clock seconds alone collide when batch capture drives short
|
||||
// renders back-to-back, silently overwriting the first file. `prefix` is the
|
||||
// backend's family marker ("" offline, "rt-" realtime).
|
||||
std::string makeUniqueTag(const std::string& prefix);
|
||||
|
||||
// Stamps the SHARED finished-capture metadata onto `s` (T2-09 dedupe — this stamp
|
||||
// was copy-pasted per backend and had silently diverged): trackGuids +
|
||||
// channelCount (echoed from the request), the resolved sampleRate (request rate,
|
||||
// else PROJECT_SRATE read from `rateProj`; 0 stays 0 when unknown), captureTempo
|
||||
// (Master_GetTempo), the capture-start time signature (TimeMap_GetTimeSigAtTime
|
||||
// against `timeSigProj` — the offline path passes nullptr = active project, the
|
||||
// realtime path pins the record's own project; the divergence stays caller-visible
|
||||
// as this argument), the WAV-aware contentHash of the finished file at
|
||||
// `absolutePath` (left empty when unreadable — the safe, confirm-eliciting
|
||||
// direction), and createdTimestamp (now). The per-backend bits (id, paths, bounds,
|
||||
// tier, realtime's recorded-length override) stay with each caller.
|
||||
// Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount
|
||||
// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE
|
||||
// from `rateProj`), captureTempo, the capture-start time signature
|
||||
// (TimeMap_GetTimeSigAtTime against `timeSigProj` — offline passes nullptr for the
|
||||
// active project, realtime pins the record's own project), the WAV-aware
|
||||
// contentHash of `absolutePath` (left empty when unreadable), and createdTimestamp.
|
||||
// Per-backend bits (id, paths, bounds, tier, realtime's length override) stay
|
||||
// with each caller.
|
||||
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||
ReaProject* rateProj, ReaProject* timeSigProj,
|
||||
const std::string& absolutePath);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// capture_batch.cpp — the M11 batch-capture family + the M10 re-capture-from-source
|
||||
// action (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded
|
||||
// as a parameter). See the header.
|
||||
// capture_batch.cpp — the batch-capture family + re-capture-from-source. See the
|
||||
// header.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// 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).
|
||||
// pointers; here they are extern.
|
||||
|
||||
#include "shell/capture/capture_batch.h"
|
||||
|
||||
@@ -50,28 +49,21 @@
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// --- M11: batch capture (per selected item / per razor area) ----------------
|
||||
// One action fires N captures — one sample per selected item (item scope) or per
|
||||
// razor area (track scope, each area's own range). Each unit routes through
|
||||
// captureAndIndexOne so every precision invariant holds; nothing lands in the
|
||||
// arrange (load-bearing principle).
|
||||
//
|
||||
// One action fires N captures — one bank sample per selected item (item scope) or per
|
||||
// razor area (track scope, each area's own range). Each individual capture honors every
|
||||
// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan
|
||||
// neutralize, relative paths, channel preservation) and M10 provenance stamping applies
|
||||
// per capture where its detection rule matches. The load-bearing principle holds: each
|
||||
// unit writes a file + a bank index entry ONLY; nothing lands in the arrange.
|
||||
//
|
||||
// Per-unit FILE NAMING: each unit's baseName carries its ordinal ("item-1",
|
||||
// "item-2", ...) so two units are never asked to write the same stem within one
|
||||
// batch, and the shared makeUniqueTag now appends a per-session monotonic counter
|
||||
// (T1-11 fix) so even same-second units across batches cannot collide.
|
||||
// Each unit's baseName carries its ordinal ("item-1", "item-2", ...) so two units
|
||||
// in one batch never share a stem, and makeUniqueTag's per-session monotonic
|
||||
// counter keeps same-second units across batches from colliding too.
|
||||
|
||||
namespace {
|
||||
|
||||
// RAII snapshot/restore of the project's media-item selection. Batch item capture must
|
||||
// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is
|
||||
// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including
|
||||
// a mid-batch failure or early return — because selection restoration is part of the
|
||||
// non-destructive invariant. Snapshot on construct (the currently-selected item set),
|
||||
// restore on destruct (deselect everything, then re-select exactly the snapshot).
|
||||
// RAII snapshot/restore of the item selection. Batch item capture must transiently
|
||||
// select exactly one item per render (RENDER_SETTINGS &32 renders whatever is
|
||||
// selected); the original selection is restored on every exit path — including a
|
||||
// mid-batch failure — as part of the non-destructive invariant.
|
||||
class ItemSelectionGuard
|
||||
{
|
||||
public:
|
||||
@@ -85,9 +77,8 @@ public:
|
||||
|
||||
~ItemSelectionGuard()
|
||||
{
|
||||
// Deselect every item in the project, then re-select the snapshot — restoring the
|
||||
// exact original set regardless of what the batch selected in between. Iterate ALL
|
||||
// items (not just the currently-selected) so any transient selection is cleared.
|
||||
// Deselect everything first (not just currently-selected) so any transient
|
||||
// selection is cleared, then re-select exactly the snapshot.
|
||||
const int total = CountMediaItems(nullptr);
|
||||
for (int i = 0; i < total; ++i)
|
||||
if (MediaItem* it = GetMediaItem(nullptr, i))
|
||||
@@ -104,9 +95,8 @@ private:
|
||||
std::vector<MediaItem*> selected_;
|
||||
};
|
||||
|
||||
// Selects exactly `item` (deselect-all then select-one) so the offline render's
|
||||
// selected-items bit (&32) captures a single item. Used inside the batch loop under the
|
||||
// ItemSelectionGuard, which restores the user's original selection afterward.
|
||||
// Deselect-all then select-one so the offline render's &32 bit captures exactly
|
||||
// this item. Called inside ItemSelectionGuard, which restores the original selection.
|
||||
void selectOnlyItem(MediaItem* item)
|
||||
{
|
||||
const int total = CountMediaItems(nullptr);
|
||||
@@ -115,9 +105,8 @@ void selectOnlyItem(MediaItem* item)
|
||||
SetMediaItemSelected(it, it == item);
|
||||
}
|
||||
|
||||
// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving
|
||||
// track order then area order — the batch analog of resolveRazorRange, which unions them.
|
||||
// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser.
|
||||
// Collects every track's razor areas as (owning track, range) pairs, track order
|
||||
// then area order. Read-only — never clears the razor selection.
|
||||
std::vector<std::pair<MediaTrack*, RazorRange>> collectRazorAreas()
|
||||
{
|
||||
std::vector<std::pair<MediaTrack*, RazorRange>> areas;
|
||||
@@ -135,10 +124,9 @@ std::vector<std::pair<MediaTrack*, RazorRange>> collectRazorAreas()
|
||||
return areas;
|
||||
}
|
||||
|
||||
// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must
|
||||
// transiently select exactly the area's owning track per render (track scope's &128 bit
|
||||
// renders whatever TRACKS are selected); the user's original track selection is restored
|
||||
// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard.
|
||||
// Mirror of ItemSelectionGuard for track selection: batch razor capture transiently
|
||||
// selects the area's owning track per render (&128 renders selected tracks), restoring
|
||||
// the original selection on every exit path (non-destructive invariant).
|
||||
class TrackSelectionGuard
|
||||
{
|
||||
public:
|
||||
@@ -170,16 +158,13 @@ private:
|
||||
|
||||
} // namespace
|
||||
|
||||
// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the
|
||||
// selection (RAII restore on every path), then for each selected item transiently selects
|
||||
// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the
|
||||
// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for
|
||||
// the whole batch). Reports a mixed-result summary (explicit-action response — allowed).
|
||||
// One sample per selected item. Snapshots the selection (RAII-restored), transiently
|
||||
// selects each item in turn, renders its exact range under item-scope FX neutralize,
|
||||
// and persists once at the end for the whole batch.
|
||||
void RunBatchCaptureItems(ReaSamplerSession& session)
|
||||
{
|
||||
// Read the selected items up front (pointers stay valid — batch mutates only selection
|
||||
// flags, never adds/removes items). Also capture each item's exact bounds and owning
|
||||
// track NOW, while the full selection is live, before any transient re-selection.
|
||||
// Read bounds + owning track now, while the full selection is live and before any
|
||||
// transient re-selection (batch only mutates selection flags, never adds/removes items).
|
||||
struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; };
|
||||
std::vector<ItemUnit> itemUnits;
|
||||
{
|
||||
@@ -201,8 +186,8 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
|
||||
return;
|
||||
}
|
||||
|
||||
// Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/
|
||||
// inverted item ranges (a zero-length item) are dropped here so no stray render runs.
|
||||
// Plan exact ranges -> validated, ordinal-assigned units; zero-length items are
|
||||
// dropped here so no stray render runs.
|
||||
std::vector<BatchRange> ranges;
|
||||
ranges.reserve(itemUnits.size());
|
||||
for (const ItemUnit& u : itemUnits)
|
||||
@@ -212,19 +197,17 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
|
||||
BatchOutcome outcome;
|
||||
bool anyAdded = false;
|
||||
{
|
||||
// Restore the user's ORIGINAL item selection on every exit path (incl. early
|
||||
// return / mid-batch failure) — non-destructive invariant.
|
||||
// selGuard restores the original item selection on every exit path.
|
||||
ItemSelectionGuard selGuard;
|
||||
|
||||
// The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only
|
||||
// for those whose range survived planning (same drop rule), matching by ordinal.
|
||||
// plan and itemUnits are parallel over kept units; skip dropped ranges in lockstep.
|
||||
std::size_t planIdx = 0;
|
||||
for (const ItemUnit& u : itemUnits)
|
||||
{
|
||||
if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep
|
||||
const CaptureUnit& unit = plan[planIdx++];
|
||||
|
||||
// Transiently select ONLY this item so the item-scope render captures exactly it.
|
||||
// select only this item so the item-scope render captures exactly it.
|
||||
selectOnlyItem(u.item);
|
||||
|
||||
ResolvedSource src;
|
||||
@@ -245,9 +228,9 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
|
||||
}
|
||||
} // selGuard restores the original selection here, on every path
|
||||
|
||||
// Persist ONCE for the whole batch (one ext-state write) — only if something landed.
|
||||
// S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample,
|
||||
// so a single increment past the last-seen value is enough to trigger one instance reload.
|
||||
// One ext-state write for the whole batch, only if something landed. The generation
|
||||
// bump is monotonic, so one increment past the last-seen value triggers reload in
|
||||
// any listening instance.
|
||||
if (anyAdded) {
|
||||
session.bumpBankGeneration();
|
||||
session.saveToActiveProject();
|
||||
@@ -256,12 +239,10 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
|
||||
ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str());
|
||||
}
|
||||
|
||||
// Batch razor capture: one bank sample per razor AREA, track scope over that area's own
|
||||
// range (the area's owning track is the source track). Track scope renders the selected
|
||||
// TRACKS via master (&128), so each unit transiently selects ONLY its owning track
|
||||
// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original
|
||||
// track selection on every path. The razor selection itself is read-only and left intact.
|
||||
// Persists ONCE at the end. Reports a mixed-result summary.
|
||||
// One sample per razor area, track scope over that area's own range. Track scope
|
||||
// renders selected tracks via master (&128), so each unit selects only its owning
|
||||
// track under TrackSelectionGuard; the razor selection itself is read-only. Persists
|
||||
// once at the end.
|
||||
void RunBatchCaptureRazor(ReaSamplerSession& session)
|
||||
{
|
||||
const std::vector<std::pair<MediaTrack*, RazorRange>> areas = collectRazorAreas();
|
||||
@@ -280,7 +261,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
|
||||
BatchOutcome outcome;
|
||||
bool anyAdded = false;
|
||||
{
|
||||
// Restore the user's ORIGINAL track selection on every exit path.
|
||||
// selGuard restores the original track selection on every exit path.
|
||||
TrackSelectionGuard selGuard;
|
||||
|
||||
std::size_t planIdx = 0;
|
||||
@@ -290,8 +271,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
|
||||
const CaptureUnit& unit = plan[planIdx++];
|
||||
MediaTrack* tr = a.first;
|
||||
|
||||
// Transiently select ONLY this track so the track-scope render (&128) captures
|
||||
// exactly it via master (over the custom time bounds we set per unit).
|
||||
// select only this track so track-scope render (&128) captures it via master.
|
||||
SetOnlyTrackSelected(tr);
|
||||
|
||||
ResolvedSource src;
|
||||
@@ -312,7 +292,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
|
||||
}
|
||||
} // selGuard restores the original track selection here, on every path
|
||||
|
||||
// S9: one coalesced bump for the whole razor batch (see the item-batch note above).
|
||||
// One coalesced generation bump for the whole batch (see the item-batch note above).
|
||||
if (anyAdded) {
|
||||
session.bumpBankGeneration();
|
||||
session.saveToActiveProject();
|
||||
@@ -321,25 +301,15 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
|
||||
ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str());
|
||||
}
|
||||
|
||||
// --- M10: re-capture from source --------------------------------------------
|
||||
// Regenerates a provenanced sample's file from its recorded source's current state
|
||||
// and updates the bank Sample in place. Bank-only — never calls InsertMedia; the
|
||||
// user re-places manually if they want the new version on the timeline.
|
||||
// Non-destructive to the source (FxBypassGuard snapshot/restore via renderOffline).
|
||||
//
|
||||
// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT
|
||||
// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and
|
||||
// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the
|
||||
// load-bearing capture-never-places line, structurally visible: this function has no
|
||||
// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore
|
||||
// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places
|
||||
// manually if they want the new version on the timeline.
|
||||
//
|
||||
// Failure modes are handled explicitly and reported to the user (a direct response
|
||||
// to an explicit action is allowed by the console policy):
|
||||
// * the selected sample has no provenance (not a resample) -> reported, no-op.
|
||||
// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op.
|
||||
// * the recorded source track(s) no longer exist -> reported, no-op.
|
||||
// * the render itself fails to satisfy the recorded request -> reported, no-op.
|
||||
// On success, if the source FX chain drifted since capture (recorded vs current
|
||||
// identity differ) the user is told — the re-capture still reflects the source AS IT
|
||||
// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source).
|
||||
// Failure modes are explicit and reported, each a no-op: no provenance, unparseable
|
||||
// fingerprint, a missing recorded source track, or a failed render. On success, if
|
||||
// the source FX chain drifted since capture, the user is told — the re-capture still
|
||||
// reflects the source as it is now (drift is detected, not frozen against).
|
||||
void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
{
|
||||
const std::vector<std::string> selected = bankPanelSelectedSampleIds();
|
||||
@@ -371,8 +341,8 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the recorded capture recipe from the fingerprint. A legacy / corrupt
|
||||
// string fails gracefully — never a partial re-capture.
|
||||
// Parse the recorded recipe; legacy/corrupt fingerprints fail gracefully, never
|
||||
// a partial re-capture.
|
||||
const std::string recordedParentId = orig->provenance->parentSampleId;
|
||||
const std::string recordedFingerprint = orig->provenance->fxChainSnapshot;
|
||||
const std::optional<model::CaptureRecipe> recipe =
|
||||
@@ -384,8 +354,7 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the recorded source track GUID(s) to live tracks. Any missing track is a
|
||||
// hard failure — we will not silently re-capture a different source.
|
||||
// Missing recorded track = hard failure; never silently re-capture a different source.
|
||||
std::vector<MediaTrack*> sourceTracks;
|
||||
for (const std::string& g : recipe->trackGuids)
|
||||
{
|
||||
@@ -400,8 +369,7 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
}
|
||||
if (sourceTracks.empty())
|
||||
{
|
||||
// The recipe recorded no source tracks (e.g. an item-scope capture whose source
|
||||
// tracks were not track-scoped). Without a resolvable source we cannot re-run.
|
||||
// e.g. an item-scope capture with no track-scoped source — nothing to resolve.
|
||||
ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this "
|
||||
"sample; cannot re-capture from source.\n");
|
||||
return;
|
||||
@@ -411,10 +379,9 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item
|
||||
: CaptureScope::Track;
|
||||
|
||||
// Rebuild the capture request verbatim from the recorded recipe — the SAME request,
|
||||
// re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate,
|
||||
// channels, bit depth all match the original so an unchanged source produces a
|
||||
// byte-identical file (bit-identical-repeats invariant, consumed as a feature).
|
||||
// Rebuild the request verbatim from the recorded recipe, re-run against the
|
||||
// source's current state: an unchanged source reproduces a byte-identical file
|
||||
// (bit-identical-repeats invariant).
|
||||
CaptureRequest req;
|
||||
req.sourceMode = static_cast<SourceMode>(recipe->sourceMode);
|
||||
req.startSeconds = recipe->startSeconds;
|
||||
@@ -428,10 +395,9 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName;
|
||||
req.trackGuids = recipe->trackGuids;
|
||||
|
||||
// Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to
|
||||
// compare against the recorded identity for drift reporting. Mirror the same
|
||||
// scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*;
|
||||
// track scope reads the track FX chain via TrackFX_*.
|
||||
// Read the current FX-chain identity BEFORE the render bypasses it, for drift
|
||||
// comparison against the recorded identity (item scope via TakeFX_*, track scope
|
||||
// via TrackFX_*).
|
||||
std::string currentIdentity;
|
||||
if (scope == CaptureScope::Item) {
|
||||
const int n = CountSelectedMediaItems(nullptr);
|
||||
@@ -459,11 +425,10 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the Sample IN PLACE: keep its identity (id) and its provenance thread
|
||||
// (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but
|
||||
// adopt the regenerated file's path / hash / length / rate / timestamp. The
|
||||
// fingerprint is rebuilt from the recipe with the CURRENT FX identity so a
|
||||
// subsequent re-capture measures drift from this point, not the original.
|
||||
// Update in place: keep identity (id) + provenance parent, adopt the regenerated
|
||||
// file's path/hash/length/rate/timestamp, and rebuild the fingerprint with the
|
||||
// current FX identity so the next re-capture measures drift from here, not the
|
||||
// original.
|
||||
model::CaptureRecipe refreshed = *recipe;
|
||||
refreshed.fxChainIdentity = currentIdentity;
|
||||
|
||||
@@ -476,33 +441,29 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
updated.sampleRate = res.sample.sampleRate;
|
||||
updated.lengthSeconds = res.sample.lengthSeconds;
|
||||
updated.captureTempo = res.sample.captureTempo;
|
||||
updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp
|
||||
updated.captureTimeSigNum = res.sample.captureTimeSigNum; // refresh meter stamp
|
||||
updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter
|
||||
updated.trackGuids = res.sample.trackGuids;
|
||||
updated.createdTimestamp = res.sample.createdTimestamp;
|
||||
// NOTE: levels, clipped, and lengthBeats are carried from the original (via the
|
||||
// *orig copy above) because the offline backend does not populate them today
|
||||
// (res.sample leaves them at defaults). If a later milestone populates these
|
||||
// fields at capture time, refresh them here from res.sample instead.
|
||||
// levels/clipped/lengthBeats carry from *orig — the offline backend doesn't
|
||||
// populate them; refresh from res.sample here if that ever changes.
|
||||
model::Provenance prov;
|
||||
prov.parentSampleId = recordedParentId;
|
||||
prov.fxChainSnapshot = model::buildFingerprint(refreshed);
|
||||
updated.provenance = prov;
|
||||
|
||||
// Single batched undo point around the in-place bank mutation (mirrors the bank
|
||||
// action family's R-B pattern). The mutation is index-only ext-state; the render
|
||||
// wrote a new file but placed nothing on the timeline.
|
||||
// One batched undo point around the in-place mutation; index-only ext-state,
|
||||
// nothing placed on the timeline.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
const bool changed = session.book().updateSampleInPlace(sampleId, updated);
|
||||
if (changed)
|
||||
{
|
||||
// Record the regenerated file in the owned manifest (a new file the tool wrote);
|
||||
// the superseded old file becomes an orphan reclaimed by Phase R prune.
|
||||
// Record the regenerated file in the owned manifest; the superseded file
|
||||
// becomes an orphan for prune to reclaim.
|
||||
session.owned().add(updated.relativePath);
|
||||
// S9: re-capture-in-place regenerates the SAME id's audio — the exact case the
|
||||
// hands-free refresh exists for (an instance referencing this id keeps playing the
|
||||
// OLD audio until it reloads). Bump inside the undo block so undo rolls back the
|
||||
// generation with the rest of the blob.
|
||||
// Regenerating the same id's audio is exactly why instances need the generation
|
||||
// bump — they'd otherwise keep playing stale audio until reload. Bumped inside
|
||||
// the undo block so undo rolls back the generation with the rest of the blob.
|
||||
session.bumpBankGeneration();
|
||||
const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty
|
||||
Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
#pragma once
|
||||
// capture_batch — the batch-capture family + re-capture-from-source (Q-W3 hoist
|
||||
// out of main.cpp; the fourth hoist, T4-02 — recapture is planner-driven like
|
||||
// batch and shares the RAII selection-guard machinery, so it belongs here, not
|
||||
// with the single-shot path). Owns:
|
||||
// * RunBatchCaptureItems — one bank sample per SELECTED item (item scope), the
|
||||
// user's item selection snapshot/restored on every path (ItemSelectionGuard);
|
||||
// * RunBatchCaptureRazor — one bank sample per razor AREA (track scope over the
|
||||
// area's own range), the user's track selection snapshot/restored on every
|
||||
// path (TrackSelectionGuard);
|
||||
// * RunRecaptureFromSource — regenerate a PROVENANCED bank sample from its
|
||||
// recorded source's CURRENT state, updating the Sample in place. BANK-ONLY.
|
||||
// The batch-capture family + re-capture-from-source: RunBatchCaptureItems (one
|
||||
// sample per selected item), RunBatchCaptureRazor (one sample per razor area),
|
||||
// RunRecaptureFromSource (regenerate a provenanced sample from its recorded
|
||||
// source's current state, bank-only, in place). Every unit routes through
|
||||
// capture_orchestrator so every precision invariant holds; persist is batched to
|
||||
// one ext-state write per action.
|
||||
//
|
||||
// Every unit honors every precision invariant via capture_orchestrator's
|
||||
// captureAndIndexOne / renderOffline (exact bounds, non-destructive neutralize,
|
||||
// relative paths); nothing here ever touches the arrange/timeline (load-bearing
|
||||
// principle). Persist is batched: ONE ext-state write per action.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
|
||||
// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
|
||||
// (main.cpp owns the API pointers).
|
||||
|
||||
namespace reasampler {
|
||||
class ReaSamplerSession;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// capture_orchestrator.cpp — the single-capture orchestration + realtime/insert
|
||||
// action bodies (Q-W3 hoist out of main.cpp; the code moved verbatim, the session
|
||||
// threaded as a parameter). See the header. FxBypassGuard lives here as a STACK
|
||||
// RAII object (precision-invariant-critical — it must restore on every exit path
|
||||
// of exactly one render call).
|
||||
// See capture_orchestrator.h. FxBypassGuard lives here as a stack RAII object —
|
||||
// it must restore on every exit path of exactly one render call.
|
||||
//
|
||||
// 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).
|
||||
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
|
||||
// the one TU that defines the API pointers; here they are extern.
|
||||
|
||||
#include "shell/capture/capture_orchestrator.h"
|
||||
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
#pragma once
|
||||
// capture_orchestrator — the single-capture orchestration + the realtime/insert
|
||||
// action bodies (Q-W3 hoist out of main.cpp, T4-02). Owns:
|
||||
// * renderOffline — ONE offline render under the scope's FxBypassGuard (the
|
||||
// stack-RAII out-of-scope FX/fader/pan neutralize, defined in the .cpp —
|
||||
// precision-invariant-critical, shared by single-shot / batch / recapture);
|
||||
// * captureAndIndexOne — render + provenance stamp + bank add + owned-manifest
|
||||
// record, WITHOUT persisting (single-shot persists right after; batch persists
|
||||
// once at the end);
|
||||
// * RunCapture / RunCaptureItemAssign — the bindable single-capture actions;
|
||||
// * RunCaptureRealtimeTrack / RunCancelRealtime — the realtime action bodies
|
||||
// (the in-flight state itself lives in realtime_lifecycle);
|
||||
// * RunInsertSelected — the M6 placement action body (the INTENDED, explicit
|
||||
// placement path — the one deliberate exception to capture-never-places).
|
||||
// Single-capture orchestration + the realtime/insert action bodies: renderOffline
|
||||
// (one offline render under the scope's FxBypassGuard, shared by single-shot/
|
||||
// batch/recapture), captureAndIndexOne (render + provenance + bank add +
|
||||
// owned-manifest record, unpersisted), RunCapture/RunCaptureItemAssign,
|
||||
// RunCaptureRealtimeTrack/RunCancelRealtime (in-flight state lives in
|
||||
// realtime_lifecycle), and RunInsertSelected — the one deliberate exception to
|
||||
// capture-never-places.
|
||||
//
|
||||
// The session is threaded explicitly (no hidden module state): main.cpp's dispatch
|
||||
// passes its ReaSamplerSession. The load-bearing principle holds structurally —
|
||||
// no capture path here calls InsertMedia or touches the arrange/timeline; only
|
||||
// RunInsertSelected places, on purpose, via the insert shell.
|
||||
// The session is threaded explicitly; no capture path here calls InsertMedia
|
||||
// or touches the timeline except RunInsertSelected, on purpose, via the insert shell.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
|
||||
// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
|
||||
// (main.cpp owns the API pointers).
|
||||
|
||||
#include <string>
|
||||
|
||||
@@ -34,17 +26,17 @@ class ReaSamplerSession;
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Renders one CaptureRequest through the offline backend under the scope's
|
||||
// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and
|
||||
// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE
|
||||
// place. Non-destructive; touches no timeline item — it writes a file only.
|
||||
// FX-bypass guard. Shared by RunCapture and RunRecaptureFromSource so the
|
||||
// FX-scope neutralize + render recipe lives in one place. Non-destructive;
|
||||
// writes a file only.
|
||||
CaptureResult renderOffline(CaptureScope scope,
|
||||
const std::vector<MediaTrack*>& sourceTracks,
|
||||
const CaptureRequest& req);
|
||||
|
||||
// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance,
|
||||
// and adds the resulting Sample to the ACTIVE bank + records the created file in
|
||||
// the owned-file manifest — WITHOUT persisting. On success, res.sample.id carries
|
||||
// the LANDED bank-index id (fresh add or hash-dedup collapse target — S8).
|
||||
// Renders one capture request, stamps provenance, adds the Sample to the
|
||||
// active bank + owned-file manifest — without persisting (batch persists once
|
||||
// at the end). res.sample.id carries the landed bank-index id (fresh add or
|
||||
// hash-dedup collapse target).
|
||||
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
CaptureScope scope,
|
||||
const ResolvedSource& src,
|
||||
@@ -53,21 +45,21 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
double endSeconds);
|
||||
|
||||
// Runs one capture-action-table row: resolve, render + add + record, persist +
|
||||
// mark dirty. Returns the landed bank-index id ("" on failure/no-op) — the S8
|
||||
// capture+assign path consumes it; the plain capture actions ignore it.
|
||||
// mark dirty. Returns the landed bank-index id ("" on failure/no-op) — the
|
||||
// arrange-ingest capture+assign path consumes it; plain capture actions ignore it.
|
||||
std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def);
|
||||
|
||||
// S8 arrange ingest: Item-scope capture into the active bank + assignment-request
|
||||
// write, in one undo block.
|
||||
// Item-scope capture into the active bank + assignment-request write, in one
|
||||
// undo block.
|
||||
void RunCaptureItemAssign(ReaSamplerSession& session);
|
||||
|
||||
// STARTS the realtime track capture (async, timer-driven — the in-flight state is
|
||||
// realtime_lifecycle's; OnTimer drives it) / cancels the in-flight one.
|
||||
// Starts the realtime track capture (async, timer-driven — in-flight state is
|
||||
// realtime_lifecycle's) / cancels the in-flight one.
|
||||
void RunCaptureRealtimeTrack(ReaSamplerSession& session);
|
||||
void RunCancelRealtime(ReaSamplerSession& session);
|
||||
|
||||
// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor
|
||||
// via the insert shell. `conform` selects the explicit opt-in tempo-match variant.
|
||||
// Places the bank panel's selected sample(s) at the edit cursor via the
|
||||
// insert shell. `conform` selects the explicit opt-in tempo-match variant.
|
||||
void RunInsertSelected(ReaSamplerSession& session, bool conform);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// capture_realtime_finalize.cpp — the FILE-SIDE half of the realtime-record shell
|
||||
// (Q-W3, T4-08 split): recorded-file discovery, move-into-bank, the Auto-tail PCM
|
||||
// decay-scan trim, and the finished-Sample population. See the header. The async
|
||||
// record lifecycle lives in capture_realtime_shell.cpp.
|
||||
// capture_realtime_finalize.cpp — recorded-file discovery, move-into-bank, the
|
||||
// Auto-tail decay-scan trim, and finished-Sample population. See the header.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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).
|
||||
// pointers; here they are extern.
|
||||
|
||||
#include "shell/capture/capture_realtime_finalize.h"
|
||||
|
||||
@@ -19,7 +17,7 @@
|
||||
#include "core/capture/capture_realtime.h" // RecordedCapture, sampleFromRecordedCapture
|
||||
#include "core/capture/render_settings.h" // autoTrimEndRatio
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames, planWavTruncate, patchU32LE
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetTrackNumMediaItems
|
||||
@@ -39,26 +37,21 @@ std::string normSlashes(std::string s) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// §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
|
||||
// Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime): after the
|
||||
// recorded file is moved into the bank (the file we OWN, never the project), scan
|
||||
// the tail (frames after the original range end) backward for the last frame above
|
||||
// -72 dB and truncate there. Rules:
|
||||
// * no tail frame above -72 dB -> trim back to the range end
|
||||
// * signal never drops below -72 dB -> 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.
|
||||
// Returns the trimmed length in seconds, or negative for "no trim applied". Any
|
||||
// unreadable/unknown/short file skips the trim rather than risk corrupting the
|
||||
// capture — this is a convenience path, not a correctness one.
|
||||
//
|
||||
// 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).
|
||||
// Assumes the recorded file is a canonical 32-bit float WAV, fully flushed/closed
|
||||
// before this runs (tick()'s Finalizing size-stable wait guarantees that on the
|
||||
// normal path; abort()'s best-effort finalize can race it).
|
||||
double trimAutoTailInPlace(const std::string& path,
|
||||
double rangeStartSeconds,
|
||||
double rangeEndSeconds) {
|
||||
@@ -73,21 +66,18 @@ double trimAutoTailInPlace(const std::string& path,
|
||||
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.
|
||||
// Range end as a frame index (frame 0 == start), using the file's own sample
|
||||
// rate (authoritative — the request rate may be 0 = follow project). Clamped 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;
|
||||
if (rangeEndFrame >= totalFrames) return kNoTrim; // tail window was empty
|
||||
|
||||
// Scan ONLY the tail region (frames after the original range end). The trim never
|
||||
// eats into the range body — the scan starts at rangeEndFrame.
|
||||
// Scan only the tail region — the trim never eats into the range body.
|
||||
const std::size_t tailFrames = totalFrames - rangeEndFrame;
|
||||
const std::vector<AudioSample> tailPcm =
|
||||
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
|
||||
@@ -97,11 +87,10 @@ double trimAutoTailInPlace(const std::string& path,
|
||||
const std::size_t lastAbove = audio::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).
|
||||
// keptFrames: the trimmed file's total frame count. No audible tail frame -> trim
|
||||
// back to rangeEndFrame; an audible frame at idx -> keep through that frame. The
|
||||
// "never drops below threshold" case falls out naturally: lastAbove is the final
|
||||
// tail frame, so keptFrames == totalFrames.
|
||||
std::size_t keptFrames;
|
||||
if (lastAbove == audio::kNoFrameAboveThreshold) {
|
||||
keptFrames = rangeEndFrame;
|
||||
@@ -113,21 +102,17 @@ double trimAutoTailInPlace(const std::string& path,
|
||||
const 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 (wav_codec's patch primitive — the one RIFF owner), 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_codec re-parse test).
|
||||
// Patch RIFF + data size fields to the kept frame count (wav_codec's patch
|
||||
// primitive — the one RIFF owner), then rewrite the file as exactly the first
|
||||
// newFileByteLength bytes. A single truncating write avoids a separate resize
|
||||
// step and any partial-write window where on-disk sizes and length disagree.
|
||||
patchU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
|
||||
patchU32LE(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.
|
||||
// A mid-write failure (full disk, yanked drive) would leave a short file while we
|
||||
// return kNoTrim, overstating the Sample length. Vanishingly unlikely for a
|
||||
// just-recorded local file, and this is a convenience path, so a temp-file+
|
||||
// atomic-rename isn't warranted; 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()),
|
||||
@@ -190,12 +175,8 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
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".
|
||||
// Only Auto trims; None recorded exact bounds and Manual is a fixed window
|
||||
// (spec §The realtime path).
|
||||
double trimmedLenSeconds = -1.0;
|
||||
if (request.tailMode == TailMode::Auto) {
|
||||
trimmedLenSeconds = trimAutoTailInPlace(destPath,
|
||||
@@ -203,7 +184,7 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
request.endSeconds);
|
||||
}
|
||||
|
||||
// The pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
|
||||
// Pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
|
||||
RecordedCapture cap;
|
||||
cap.relativePath = paths.relativePath;
|
||||
cap.uniqueTag = uniqueTag;
|
||||
@@ -218,23 +199,16 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = sampleFromRecordedCapture(cap);
|
||||
|
||||
// The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount
|
||||
// (request echo), resolved sampleRate (request rate else PROJECT_SRATE — read
|
||||
// against the record's OWN project), captureTempo, the capture-start time
|
||||
// signature (timeSigProj = proj: the realtime path PINS the record's own
|
||||
// project — the divergence from offline's active-project read, kept
|
||||
// caller-visible here), the WAV-aware contentHash of the (possibly trimmed)
|
||||
// bank file, and createdTimestamp.
|
||||
// Shared finished-capture stamp. timeSigProj = proj: the realtime path pins the
|
||||
// record's own project (offline reads the active project instead) — the
|
||||
// divergence is kept caller-visible here.
|
||||
stampCaptureSample(result.sample, request, /*rateProj=*/proj,
|
||||
/*timeSigProj=*/proj, destPath);
|
||||
|
||||
// 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.
|
||||
// The recorded length differs from the request range when a tail was recorded,
|
||||
// so lengthSeconds must reflect the file, not the range: trimmed length if Auto
|
||||
// trimmed, else the full recorded window (recordWindowEnd - start; equals the
|
||||
// exact range when tailMode is None).
|
||||
if (trimmedLenSeconds >= 0.0) {
|
||||
result.sample.lengthSeconds = trimmedLenSeconds;
|
||||
} else {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
#pragma once
|
||||
// capture_realtime_finalize — the FILE-SIDE half of the realtime-record shell
|
||||
// (Q-W3, T4-08 split riding the Q-9 rename): discovering the file REAPER actually
|
||||
// recorded, moving it into the bank, the Auto-tail PCM decay-scan trim, and the
|
||||
// finished-Sample population. The async record LIFECYCLE (state snapshot/restore,
|
||||
// begin/tick/abort) lives in capture_realtime_shell.cpp; this half talks to
|
||||
// wav_codec and the filesystem, not to the transport.
|
||||
// The file-side half of the realtime-record shell: discovers the file REAPER
|
||||
// actually recorded, moves it into the bank, runs the Auto-tail decay-scan trim,
|
||||
// and populates the finished Sample. The async record lifecycle (state
|
||||
// snapshot/restore, begin/tick/abort) lives in capture_realtime_shell.cpp; this
|
||||
// half talks to wav_codec and the filesystem, not the transport.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
|
||||
// MediaTrack / ReaProject are forward-declared (via capture.h) so this header
|
||||
// stays SDK-lite.
|
||||
// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
|
||||
// (main.cpp owns the API pointers). MediaTrack/ReaProject are forward-declared
|
||||
// (via capture.h) so this header stays SDK-lite.
|
||||
|
||||
#include <string>
|
||||
|
||||
@@ -18,21 +16,18 @@
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Discovers the file REAPER actually recorded onto the temp track: the first media
|
||||
// item's active take's source file, forward-slashed. Empty string if nothing was
|
||||
// recorded (no item / take / source). Also used by the lifecycle's flush wait
|
||||
// The first media item's active take's source file on the temp track, forward-
|
||||
// slashed; empty if nothing was recorded. Also used by the lifecycle's flush wait
|
||||
// (size-stable check) before finalize runs.
|
||||
std::string recordedFilePath(MediaTrack* temp);
|
||||
|
||||
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
||||
// move it into the bank at `paths`, Auto-trim the tail decay in place when the
|
||||
// request asks for it, and populate the Sample (pure sampleFromRecordedCapture +
|
||||
// the shared stampCaptureSample — both project reads pinned to `proj`, the
|
||||
// record's OWN project). Returns Ok + Sample on success, or a RenderFailed result.
|
||||
// Does NOT restore any snapshotted state — the caller restores unconditionally
|
||||
// afterward (finalize + restore are separate steps so a finalize failure still
|
||||
// restores). `recordWindowEnd` is the recorded window end in project seconds
|
||||
// (>= request.endSeconds when a tail was recorded) — the untrimmed-length source.
|
||||
// Discovers the recorded file, moves it into the bank at `paths`, Auto-trims the
|
||||
// tail decay in place when requested, and populates the Sample (project reads
|
||||
// pinned to `proj`, the record's own project). Does NOT restore any snapshotted
|
||||
// state — the caller restores unconditionally afterward, even on a finalize
|
||||
// failure, so finalize and restore stay separate steps. `recordWindowEnd` is the
|
||||
// recorded window end in project seconds (>= request.endSeconds when a tail was
|
||||
// recorded).
|
||||
CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
const CaptureRequest& request,
|
||||
const BankPaths& paths,
|
||||
|
||||
@@ -1,78 +1,49 @@
|
||||
// capture_realtime_shell.cpp — REAPER-facing realtime-record backend
|
||||
// (RealtimeRecordBackend): the ASYNC record LIFECYCLE — state snapshot/restore +
|
||||
// begin/tick/abort. (Renamed from capture_realtime.cpp in Q-W3 — the Q-9 naming
|
||||
// rider: the PURE module owns the capture_realtime stem, this shell takes the
|
||||
// suffix, matching drag_out ↔ drag_out_win.) The FILE-SIDE half — recorded-file
|
||||
// discovery, move-into-bank, Auto-tail trim, Sample population — lives in
|
||||
// capture_realtime_finalize.cpp (T4-08 split).
|
||||
// REAPER-facing realtime-record backend (RealtimeRecordBackend): the async record
|
||||
// lifecycle — state snapshot/restore + begin/tick/abort. The file-side half
|
||||
// (recorded-file discovery, move-into-bank, Auto-tail trim, Sample population)
|
||||
// lives in capture_realtime_finalize.cpp.
|
||||
//
|
||||
// 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).
|
||||
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
|
||||
// the one TU that defines the API pointers; here they are extern.
|
||||
//
|
||||
// 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.
|
||||
// Captures the requested scope by recording in realtime into a hidden temp
|
||||
// track, then moves the recorded file into the bank as a Sample —
|
||||
// non-destructively. TRACK scope only this increment (the selected track's own
|
||||
// output); item realtime is deferred (UnsupportedMode) rather than 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.
|
||||
// ASYNC: a realtime record takes (end - start) wall-clock seconds; blocking the
|
||||
// main thread for that long freezes REAPER's UI. So it's driven across timer
|
||||
// ticks: begin() validates, snapshots all state to restore, creates the temp
|
||||
// track, routes the source-track tap, arms, CSurf_OnRecord, and returns
|
||||
// immediately; tick() (from OnTimer, same tick as session.poll()) reads the
|
||||
// transport and on a terminal verdict stops + finalizes/aborts + restores
|
||||
// everything; abort() force-terminates (shutdown/project switch) + restores.
|
||||
//
|
||||
// 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 the pure
|
||||
// core/capture/capture_realtime.{h,cpp} (unit-tested outside the DAW). This TU
|
||||
// owns only the REAPER-bound lifecycle recipe.
|
||||
// The snapshot + restore live on RealtimeCaptureState, not a function-scope RAII
|
||||
// guard, because the record spans ticks — no single stack frame outlives it.
|
||||
// restore() is idempotent: every terminal path (completion, user stop, error,
|
||||
// project switch, unload) funnels through the same restore. The pure record-mode
|
||||
// bookkeeping, recorded-file->Sample mapping, and completion state machine
|
||||
// (advanceRecordPhase) live in core/capture/capture_realtime (unit-tested
|
||||
// outside the DAW); this TU owns only the REAPER-bound lifecycle 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.
|
||||
// TAP: the hidden temp track receives a send FROM each selected source track
|
||||
// (CreateTrackSend(source, temp)) and records its own output (B_MAINSEND=0, so
|
||||
// it never sums back into the master — no feedback, no monitoring double).
|
||||
// Multiple selected tracks sum in the one 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.
|
||||
// Why this needs no FxBypassGuard: CreateTrackSend defaults to I_SENDMODE=0
|
||||
// (post-fader), which taps the source track after its own FX/fader/pan — its
|
||||
// own output — but before the parent/folder/master sums it. The tap is
|
||||
// chain-independent by construction: there's nothing downstream of the branch
|
||||
// point to neutralize. (An earlier spike sent FROM the master, which REAPER
|
||||
// refuses as a feedback loop and silently recorded nothing — a regular
|
||||
// track->track send has no such loop.)
|
||||
//
|
||||
// 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.
|
||||
// Non-destructive: deleting the temp track on teardown removes every send
|
||||
// created into it (REAPER cannot leave a send dangling to a deleted
|
||||
// destination), so no source track retains any routing change.
|
||||
|
||||
#include "shell/capture/capture_realtime_shell.h"
|
||||
|
||||
@@ -125,10 +96,9 @@ std::string readRppPath() {
|
||||
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.
|
||||
// -1 if unresolved yet. Used by the flush wait to detect stability (size
|
||||
// unchanged across a tick) before moving the file — a take REAPER is still
|
||||
// flushing grows tick over tick.
|
||||
std::int64_t recordedFileSize(MediaTrack* temp) {
|
||||
const std::string path = recordedFilePath(temp);
|
||||
if (path.empty()) return -1;
|
||||
@@ -140,42 +110,33 @@ std::int64_t recordedFileSize(MediaTrack* temp) {
|
||||
|
||||
} // 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.
|
||||
// Holds everything to restore across the many ticks the record spans (temp
|
||||
// track + its sends, other tracks' I_RECARM, transport, edit cursor, time
|
||||
// selection), plus the request echo needed to finalize the Sample. restore()
|
||||
// is idempotent (restored_ latch) — 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.
|
||||
// Transport reads use *Ex(proj_) so a project switch mid-record can't read
|
||||
// the wrong transport.
|
||||
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).
|
||||
// Project seconds, >= request_.endSeconds. A tail mode runs the transport
|
||||
// past the range end (Auto: +8s cap; Manual: +set length) — this, not
|
||||
// request_.endSeconds, is what the completion machine waits for.
|
||||
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.
|
||||
// Sends created into temp_ are removed automatically when temp_ is
|
||||
// deleted — no separate send handle to track.
|
||||
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.
|
||||
// Steady clock (not the play cursor) so a stuck/looping transport is still
|
||||
// caught. begunAt_ set at begin(); finalizingAt_ set on the Recording->
|
||||
// Finalizing edge 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_{};
|
||||
|
||||
@@ -225,37 +186,26 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Idempotent teardown called on every terminal path: stop transport if
|
||||
// still running, delete temp track (drops its sends + recorded item),
|
||||
// restore other tracks' arm, restore time selection + edit cursor.
|
||||
// OnStopButtonEx(proj_) is project-scoped, not the global CSurf_OnStop, so
|
||||
// a project switch mid-record (proj_ no longer active) still stops OUR
|
||||
// project's transport, never the foreign now-active one.
|
||||
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.
|
||||
// If the captured project was closed mid-record, proj_/temp_ point at freed
|
||||
// memory; touching them is a use-after-free. ValidatePtr2 with a null
|
||||
// project validates the ReaProject* itself. 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.
|
||||
// For the closed-project case: a closed project already reclaimed its temp
|
||||
// track, arms, and transport, so drop the handle without touching REAPER state.
|
||||
void dropWithoutRestore() {
|
||||
restored_ = true;
|
||||
temp_ = nullptr;
|
||||
@@ -266,21 +216,16 @@ public:
|
||||
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).
|
||||
// Deleting the temp track drops the source-track sends (REAPER removes
|
||||
// every send whose destination is deleted) and the recorded item in one move.
|
||||
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);
|
||||
}
|
||||
@@ -294,13 +239,6 @@ private:
|
||||
bool finalized_ = false;
|
||||
};
|
||||
|
||||
// The FILE-SIDE finalize half (recorded-file discovery, move-into-bank, the
|
||||
// Auto-tail PCM decay-scan trim, and the finished-Sample population) lives in
|
||||
// capture_realtime_finalize.cpp (T4-08). This TU owns only the async lifecycle.
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
}
|
||||
@@ -309,8 +247,8 @@ 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.
|
||||
// Item realtime is deferred — 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 "
|
||||
@@ -354,7 +292,7 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
// .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)
|
||||
Main_SaveProject(proj, true);
|
||||
rppPath = readRppPath();
|
||||
}
|
||||
if (rppPath.empty()) {
|
||||
@@ -365,68 +303,51 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
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("rt-"); // shared mint (T1-11 monotonic counter)
|
||||
st->uniqueTag_ = makeUniqueTag("rt-");
|
||||
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.
|
||||
// Extended past the range end for a tail mode (Auto/Manual), exact for
|
||||
// None; the extra window is trimmed later (Auto) or kept (Manual).
|
||||
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).
|
||||
// Deliberately NOT wrapped in an undo block — this backend fully restores
|
||||
// its own state across every terminal path, so an undo point would surface
|
||||
// an internal, fully-reversed scaffold for no user-meaningful action.
|
||||
// Disarm every other track BEFORE the temp track exists so it's never in
|
||||
// the arm snapshot.
|
||||
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).
|
||||
// Hidden temp track: no default FX/envelopes, hidden from both panels,
|
||||
// B_MAINSEND=0 so it doesn't sum back into the master (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
|
||||
st->restore();
|
||||
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.
|
||||
// A send FROM each selected source track INTO the temp track; the temp
|
||||
// records its own output, so sends sum in it — matching offline track
|
||||
// scope's multi-track handling. Sends default to post-fader/full-stereo,
|
||||
// left at defaults deliberately — that IS the track-scope tap point.
|
||||
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.";
|
||||
@@ -434,10 +355,8 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
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.)
|
||||
// The temp track has no FX and unity fader, so its post-fader output
|
||||
// equals the summed sends; track scope is fully wet -> PostFader.
|
||||
const OutputTap tap = outputTapForWetDry(request.wetDry);
|
||||
const RecordModePlan rec = recordModePlanFor(request.channelCount, tap);
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast<double>(rec.recMode));
|
||||
@@ -446,54 +365,41 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
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().
|
||||
// recordWindowEnd extends past the range end for a tail mode so the
|
||||
// transport captures the decay; cursor + time selection 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.
|
||||
// tick() detects completion via the play cursor reaching the range end
|
||||
// (the pure state machine), independent of REAPER's auto-punch settings.
|
||||
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).
|
||||
// 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.
|
||||
// A prior terminal path (e.g. abort()) already tore this down — 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).
|
||||
// *Ex(state.proj_) so a project switch can't point these reads at the
|
||||
// wrong transport.
|
||||
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).
|
||||
// File is ready when its size is positive and unchanged from the previous
|
||||
// tick — REAPER finished flushing the take. Comparing across a tick avoids
|
||||
// moving a file mid-write.
|
||||
if (prevPhase == RecordPhase::Finalizing) {
|
||||
state.markFinalizingStartOnce();
|
||||
inputs.finalizingSeconds = state.finalizingSeconds();
|
||||
@@ -502,34 +408,29 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
||||
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.
|
||||
// Waits for the transport to reach the recorded window end (extended for a
|
||||
// tail mode), not the request's range end — the extra tail window is part
|
||||
// of the record.
|
||||
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.
|
||||
// On Recording -> Finalizing, stop OUR project's transport once so REAPER
|
||||
// begins flushing the take; project-scoped so a project switch can't stop
|
||||
// the wrong (foreign active) project.
|
||||
if (prevPhase == RecordPhase::Recording &&
|
||||
isStopRequested(state.phase_)) {
|
||||
state.stopOwnTransport();
|
||||
state.markFinalizingStartOnce(); // anchor the flush ceiling from the stop
|
||||
state.markFinalizingStartOnce();
|
||||
}
|
||||
|
||||
if (!isTerminalPhase(state.phase_)) {
|
||||
out.status = RealtimeTickStatus::InProgress;
|
||||
return out; // keep the OnTimer tick fast — recording or flushing
|
||||
return out;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Done: file flushed + stable, finalize moves it into the bank. Failed:
|
||||
// flush ceiling tripped, nothing usable.
|
||||
CaptureResult res;
|
||||
if (state.phase_ == RecordPhase::Done) {
|
||||
res = finalizeRecording(state.proj_, state.temp_, state.request_,
|
||||
@@ -550,23 +451,15 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
||||
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 the captured project was closed mid-record, proj_/temp_ point at
|
||||
// freed memory — drop the handle without touching REAPER state. This is
|
||||
// the one terminal path that can run against a possibly-closed project
|
||||
// (tick() only runs while proj_ is still the active project).
|
||||
if (!state.captureProjectStillOpen()) {
|
||||
state.dropWithoutRestore();
|
||||
out.result.status = CaptureStatus::RenderFailed;
|
||||
@@ -576,25 +469,18 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
|
||||
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.
|
||||
// Project still open: stop the transport, then try to finalize whatever
|
||||
// was captured so a near-complete record keeps its audio; if nothing
|
||||
// usable was recorded, finalize returns RenderFailed and we abort clean.
|
||||
// abort() is the force-terminate path — unlike tick() it can't span ticks
|
||||
// to wait for the flush, so it still races REAPER's audio-thread take close.
|
||||
state.stopOwnTransport();
|
||||
|
||||
CaptureResult res = finalizeRecording(state.proj_, state.temp_, state.request_,
|
||||
state.paths_, state.uniqueTag_,
|
||||
state.recordWindowEnd_);
|
||||
state.markFinalized();
|
||||
state.restore(); // the non-destructive gate — always runs
|
||||
state.restore();
|
||||
|
||||
out.result = res;
|
||||
out.status = (res.status == CaptureStatus::Ok)
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
#pragma once
|
||||
// capture_realtime_shell — the ASYNC realtime-record seam (Q-W6 split of the former
|
||||
// fat capture.h: this header owns the realtime backend's begin/tick/abort surface;
|
||||
// capture.h keeps the shared CaptureRequest/CaptureResult types, the offline
|
||||
// backend, and the shared backend helpers). Implemented by
|
||||
// capture_realtime_shell.cpp; driven by exactly one caller (realtime_lifecycle).
|
||||
// The ASYNC realtime-record seam: begin/tick/abort. capture.h keeps the shared
|
||||
// CaptureRequest/CaptureResult types, the offline backend, and shared helpers.
|
||||
// Implemented by capture_realtime_shell.cpp; driven by exactly one caller
|
||||
// (realtime_lifecycle).
|
||||
//
|
||||
// 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.
|
||||
// CSurf_OnRecord starts the transport on REAPER's audio thread and returns
|
||||
// immediately — it does not block until the range completes. Blocking the main
|
||||
// thread would freeze REAPER's UI, so the 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.
|
||||
//
|
||||
// SEAM CHOICE (surfaced): the two backends deliberately share NO interface. The
|
||||
// lifecycles are genuinely different (offline is headless + immediate — one
|
||||
// synchronous capture() call returns a finished Sample; realtime is
|
||||
// transport-driven + async — begin/tick/abort across timer ticks), so a shared
|
||||
// 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; the realtime backend owns this small bespoke async seam.
|
||||
// This is the split-sync/async fork, chosen over a unified async interface for
|
||||
// that reason. (The old synchronous ICaptureBackend interface over
|
||||
// OfflineRenderBackend was deleted in Q-W3 — T4-26: one deriver, zero polymorphic
|
||||
// call sites.)
|
||||
// The two backends deliberately share NO interface — do not reintroduce one.
|
||||
// Offline is headless + immediate (one synchronous capture() call); realtime is
|
||||
// transport-driven + async. A shared interface would make offline fake a
|
||||
// lifecycle it doesn't have (tick() always Done on first call).
|
||||
//
|
||||
// REAPER-free like capture.h: MediaTrack is forward-declared there and never
|
||||
// REAPER-free like capture.h: MediaTrack is forward-declared there, never
|
||||
// dereferenced here; the REAPER-facing TU is capture_realtime_shell.cpp.
|
||||
|
||||
#include <memory>
|
||||
@@ -47,74 +37,64 @@ struct RealtimeTickResult {
|
||||
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,
|
||||
// The opaque in-flight capture state: the snapshot of everything to restore
|
||||
// (temp track + its 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_shell.cpp; the header stays REAPER-free (nothing is
|
||||
// dereferenced here) by holding it behind a forward-declared type + unique_ptr.
|
||||
// Defined in capture_realtime_shell.cpp; forward-declared here to stay REAPER-free.
|
||||
//
|
||||
// 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.
|
||||
// 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 (completion, user stop, error, project switch, unload)
|
||||
// funnels through the same restore, safe to call once from whichever fires.
|
||||
class RealtimeCaptureState;
|
||||
|
||||
// Out-of-line deleter so callers (realtime_lifecycle) can own a unique_ptr to the
|
||||
// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the
|
||||
// delete is compiled in capture_realtime_shell.cpp where the type is complete,
|
||||
// keeping this header REAPER-free (load-bearing split).
|
||||
// Out-of-line deleter so callers can own a unique_ptr to the opaque
|
||||
// RealtimeCaptureState without its full (REAPER-typed) definition.
|
||||
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.
|
||||
// Captures by recording in realtime into a hidden temp track, then moves the
|
||||
// recorded file into the bank as a Sample. For sources offline render can't 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). 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.
|
||||
// Non-bit-identical by nature; offline stays the deterministic default.
|
||||
// Non-destructive across every terminal path is harder here than offline
|
||||
// because the record spans ticks: 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).
|
||||
// TRACK scope only (this increment): records the selected track's own output
|
||||
// (item + track's own FX/fader/pan, pre-parent), matching offline's track
|
||||
// scope. Needs no FxBypassGuard — a send tapping a track's output is naturally
|
||||
// pre-parent, 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).
|
||||
// Validates the request (track scope, non-empty range, >=1 source track,
|
||||
// active+saved project, transport idle), snapshots state, creates the hidden
|
||||
// temp track, routes a send from each source track into it, arms, and
|
||||
// CSurf_OnRecord — then returns immediately. `sourceTracks` are resolved by
|
||||
// the caller; CaptureRequest itself stays REAPER-free. On success the
|
||||
// returned unique_ptr owns the in-flight state; on failure returns nullptr
|
||||
// with `outFailure` filled (nothing left mutated).
|
||||
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.
|
||||
// Reads the transport (bound to the record's OWN project handle so a project
|
||||
// switch can't confuse it); on a terminal verdict stops the transport,
|
||||
// finalizes the recorded file (Done) or reports the failure (Failed), then
|
||||
// restores all snapshotted state. After Done/Failed the state is spent.
|
||||
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.
|
||||
// Force-terminate now without waiting for the range end: stops transport,
|
||||
// finalizes best-effort or abandons, restores all snapshotted state. For
|
||||
// shutdown/project-switch paths where the record must not leak a temp
|
||||
// track/armed track/altered transport. Idempotent.
|
||||
RealtimeTickResult abort(RealtimeCaptureState& state);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
// insert.cpp — REAPER-facing placement shell (M6). See insert.h.
|
||||
// insert.cpp — REAPER-facing placement shell. See insert.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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).
|
||||
// extern.
|
||||
//
|
||||
// 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.
|
||||
// Unlike capture / bank_panel (which never touch the arrange), insert deliberately
|
||||
// adds items to the arrange — that is its whole job. 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.
|
||||
// Runtime assumptions the SDK header doesn't fully spell out (flagged, not yet
|
||||
// DAW-verified):
|
||||
// A. InsertMedia mode 0 ("add to current track") is assumed to target the sole
|
||||
// selected track — the header doesn't spell out how "current" resolves, so we
|
||||
// force exactly one selection via SetOnlyTrackSelected before each call. If
|
||||
// REAPER means last-focused rather than last-selected, this needs revisiting.
|
||||
// B. Mode 0 is assumed to insert at the edit cursor (REAPER's documented
|
||||
// convention for base modes 0/1; the header has no explicit "at cursor" bit).
|
||||
// C. InsertMedia may advance the cursor to the end of the inserted media; we
|
||||
// reset to the snapshot position before each track's insert, so this doesn't
|
||||
// matter either way.
|
||||
// D. SetEditCurPos(time, false, false) — moveview=false, seekplay=false — is
|
||||
// assumed to move the cursor without scrolling the view or the transport.
|
||||
// E. SetOnlyTrackSelected deselects all tracks and selects exactly one (per its
|
||||
// header doc-comment — the strongest confirmation we have here).
|
||||
|
||||
#include "shell/capture/insert.h"
|
||||
|
||||
@@ -57,7 +50,6 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
|
||||
using capture::computeInsertMode;
|
||||
using capture::normalizeSlashes;
|
||||
using capture::resolveBankFile;
|
||||
@@ -67,11 +59,10 @@ 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 panel_bank_ops.cpp): a shared "current project dir"
|
||||
// REAPER helper is a clean small refactor now that a fourth consumer exists — out
|
||||
// of scope for M6.
|
||||
// Mirrors bank_panel/capture/persist's own derivation; the bank index stores
|
||||
// relative paths, so resolving a file needs the current .rpp dir. A shared "current
|
||||
// project dir" helper would be a clean small refactor now that a fourth consumer
|
||||
// exists (also noted in panel_bank_ops.cpp) — out of scope here.
|
||||
std::string currentProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
@@ -80,9 +71,8 @@ std::string currentProjectDir() {
|
||||
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.
|
||||
// Snapshot of the currently-selected track set (master is skipped, matching
|
||||
// CountSelectedTracks/GetSelectedTrack), in selection order, for restore later.
|
||||
std::vector<MediaTrack*> snapshotSelectedTracks() {
|
||||
const int n = CountSelectedTracks(nullptr); // nullptr = active project
|
||||
std::vector<MediaTrack*> tracks;
|
||||
@@ -92,12 +82,10 @@ std::vector<MediaTrack*> snapshotSelectedTracks() {
|
||||
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.
|
||||
// Restores a snapshotted selection: deselect all via the first track, then
|
||||
// re-select the rest. Empty snapshot -> no-op (guards an 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);
|
||||
@@ -109,9 +97,8 @@ 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.
|
||||
// WHO: the user's selected track set. No-op when nothing is selected — inserting
|
||||
// without a target track would create an unintended track or behave unpredictably.
|
||||
const std::vector<MediaTrack*> selectedTracks = snapshotSelectedTracks();
|
||||
if (selectedTracks.empty()) {
|
||||
ShowConsoleMsg("ReaSampler insert: select a track first.\n");
|
||||
@@ -119,22 +106,20 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
|
||||
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.
|
||||
// WHAT: the single focused sample from the panel; multi-select is deprioritized,
|
||||
// so take the first id. Empty selection -> no-op.
|
||||
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).
|
||||
// WHERE: an unsaved project has no resolvable bank dir; no-op rather than
|
||||
// resolving against CWD.
|
||||
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).
|
||||
// Resolve against the bank the selection came from — it may be the pool or a
|
||||
// shown named bank, 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();
|
||||
@@ -153,16 +138,14 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
|
||||
// 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.
|
||||
// One undo block around the whole placement (all tracks + selection/cursor
|
||||
// restore) so a single undo removes every item and restores prior state. Always
|
||||
// balanced — opened before the first insert, closed after the restore.
|
||||
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.
|
||||
// Insert onto each selected track at the same cursor position (assumption B): per
|
||||
// track, isolate it as the only selection (A + E), reset the cursor (C is
|
||||
// irrelevant since we own the reset), then insert.
|
||||
for (MediaTrack* track : selectedTracks) {
|
||||
SetOnlyTrackSelected(track); // assumption A + E
|
||||
SetEditCurPos(cursorPos, false, false); // assumption D
|
||||
@@ -170,14 +153,12 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
|
||||
++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).
|
||||
// Restore the original selection + cursor — non-destructive to the user's DAW state.
|
||||
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).
|
||||
// Label reflects count + conform choice for a clear undo history. extraflags -1 =
|
||||
// UNDO_STATE_ALL (tracks, items, envelope points, project state).
|
||||
const std::string label =
|
||||
"ReaSampler: insert on " + std::to_string(result.inserted) +
|
||||
(result.inserted == 1 ? " track" : " tracks") +
|
||||
|
||||
+14
-18
@@ -1,21 +1,17 @@
|
||||
#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.
|
||||
// Placement of bank samples into the arrange. REAPER-facing shell: 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.
|
||||
// The deliberate, user-invoked placement act (root CLAUDE.md §load-bearing
|
||||
// principle: capture never auto-inserts) — 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.
|
||||
// Non-destructive to the bank: references the bank file, never modifies it or ext
|
||||
// state. No silent time-stretch: conform-to-tempo is an explicit opt-in, 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).
|
||||
// SDK-free header; all REAPER API use lives in insert.cpp.
|
||||
|
||||
#include "core/capture/insert_plan.h"
|
||||
|
||||
@@ -32,16 +28,16 @@ struct InsertRequest {
|
||||
|
||||
// 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)
|
||||
Ok,
|
||||
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
|
||||
int inserted = 0;
|
||||
int skipped = 0; // selected-but-unresolvable/unreadable samples
|
||||
};
|
||||
|
||||
// Runs the insert: reads the bank panel's single focused sample and the user's
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See
|
||||
// item_read.h. Compiled into the reaper_reasampler MODULE; includes
|
||||
// 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).
|
||||
// defines the API pointers).
|
||||
|
||||
#include "shell/capture/item_read.h"
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
#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.
|
||||
// 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 — the item-read analog of
|
||||
// track_guid's single MediaTrack* -> GUID-key formatter. Extracted from
|
||||
// near-identical private itemGuid/itemLaneName pairs previously duplicated in
|
||||
// view.cpp and bank_panel.cpp.
|
||||
//
|
||||
// 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).
|
||||
// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
|
||||
// (main.cpp owns the API pointers). MediaItem/MediaTrack are forward-declared so
|
||||
// this header stays SDK-lite. These are shell reads; the managed/manual decision
|
||||
// that consumes the lane name stays pure in lane_keys (isOnManualLane).
|
||||
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
// provenance_shell.cpp — the REAPER reads behind Milestone 10. See provenance_shell.h.
|
||||
// provenance_shell.cpp — the REAPER reads behind provenance. See provenance_shell.h.
|
||||
// Every REAPER symbol used here is verified against
|
||||
// vendor/reaper-sdk/sdk/reaper_plugin_functions.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)
|
||||
// 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.
|
||||
|
||||
#include "shell/capture/provenance_shell.h"
|
||||
|
||||
@@ -51,7 +37,6 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
|
||||
using capture::normalizeSlashes;
|
||||
using capture::resolveBankFile;
|
||||
|
||||
@@ -80,9 +65,8 @@ std::string fxChainIdentityForTrack(MediaTrack* tr) {
|
||||
}
|
||||
|
||||
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.
|
||||
// The owning track's chain is out of scope for an item capture (bypassed
|
||||
// during render) — TakeFX_* on the active take is the correct family here.
|
||||
std::vector<std::string> perItem;
|
||||
perItem.reserve(items.size());
|
||||
for (MediaItem* it : items) {
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
#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 REAPER-facing reads provenance 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).
|
||||
// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
|
||||
// (main.cpp owns the API pointers). MediaTrack is forward-declared so this header
|
||||
// stays SDK-lite.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -28,53 +25,43 @@ namespace reasampler {
|
||||
|
||||
class BankBook;
|
||||
|
||||
// Real-namespace-home using-declaration (Q-W6: the namespaces.h shim is retired).
|
||||
using model::BankFileRef;
|
||||
|
||||
// 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.
|
||||
// pure provenance string via the track's own FX chain (TrackFX_*) 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).
|
||||
// take FX chain (TakeFX_*), in item order then FX order, combined with
|
||||
// combineChainIdentities so distinct per-item partitions never collide. `items` is
|
||||
// the same source-item set the shell collected for the item-scope capture.
|
||||
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).
|
||||
// The media-file path of every selected media item's active take source,
|
||||
// normalized to forward-slash. Unresolvable items (no take/source/name) are
|
||||
// omitted — never an empty string in the result, so detectParent's "not in bank"
|
||||
// branch is honest. This is the item-scope source set.
|
||||
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).
|
||||
// The track-scope source set: media-file paths of the items on `tracks` that
|
||||
// overlap the capture range [startSeconds, endSeconds). A track capture selects
|
||||
// the track, not the item, so this is what "the source audio" means for it. 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).
|
||||
// Enumerates the active book's samples across every bank as pure BankFileRefs,
|
||||
// each id paired with its file resolved to an absolute path against `projectDir`.
|
||||
// An unresolvable path (empty projectDir/relativePath) gets an empty absolutePath,
|
||||
// which detectParent never matches.
|
||||
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).
|
||||
// Resolves a canonical track-GUID string to a live MediaTrack* in the active
|
||||
// project. 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 (no membership GUID, never a capture source).
|
||||
MediaTrack* trackByGuid(const std::string& guid);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// realtime_lifecycle.cpp — the in-flight realtime-capture state machine + globals
|
||||
// (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded as a
|
||||
// parameter). See the header.
|
||||
// realtime_lifecycle.cpp — the in-flight realtime-capture state machine + globals.
|
||||
// See the header.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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).
|
||||
// pointers; here they are extern.
|
||||
|
||||
#include "shell/capture/realtime_lifecycle.h"
|
||||
|
||||
@@ -17,15 +16,13 @@
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// --- M8 in-flight realtime capture (async, timer-driven) --------------------
|
||||
RealtimeRecordBackend g_rtBackend;
|
||||
RealtimeCaptureHandle g_rtCapture;
|
||||
ReaProject* g_rtCaptureProject = nullptr;
|
||||
|
||||
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
|
||||
// Sample to the ACTIVE bank (session.bank() resolves to book.activeIndex() — B2),
|
||||
// persist + MarkProjectDirty. Shared by the tick-completion path and the abort
|
||||
// paths. On a non-Ok result, logs the failure only.
|
||||
// Commits a finished realtime capture (a Done tick/abort with an Ok result): adds
|
||||
// the Sample to the active bank, persists + MarkProjectDirty. Shared by the
|
||||
// tick-completion and abort paths. On a non-Ok result, logs the failure only.
|
||||
void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res)
|
||||
{
|
||||
if (res.status != CaptureStatus::Ok)
|
||||
@@ -34,40 +31,36 @@ void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res)
|
||||
return;
|
||||
}
|
||||
session.bank().add(res.sample);
|
||||
// B-cap: record the file the capture created in the owned-file manifest, at the same
|
||||
// point the Sample is added and before the same persist. Recorded regardless of the
|
||||
// index AddResult — even a hash-collapse still WROTE a file the tool owns, and the
|
||||
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
|
||||
// Record the file in the owned manifest regardless of the index AddResult — even
|
||||
// a hash-collapse still wrote a file the tool owns; the manifest dedups a repeat
|
||||
// path itself (prune reconciles manifest vs index).
|
||||
session.owned().add(res.sample.relativePath);
|
||||
// S9: a capture add changes what a live instance could play (a new sample landed in the
|
||||
// active bank) -> bump before the persist so the stamped generation refreshes instances.
|
||||
// A capture add changes what a live instance could play, so bump the generation
|
||||
// before persisting to refresh instances.
|
||||
session.bumpBankGeneration();
|
||||
session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp)
|
||||
session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty
|
||||
}
|
||||
|
||||
// Advance any in-flight realtime capture one tick. Cheap when none is running (a
|
||||
// null check) and fast even mid-record (tick() only reads the transport until the
|
||||
// terminal tick). Detects a project switch mid-capture and aborts+restores so the
|
||||
// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so
|
||||
// poll's project-switch handling sees a cleaned-up project.
|
||||
// Advances any in-flight realtime capture one tick. Detects a project switch
|
||||
// mid-capture and aborts+restores so the capture never leaks across projects.
|
||||
// Called from OnTimer before session.poll() so poll's own project-switch handling
|
||||
// sees an already-cleaned-up project.
|
||||
void DriveRealtimeCapture(ReaSamplerSession& session)
|
||||
{
|
||||
if (!g_rtCapture) return;
|
||||
|
||||
// Project switch guard: if the active project is no longer the one the capture
|
||||
// belongs to, a new/other project became active mid-record — abort + restore
|
||||
// (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize
|
||||
// into the new project.
|
||||
// If the active project is no longer the one the capture belongs to, a project
|
||||
// switch happened mid-record: abort + restore into the original project the
|
||||
// state is bound to, and drop it — never finalize into the new project.
|
||||
ReaProject* active = EnumProjects(-1, nullptr, 0);
|
||||
if (active != g_rtCaptureProject)
|
||||
{
|
||||
RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
|
||||
// Only commit if the ORIGINAL project is still open and active would be it —
|
||||
// on a switch we restored into the original but must not persist into the
|
||||
// now-active foreign project. Log the outcome without persisting. On a Failed
|
||||
// abort surface abort()'s own message — it distinguishes a clean tab-switch
|
||||
// abort from the closed-project DROP (the captured project was closed mid-record,
|
||||
// review §1: nothing restored because the pointers were already freed).
|
||||
// Log without persisting — we restored into the original project but must
|
||||
// not persist into the now-active foreign one. A Failed abort surfaces
|
||||
// abort()'s own message, distinguishing a clean tab-switch abort from the
|
||||
// closed-project case (nothing restored because the pointers were already
|
||||
// freed).
|
||||
if (r.status == RealtimeTickStatus::Done)
|
||||
ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- "
|
||||
"captured audio restored into the original project; not "
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
#pragma once
|
||||
// realtime_lifecycle — the in-flight realtime-capture state machine + globals
|
||||
// (Q-W3 hoist out of main.cpp). A realtime record spans many timer ticks (it takes
|
||||
// end-start wall-clock seconds and must NOT block REAPER's UI): the action STARTS
|
||||
// it (capture_orchestrator::RunCaptureRealtimeTrack -> g_rtBackend.begin), OnTimer
|
||||
// drives it here (DriveRealtimeCapture -> g_rtBackend.tick) each tick until a
|
||||
// The in-flight realtime-capture state machine + globals. A realtime record spans
|
||||
// many timer ticks (it takes end-start wall-clock seconds and must not block
|
||||
// REAPER's UI): the action starts it (RunCaptureRealtimeTrack -> g_rtBackend.begin),
|
||||
// OnTimer drives it here (DriveRealtimeCapture -> g_rtBackend.tick) each tick until a
|
||||
// terminal verdict, then the handle is cleared.
|
||||
//
|
||||
// The three globals are EXPOSED (extern) rather than wrapped: the action bodies in
|
||||
// capture_orchestrator manipulate them exactly as main.cpp did (zero-behavior-change
|
||||
// move), and — load-bearing (CONTEXT.md §Phase Q hot-path guardrail) — the timer's
|
||||
// IDLE FAST-PATH stays a SINGLE POINTER TEST at the call site:
|
||||
// The three globals are extern rather than wrapped so the timer's idle fast path
|
||||
// stays a single pointer test at the call site — load-bearing:
|
||||
// if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
|
||||
// No per-tick cross-TU call, no accessor indirection, when nothing is recording.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
|
||||
// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
|
||||
// (main.cpp owns the API pointers).
|
||||
|
||||
#include "shell/capture/capture_realtime_shell.h" // RealtimeRecordBackend / RealtimeCaptureHandle
|
||||
|
||||
@@ -24,33 +21,30 @@ class ReaSamplerSession;
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The realtime backend + the in-flight capture handle. Non-null handle == a
|
||||
// capture is in progress (used to reject a second one, to drive the per-tick
|
||||
// advance, and to abort on project switch / unload).
|
||||
// Non-null g_rtCapture == a capture is in progress: used to reject a second one,
|
||||
// drive the per-tick advance, and abort on project switch / unload.
|
||||
extern RealtimeRecordBackend g_rtBackend;
|
||||
extern RealtimeCaptureHandle g_rtCapture;
|
||||
|
||||
// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets
|
||||
// The project the in-flight capture belongs to (opaque, compare-only) — lets
|
||||
// OnTimer detect a project switch mid-capture and abort+restore rather than leak the
|
||||
// temp track/arm/transport into or across projects. Only meaningful when
|
||||
// g_rtCapture != nullptr.
|
||||
// temp track/arm/transport across projects. Meaningful only when g_rtCapture != nullptr.
|
||||
extern ReaProject* g_rtCaptureProject;
|
||||
|
||||
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
|
||||
// Sample to the ACTIVE bank, record the owned file, bump the generation, persist +
|
||||
// MarkProjectDirty. On a non-Ok result, logs the failure only.
|
||||
// Commits a finished realtime capture (a Done tick/abort with an Ok result): adds
|
||||
// the Sample to the active bank, records the owned file, bumps the generation,
|
||||
// persists + MarkProjectDirty. On a non-Ok result, logs the failure only.
|
||||
void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res);
|
||||
|
||||
// Advance any in-flight realtime capture one tick. Cheap when none is running (a
|
||||
// null check — though the caller already guards, see the header note) and fast even
|
||||
// mid-record. Detects a project switch mid-capture and aborts+restores so the
|
||||
// capture never leaks across projects. Called from OnTimer BEFORE session.poll().
|
||||
// Advances any in-flight realtime capture one tick. Detects a project switch
|
||||
// mid-capture and aborts+restores so the capture never leaks across projects.
|
||||
// Called from OnTimer before session.poll().
|
||||
void DriveRealtimeCapture(ReaSamplerSession& session);
|
||||
|
||||
// Unload teardown: abort any in-flight capture while the API pointers are still
|
||||
// live — finalize-or-abort + restore so we never leave a temp track, an armed
|
||||
// track, or an altered transport/cursor in the user's project on unload. Commits
|
||||
// whatever was captured (best effort) before tearing down. No-op when idle.
|
||||
// track, or an altered transport/cursor behind. Commits whatever was captured
|
||||
// (best effort) before tearing down. No-op when idle.
|
||||
void AbortRealtimeCaptureForUnload(ReaSamplerSession& session);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// scope_resolve.cpp — scope/source resolution for the capture action family
|
||||
// (Q-W3 hoist out of main.cpp; the code moved verbatim, session state threaded as
|
||||
// parameters). See the header.
|
||||
// scope_resolve.cpp — scope/source resolution for the capture action family. See
|
||||
// the header.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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).
|
||||
// pointers; here they are extern.
|
||||
|
||||
#include "shell/capture/scope_resolve.h"
|
||||
|
||||
@@ -50,8 +49,7 @@ model::ProvenanceScope provenanceScopeFor(CaptureScope scope)
|
||||
|
||||
// Collects the tracks that own the selected items (Item scope) into
|
||||
// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an
|
||||
// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning
|
||||
// track (SDK header, verify). GUIDs recorded for provenance.
|
||||
// item capture hears take/item FX only. GUIDs recorded for provenance.
|
||||
bool collectSelectedItemTracks(ResolvedSource& out)
|
||||
{
|
||||
const int n = CountSelectedMediaItems(nullptr);
|
||||
@@ -76,9 +74,8 @@ bool collectSelectedItemTracks(ResolvedSource& out)
|
||||
} // namespace
|
||||
|
||||
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
|
||||
// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits),
|
||||
// and returns the union bound. Reads only — never clears the razor selection.
|
||||
// Returns false when no track-audio razor area exists on any track.
|
||||
// start, end, envGuidString) and returns the union of parsed track-audio areas.
|
||||
// Reads only — never clears the razor selection.
|
||||
bool resolveRazorRange(double& start, double& end)
|
||||
{
|
||||
std::vector<RazorRange> allRanges;
|
||||
@@ -100,9 +97,6 @@ bool resolveRazorRange(double& start, double& end)
|
||||
return end > start;
|
||||
}
|
||||
|
||||
// Infers the render RANGE for any scope: razor union when a razor area is present,
|
||||
// else the time selection (pure inferRangeSource decides which). Orthogonal to
|
||||
// scope. Returns false (with a reason) when neither yields a non-empty range.
|
||||
bool resolveRange(double& start, double& end, std::string& why)
|
||||
{
|
||||
double rzStart = 0.0, rzEnd = 0.0;
|
||||
@@ -117,7 +111,6 @@ bool resolveRange(double& start, double& end, std::string& why)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
|
||||
bool collectSelectedTracks(ResolvedSource& out)
|
||||
{
|
||||
const int n = CountSelectedTracks(nullptr); // nullptr = active project
|
||||
@@ -133,8 +126,6 @@ bool collectSelectedTracks(ResolvedSource& out)
|
||||
return !out.sourceTracks.empty();
|
||||
}
|
||||
|
||||
// Resolves the source for a scope: the selection tracks (item/track), plus the
|
||||
// inferred range. Returns false with a reason on nothing to do.
|
||||
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why)
|
||||
{
|
||||
switch (scope)
|
||||
@@ -153,11 +144,8 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh
|
||||
return resolveRange(out.startSeconds, out.endSeconds, why);
|
||||
}
|
||||
|
||||
// Current project's directory (parent of its .rpp), forward-slashed, no trailing
|
||||
// slash — the same derivation capture.cpp does internally, needed here so M10 can
|
||||
// resolve the bank's relative paths to absolute for parent detection. Empty for an
|
||||
// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank
|
||||
// file resolve empty -> no false parentage. Read-only; mutates nothing.
|
||||
// Empty for an unsaved project (EnumProjects writes an empty .rpp path), which
|
||||
// makes every bank file resolve empty -> no false parentage.
|
||||
std::string currentProjectDir()
|
||||
{
|
||||
std::vector<char> buf(4096, '\0');
|
||||
@@ -171,16 +159,8 @@ std::string currentProjectDir()
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Builds the M10 provenance for a capture IF it genuinely resamples from a bank
|
||||
// sample, else returns nullopt (the common, non-resample case). Detection rule
|
||||
// (stated honestly): the capture's source item media file(s) must all resolve, by
|
||||
// exact normalized absolute path, to ONE bank sample's file (detectParent). On a
|
||||
// match, records that sample's id as the parent plus a THIN capture-recipe
|
||||
// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels +
|
||||
// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from
|
||||
// source" can replay the request and report drift. NEVER a serialized chain to
|
||||
// restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per
|
||||
// selected item, combined in item order; Track scope reads the track FX chain.
|
||||
// Detection rule: the capture's source item media file(s) must all resolve, by
|
||||
// exact normalized absolute path, to one bank sample's file.
|
||||
std::optional<model::Provenance> buildCaptureProvenance(
|
||||
const BankBook& book, const CaptureRequest& req,
|
||||
CaptureScope scope, const ResolvedSource& src)
|
||||
@@ -188,9 +168,9 @@ std::optional<model::Provenance> buildCaptureProvenance(
|
||||
const std::string projectDir = currentProjectDir();
|
||||
const std::vector<model::BankFileRef> bankFiles = bankFileRefs(book, projectDir);
|
||||
|
||||
// The "what audio is being captured" source set depends on scope: item scope uses
|
||||
// the SELECTED items (the user picked them); track scope uses the range-overlapping
|
||||
// items ON the source tracks (the user picked the track, not the item).
|
||||
// What "the source audio" means depends on scope: item scope uses the selected
|
||||
// items (the user picked them); track scope uses the range-overlapping items on
|
||||
// the source tracks (the user picked the track, not the item).
|
||||
const std::vector<std::string> sourceFiles =
|
||||
scope == CaptureScope::Item
|
||||
? selectedItemSourceFiles()
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
#pragma once
|
||||
// scope_resolve — scope/source resolution for the capture action family (Q-W3
|
||||
// hoist out of main.cpp). The three concerns every capture entry point shares:
|
||||
// * RANGE inference — razor union else time selection (razor-else-time),
|
||||
// orthogonal to scope;
|
||||
// * SOURCE-TRACK collection — the selected tracks (Track scope) or the selected
|
||||
// items' owning tracks (Item scope), deduped, with canonical GUIDs;
|
||||
// * PROVENANCE ASSEMBLY inputs — the M10 resample-from-sample detection + the
|
||||
// thin capture-recipe fingerprint built from the LIVE (un-bypassed) chain.
|
||||
// Scope/source resolution shared by every capture entry point. Three concerns:
|
||||
// * range inference — razor union else time selection, orthogonal to scope;
|
||||
// * source-track collection — selected tracks (Track scope) or selected items'
|
||||
// owning tracks (Item scope), deduped, with canonical GUIDs;
|
||||
// * provenance-assembly inputs — resample-from-sample detection + the thin
|
||||
// capture-recipe fingerprint built from the live (un-bypassed) chain.
|
||||
//
|
||||
// All reads are non-destructive: selection, razor, and time selection are read,
|
||||
// never mutated. REAPER-facing: the .cpp includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md
|
||||
// §contract). MediaTrack is forward-declared (via capture.h) so this header stays
|
||||
// SDK-lite.
|
||||
// never mutated. The .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers). MediaTrack is forward-
|
||||
// declared (via capture.h) so this header stays SDK-lite.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -35,18 +32,16 @@ struct ResolvedSource
|
||||
{
|
||||
double startSeconds = 0.0;
|
||||
double endSeconds = 0.0;
|
||||
std::vector<MediaTrack*> sourceTracks; // item-owning tracks / selected tracks
|
||||
std::vector<std::string> trackGuids; // canonical GUIDs of sourceTracks
|
||||
std::vector<MediaTrack*> sourceTracks;
|
||||
std::vector<std::string> trackGuids;
|
||||
};
|
||||
|
||||
// Reads every track's P_RAZOREDITS, parses the track-audio areas (pure
|
||||
// parseRazorEdits), and returns the union bound. Reads only — never clears the
|
||||
// razor selection. Returns false when no track-audio razor area exists on any track.
|
||||
// Reads every track's P_RAZOREDITS and returns the union of parsed track-audio
|
||||
// areas. Reads only — never clears the razor selection.
|
||||
bool resolveRazorRange(double& start, double& end);
|
||||
|
||||
// Infers the render RANGE for any scope: razor union when a razor area is present,
|
||||
// else the time selection (pure inferRangeSource decides which). Orthogonal to
|
||||
// scope. Returns false (with a reason) when neither yields a non-empty range.
|
||||
// Infers the render range for any scope: razor union when present, else the time
|
||||
// selection. Returns false with a reason when neither yields a non-empty range.
|
||||
bool resolveRange(double& start, double& end, std::string& why);
|
||||
|
||||
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
|
||||
@@ -60,10 +55,10 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh
|
||||
// slash. Empty for an unsaved project (no false parentage). Read-only.
|
||||
std::string currentProjectDir();
|
||||
|
||||
// Builds the M10 provenance for a capture IF it genuinely resamples from a bank
|
||||
// sample (detectParent over `book`'s resolved file refs), else returns nullopt (the
|
||||
// common, non-resample case). Must run BEFORE the FxBypassGuard neutralizes the
|
||||
// in-scope chain — the source FX-chain identity is read from the LIVE chain.
|
||||
// Builds the provenance for a capture if it genuinely resamples from a bank sample
|
||||
// (detectParent over `book`'s resolved file refs), else returns nullopt (the common,
|
||||
// non-resample case). Must run BEFORE the FxBypassGuard neutralizes the in-scope
|
||||
// chain — the source FX-chain identity is read from the live chain.
|
||||
std::optional<model::Provenance> buildCaptureProvenance(
|
||||
const BankBook& book, const CaptureRequest& req,
|
||||
CaptureScope scope, const ResolvedSource& src);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// track_guid.cpp — the single MediaTrack* -> canonical GUID key formatter. See
|
||||
// track_guid.h. Compiled into the reaper_reasampler MODULE; includes
|
||||
// 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).
|
||||
// that defines the API pointers).
|
||||
|
||||
#include "shell/capture/track_guid.h"
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
#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 (design_view_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).
|
||||
// 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 (design_view_actions.cpp) key membership on this exact string, so the
|
||||
// contract lives in a single helper rather than being re-derived at two call sites.
|
||||
//
|
||||
// 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.
|
||||
// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
|
||||
// (main.cpp owns the API pointers). MediaTrack is forward-declared so this header
|
||||
// stays SDK-lite.
|
||||
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// editor_controls.cpp — the ReaSamplerEditor's PARAMETER PLUMBING (Q-W2v split of
|
||||
// reasampler_editor.cpp, T4-11): the control-value domain maps (controlValue /
|
||||
// applyControl — seconds/fraction/frames <-> normalized 0..1), the r11 knob-deck
|
||||
// group descriptors + control-id<->value binding, the S-VIEW-3 envelope pack/unpack
|
||||
// (the TRIGGER SEAM converter), the curve-popup target resolution, and applyZoneControl.
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the control-value domain
|
||||
// maps (controlValue / applyControl — seconds/fraction/frames <-> normalized 0..1), the
|
||||
// knob-deck group descriptors + control-id<->value binding, the envelope pack/unpack
|
||||
// (the trigger-seam converter), the curve-popup target resolution, and applyZoneControl.
|
||||
// Value logic only — no painting, no window plumbing.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
@@ -13,8 +12,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1)
|
||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters (S-VIEW-3)
|
||||
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
|
||||
#include "core/util/clamp01.h"
|
||||
#include "shell/instrument/editor_internal.h" // DeckGroup ids
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
@@ -22,35 +21,32 @@
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters
|
||||
using instrument::ui::EnvMode; // envelope_overlay's mode enum (Q-W6: shim retired)
|
||||
using instrument::ui::EnvMode; // envelope_overlay's mode enum
|
||||
using instrument::engine::formatMasterGainLabel;
|
||||
using instrument::engine::masterGainLinearFromNorm;
|
||||
using instrument::engine::masterGainNormFromLinear;
|
||||
using util::clamp01;
|
||||
|
||||
namespace {
|
||||
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is
|
||||
// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span
|
||||
// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build
|
||||
// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
|
||||
// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is
|
||||
// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent —
|
||||
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the
|
||||
// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate
|
||||
// ruling). Build-time residual — one place to retune; not persisted.
|
||||
// Control-surface value domains (the shell owns these — param_slider is engine-free and maps
|
||||
// only 0..1). Wall-clock time sliders (AHDSR A/H/D/R, pitch env A/D) span [0, kEnvTimeMaxSeconds]
|
||||
// seconds — rate-free, exactly what the zone stores; the keymap build resolves seconds->frames
|
||||
// at the live rate. Source-timeline fade sliders (Trigger fade-in/out) store source frames
|
||||
// (never a wall-clock second), but the knob's full-scale throw is a wall-clock intent —
|
||||
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in
|
||||
// rate constant, per the no-hardcoded-rate ruling.
|
||||
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
|
||||
constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock)
|
||||
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
|
||||
constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%)
|
||||
constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling (0..200%)
|
||||
|
||||
} // namespace
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
|
||||
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
|
||||
// the rate-resolved frames ceiling (T3-03). Two domains, kept explicit so neither leaks a rate.
|
||||
// A stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
|
||||
// pins it) and gets rewritten down on the next knob touch — deliberate, matching the old
|
||||
// fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200.
|
||||
// the rate-resolved frames ceiling. Two domains, kept explicit so neither leaks a rate. A
|
||||
// stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
|
||||
// pins it) and gets rewritten down on the next knob touch.
|
||||
const double fadeMax = fadeMaxFrames();
|
||||
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
|
||||
const auto framesToNorm = [fadeMax](std::int64_t f) {
|
||||
@@ -80,7 +76,7 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const
|
||||
|
||||
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
|
||||
int segment) const {
|
||||
const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale
|
||||
const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale
|
||||
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
|
||||
const auto normToFrames = [fadeMax](double v) -> std::int64_t {
|
||||
// Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves.
|
||||
@@ -122,14 +118,12 @@ double ReaSamplerEditor::liveSampleRate() const {
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::fadeMaxFrames() const {
|
||||
// T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock)
|
||||
// resolved against the live rate — the SAME time base the envelope overlay already uses
|
||||
// to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()),
|
||||
// and the rate captures are made at (the capture path renders at the project rate).
|
||||
// Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the
|
||||
// exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) —
|
||||
// callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert
|
||||
// rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only.
|
||||
// The Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) resolved
|
||||
// against the live rate — the same time base the envelope overlay already uses to place
|
||||
// these source-frame fades on screen. Pre-setupProcessing the rate is still 0: rather than
|
||||
// substitute a literal rate, callers treat a <= 0 return as "ceiling unavailable yet" and
|
||||
// degrade the knob to inert rather than guess a rate. Storage stays source frames — this
|
||||
// resolves the UI ceiling only.
|
||||
const double rate = liveSampleRate();
|
||||
if (rate <= 0.0) return 0.0;
|
||||
return kFadeMaxSeconds * rate;
|
||||
@@ -141,11 +135,11 @@ double ReaSamplerEditor::previewVelocity01() const {
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders
|
||||
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs).
|
||||
// Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger
|
||||
// leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows
|
||||
// the neighbouring groups (r11).
|
||||
// The per-zone groups — the deck grammar both surfaces share (the Zone panel renders
|
||||
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs). Group
|
||||
// widths are mode-independent: AMP ENVELOPE reserves its 5-cell Gate width (Trigger leaves
|
||||
// two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows the
|
||||
// neighbouring groups.
|
||||
std::vector<DeckGroupDesc> out;
|
||||
{
|
||||
DeckGroupDesc amp;
|
||||
@@ -159,8 +153,8 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe
|
||||
static_cast<int>(ParamControl::kSustain),
|
||||
static_cast<int>(ParamControl::kRelease)};
|
||||
} else {
|
||||
// Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out —
|
||||
// matches the drawn envelope), plus the two reserved blanks.
|
||||
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
|
||||
// the drawn envelope), plus the two reserved blanks.
|
||||
amp.cellIds = {static_cast<int>(ParamControl::kTrigFadeIn),
|
||||
static_cast<int>(ParamControl::kTrigLength),
|
||||
static_cast<int>(ParamControl::kTrigFadeOut), -1, -1};
|
||||
@@ -190,9 +184,8 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
|
||||
// groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the
|
||||
// post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays
|
||||
// OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs.
|
||||
// groups. Per-instance state (ComponentState) stays off the Zone panel, so they are
|
||||
// appended here, not in zoneDeckGroupDescs.
|
||||
std::vector<DeckGroupDesc> out = zoneDeckGroupDescs(play);
|
||||
{
|
||||
DeckGroupDesc voice;
|
||||
@@ -303,8 +296,8 @@ std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone
|
||||
|
||||
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||
// Match the control-panel sliders' own domains so a node drag can never produce a param a
|
||||
// slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the
|
||||
// Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use).
|
||||
// slider couldn't. AHDSR seconds cap at kEnvTimeMaxSeconds; the Trigger fade/length
|
||||
// fractions cap at 1.0 (the natural full-span bound the sliders use).
|
||||
EnvClampBounds b;
|
||||
b.maxAttackSeconds = kEnvTimeMaxSeconds;
|
||||
b.maxHoldSeconds = kEnvTimeMaxSeconds;
|
||||
@@ -326,8 +319,8 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
|
||||
env.decaySeconds = play.adsr.decaySeconds;
|
||||
env.sustainLevel = play.adsr.sustainLevel;
|
||||
env.releaseSeconds = play.adsr.releaseSeconds;
|
||||
// Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played
|
||||
// span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start
|
||||
// Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played
|
||||
// span (the trigger-seam converter, pack direction). startFrame is the zone's effective start
|
||||
// point so the fraction denominator matches the voice's actual post-start span. A zero play
|
||||
// length yields 0 fractions.
|
||||
env.lengthFraction = play.trigger.lengthFraction;
|
||||
@@ -348,10 +341,10 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
play.adsr.releaseSeconds = env.releaseSeconds;
|
||||
} else {
|
||||
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
|
||||
// the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's
|
||||
// effective start point so the frame denominator matches the voice's actual post-start span.
|
||||
// Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length
|
||||
// trigger never plays nothing.
|
||||
// the played span (the trigger-seam converter, unpack direction). startFrame is the
|
||||
// zone's effective start point so the frame denominator matches the voice's actual
|
||||
// post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces
|
||||
// so a zero-length trigger never plays nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
|
||||
const std::int64_t playLen =
|
||||
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
|
||||
@@ -361,8 +354,8 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
}
|
||||
|
||||
PerformanceZone ReaSamplerEditor::popupZone() const {
|
||||
// The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample
|
||||
// face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
|
||||
// The zone the popup displays: the Zone surface's selected zone, else the Sample face's
|
||||
// one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
|
||||
if (view_ == View::kZone && selectedZone_ >= 0 &&
|
||||
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
return map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// editor_input_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE
|
||||
// input + the hover resolver (Q-W2v split of reasampler_editor.cpp, T4-11): the L3 hover
|
||||
// resolution across all three faces, the Browse picker's click branch (tabs, cards,
|
||||
// select-then-confirm, scroll-thumb grab, search focus), the Zone surface's click branch
|
||||
// (add/delete, strip drags, numeric-entry focus, per-zone deck + curve button), the
|
||||
// browser wheel scroll, the type-to-filter / note-entry keystrokes, and the S13 degraded
|
||||
// drop affordance. Windows-only (D5).
|
||||
// editor_input_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
|
||||
// input + the hover resolver: hover resolution across all three faces, the Browse picker's
|
||||
// click branch (tabs, cards, select-then-confirm, scroll-thumb grab, search focus), the
|
||||
// Zone surface's click branch (add/delete, strip drags, numeric-entry focus, per-zone deck
|
||||
// + curve button), the browser wheel scroll, the type-to-filter / note-entry keystrokes,
|
||||
// and the degraded drop affordance. Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -15,10 +14,10 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12)
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover)
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
|
||||
#include "core/instrument/map/note_entry.h" // parseNoteEntry (S12 numeric entry)
|
||||
#include "core/instrument/map/note_entry.h" // parseNoteEntry (numeric entry)
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover)
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -28,8 +27,6 @@ using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
// --- Hover resolution (Phase L, L3) ------------------------------------------
|
||||
//
|
||||
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
|
||||
// idle move is free). Mirrors onMouseDown's hit-test order, but read-only. Windows-only.
|
||||
void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
@@ -57,7 +54,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
if (tab >= 0) h = {HoverKind::kFilterTab, tab};
|
||||
else if (card >= 0) h = {HoverKind::kCard, card};
|
||||
}
|
||||
} else if (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2)
|
||||
} else if (curvePopupOpen_) { // the curve popup — modal over Sample and Zone
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, hgt);
|
||||
if (contains(pl.close, x, y)) {
|
||||
h = {HoverKind::kPopupClose, -1};
|
||||
@@ -79,8 +76,8 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||||
h = {HoverKind::kDeleteZone, -1};
|
||||
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
// FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's
|
||||
// hover grammar — knobs light + swap label->value).
|
||||
// The per-zone knob deck + the mini curve-preview button (the Sample deck's hover
|
||||
// grammar — knobs light + swap label->value).
|
||||
if (contains(zonesCurveButton(content), x, y)) {
|
||||
h = {HoverKind::kCurveButton, -1};
|
||||
} else {
|
||||
@@ -93,7 +90,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
|
||||
}
|
||||
}
|
||||
} else { // Sample view (home, r11 recomposition)
|
||||
} else { // Sample view (home)
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
|
||||
const SampleBands bands =
|
||||
@@ -128,8 +125,8 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// The Browse-modal branch of the mouse-down dispatch (formerly inline in onMouseDown —
|
||||
// behavior-identical; see editor_input_sample.cpp for the dispatch).
|
||||
// The Browse-modal branch of the mouse-down dispatch (see editor_input_sample.cpp for the
|
||||
// dispatch).
|
||||
void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
|
||||
@@ -198,8 +195,8 @@ void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The Zone-surface branch of the mouse-down dispatch (formerly the tail of onMouseDown —
|
||||
// behavior-identical; the curve popup is modal over the Zone surface too, FB2).
|
||||
// The Zone-surface branch of the mouse-down dispatch (the curve popup is modal over the
|
||||
// Zone surface too).
|
||||
void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
const Rect back = zoneBackRect(w, h);
|
||||
@@ -209,11 +206,11 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
if (contains(addR, x, y)) {
|
||||
// Add a narrow default zone for the picked capture (or the first visible sample as a
|
||||
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
|
||||
// already exists (pre-fix bleed survivor), select it rather than appending a duplicate
|
||||
// (mirrors the upsert the root-marker drag path already performs).
|
||||
// NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped
|
||||
// to [0,127]) so the new zone is immediately "authored" (narrow) and survives
|
||||
// reconcileSingleCaptureZones without being treated as a Sample-face full-range zone.
|
||||
// already exists, select it rather than appending a duplicate (mirrors the upsert the
|
||||
// root-marker drag path already performs). Narrow default: seed [root-6, root+5] (one
|
||||
// octave centred on the bank root, clamped to [0,127]) so the new zone is immediately
|
||||
// "authored" (narrow) and survives reconcileSingleCaptureZones without being treated
|
||||
// as a Sample-face full-range zone.
|
||||
std::string seed = !selectedId_.empty() ? selectedId_
|
||||
: (!visible_.empty() ? visible_.front().id : std::string());
|
||||
if (seed.empty()) return;
|
||||
@@ -290,7 +287,7 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
|
||||
// Numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
|
||||
// zone is selected. entryText_ starts empty (the user types the full value).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
@@ -305,9 +302,9 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
}
|
||||
entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry
|
||||
|
||||
// The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the
|
||||
// SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected
|
||||
// (the Zone surface has no single-capture fallback — that lives on the Sample face).
|
||||
// The per-zone param surface: the knob deck + the mini curve-preview button — the same
|
||||
// grammar and hit-test machinery as the Sample face. Only when a zone is selected (the
|
||||
// Zone surface has no single-capture fallback — that lives on the Sample face).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
if (contains(zonesCurveButton(content), x, y)) {
|
||||
curvePopupOpen_ = true;
|
||||
@@ -335,7 +332,7 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||||
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
|
||||
// GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release.
|
||||
// Grab-anchored vertical drag: live-drag the map, commit on release.
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = selectedZone_;
|
||||
@@ -351,8 +348,8 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
|
||||
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
|
||||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive
|
||||
// delta (wheel up) scrolls toward the top (smaller offset).
|
||||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A
|
||||
// positive delta (wheel up) scrolls toward the top (smaller offset).
|
||||
if (view_ != View::kBrowse) return;
|
||||
const int rows = delta / 120;
|
||||
if (rows == 0) return;
|
||||
@@ -362,8 +359,8 @@ void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
// r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
|
||||
// or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search
|
||||
// The curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
|
||||
// or the Zone surface; opening it clears any note-entry focus, and the Browse search
|
||||
// cannot hold focus under it).
|
||||
if (curvePopupOpen_ && ch == 27) {
|
||||
curvePopupOpen_ = false;
|
||||
@@ -371,7 +368,7 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// S12 numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes
|
||||
// Numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes
|
||||
// and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
|
||||
// focused, owns the keystrokes).
|
||||
if (view_ == View::kZone && entryField_ >= 0) {
|
||||
@@ -402,8 +399,9 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
|
||||
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search).
|
||||
// Type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
|
||||
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then
|
||||
// search).
|
||||
if (view_ != View::kBrowse || !searchFocused_) return;
|
||||
if (ch == 8) { // backspace
|
||||
if (!searchQuery_.empty()) searchQuery_.pop_back();
|
||||
@@ -421,12 +419,12 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
|
||||
// S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact
|
||||
// ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff
|
||||
// decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a
|
||||
// timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing
|
||||
// at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs
|
||||
// each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0.
|
||||
// The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor
|
||||
// drop -> extension) is not shipped, so we do not ingest the dropped files and — load-
|
||||
// bearing — never insert a timeline item. Instead of silently swallowing the drop, flash a
|
||||
// clear affordance pointing at the shipped ingest gesture. dropHintTicks_ counts sync ticks
|
||||
// (kSyncTimerIntervalMs each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer
|
||||
// decays it to 0.
|
||||
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
|
||||
dropHintTicks_ = 6;
|
||||
#ifdef _WIN32
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// editor_input_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE input + the drag-state
|
||||
// machine (Q-W2v split of reasampler_editor.cpp, T4-11): the mouse-down dispatch (the
|
||||
// Sample-face branch inline; Browse/Zone branches delegate to editor_input_browse_zone),
|
||||
// the curve-popup/curve-box click machinery, the live drag resolution (onMouseMove — deck
|
||||
// knobs, root marker, envelope nodes, curve nodes, wave markers, scroll thumb, zone
|
||||
// edges), the release commit (onMouseUp), and the popup right-click delete. Windows-only
|
||||
// (D5). All hit-test math is pure; this TU routes and mutates editor state only.
|
||||
// editor_input_sample.cpp — the ReaSamplerEditor's sample-face input + the drag-state
|
||||
// machine: the mouse-down dispatch (the Sample-face branch inline; Browse/Zone branches
|
||||
// delegate to editor_input_browse_zone), the curve-popup/curve-box click machinery, the
|
||||
// live drag resolution (onMouseMove — deck knobs, root marker, envelope nodes, curve
|
||||
// nodes, wave markers, scroll thumb, zone edges), the release commit (onMouseUp), and the
|
||||
// popup right-click delete. Windows-only. All hit-test math is pure; this TU routes and
|
||||
// mutates editor state only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag)
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet (r11)
|
||||
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag (S-VIEW-3)
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
|
||||
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
|
||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (FA4 grab-anchored drag)
|
||||
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap (S11)
|
||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
|
||||
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -31,11 +31,10 @@ using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
|
||||
// The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home
|
||||
// (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash
|
||||
// click dismiss (outside only when no drag is in flight, per the spec); in-box clicks
|
||||
// route to the shared curve machinery against popupZoneIndex(); anything else on the
|
||||
// sheet is swallowed.
|
||||
// The curve popup: while open the sheet is modal over its host face — the Sample home or
|
||||
// the Zone surface — it owns every left-click. Close click / outside-wash click dismiss
|
||||
// (outside only when no drag is in flight); in-box clicks route to the shared curve
|
||||
// machinery against popupZoneIndex(); anything else on the sheet is swallowed.
|
||||
if (!curvePopupOpen_) return false;
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
if (contains(pl.close, x, y)) {
|
||||
@@ -77,13 +76,10 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x,
|
||||
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
|
||||
dragStartMap_ = map_;
|
||||
|
||||
// Empty-space click inside the MAPPING BOX: add a control point at the cursor via the pure
|
||||
// inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller
|
||||
// gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the
|
||||
// caption band — must not add a point; a click there would clamp to velocity 0/127 and
|
||||
// produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab
|
||||
// an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is
|
||||
// handled above; only the add path is box-gated here.
|
||||
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
|
||||
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
|
||||
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an endpoint.
|
||||
// A ring click can still grab an existing node (handled above); only add is box-gated.
|
||||
if (idx < 0) {
|
||||
const bool inBox = (x >= box.left && x < box.left + box.width &&
|
||||
y >= box.top && y < box.top + box.height);
|
||||
@@ -115,15 +111,15 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
|
||||
// ---- Browse modal (S-VIEW-5): the face branch lives in editor_input_browse_zone ----
|
||||
// Browse modal: the face branch lives in editor_input_browse_zone.
|
||||
if (view_ == View::kBrowse) {
|
||||
mouseDownBrowse(w, h, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Sample home (S-VIEW-2 / r11) ----
|
||||
// Sample home.
|
||||
if (view_ == View::kSample) {
|
||||
// r11 curve popup: while open the sheet is modal — it owns every left-click.
|
||||
// The curve popup: while open the sheet is modal — it owns every left-click.
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
|
||||
const PerformanceZone probeZone = effectiveSampleZone();
|
||||
@@ -155,8 +151,8 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself
|
||||
// never jumps the value (FA4); the delta from the grab point maps via knobDragValue.
|
||||
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
|
||||
// jumps the value; the delta from the grab point maps via knobDragValue.
|
||||
if (contains(cr.velCell, x, y)) {
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
|
||||
@@ -187,9 +183,9 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The knob deck (r11): toggles commit at once (a discrete, final edit — the slider
|
||||
// precedent); knobs start a grab-anchored vertical drag. The deck band swallows its
|
||||
// clicks (no fall-through to the hero/markers).
|
||||
// The knob deck: toggles commit at once (a discrete, final edit); knobs start a
|
||||
// grab-anchored vertical drag. The deck band swallows its clicks (no fall-through to
|
||||
// the hero/markers).
|
||||
if (contains(bands.deck, x, y)) {
|
||||
const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y,
|
||||
bands.deck.width);
|
||||
@@ -266,7 +262,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers.
|
||||
// Hero waveform: envelope nodes first, then the wave markers.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
const Rect waveArea = bands.hero;
|
||||
@@ -304,7 +300,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fenced root strip: grab the root marker (remainder-width since r11).
|
||||
// Fenced root strip: grab the root marker (remainder-width).
|
||||
if (cr.rootStrip.width > 0) {
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
|
||||
@@ -320,7 +316,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Zone surface (S-VIEW-8 / FB2): the face branch lives in editor_input_browse_zone ----
|
||||
// Zone surface: the face branch lives in editor_input_browse_zone.
|
||||
mouseDownZone(w, h, x, y);
|
||||
}
|
||||
|
||||
@@ -335,24 +331,24 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
const int dx = x - dragStartX_;
|
||||
|
||||
if (drag_ == DragKind::kDeckKnob) {
|
||||
// r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from
|
||||
// the value at grab (up = increase), so the value tracks relative motion and never
|
||||
// jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP.
|
||||
// Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the
|
||||
// value at grab (up = increase), so the value tracks relative motion and never jumps
|
||||
// on grab. Live feedback; zone-param commits land on WM_LBUTTONUP.
|
||||
const int dy = y - dragStartY_;
|
||||
applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy));
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted
|
||||
// The Sample bands derive from the deck height (mode-independent width math). Hoisted
|
||||
// below the kDeckKnob early-return — that branch uses neither deckDescs nor bands.
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
|
||||
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
|
||||
if (drag_ == DragKind::kRootMarker) {
|
||||
// The fenced root strip on the Sample cluster band. Setting the root materializes a
|
||||
// full-keyboard zone carrying the override on the picked id (the D-B override vehicle) —
|
||||
// upsert by id so a repeated drag edits the same zone rather than stacking duplicates.
|
||||
// full-keyboard zone carrying the override on the picked id — upsert by id so a
|
||||
// repeated drag edits the same zone rather than stacking duplicates.
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const Rect stripArea = clusterRects(bands.cluster, chan.mono, kDeckKnobSize).rootStrip;
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
@@ -381,10 +377,11 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kEnvNode) {
|
||||
// S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through
|
||||
// the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the
|
||||
// picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_)
|
||||
// so the delta is absolute. Materialize the zone if needed (mirror of the marker path).
|
||||
// Resolve the grabbed envelope node's new params from the pixel delta (through the
|
||||
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the
|
||||
// picked id's one-zone play params. The AmpEnvelope was snapshotted at grab
|
||||
// (dragStartEnv_) so the delta is absolute. Materialize the zone if needed (mirror of
|
||||
// the marker path).
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
const double rate = liveSampleRate();
|
||||
if (frames <= 0 || rate <= 0.0) return;
|
||||
@@ -403,9 +400,9 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kCurveNode) {
|
||||
// S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure
|
||||
// inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve +
|
||||
// box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
|
||||
// Resolve the grabbed control point from the pixel delta through the pure inverse map
|
||||
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box
|
||||
// (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
|
||||
// commit lands on WM_LBUTTONUP.
|
||||
if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
if (curvePointIndex_ < 0) return;
|
||||
@@ -419,8 +416,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kWaveMarker) {
|
||||
// S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap
|
||||
// it against the decoded PCM, apply the inter-marker clamps, and write the override live.
|
||||
// Resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap it
|
||||
// against the decoded PCM, apply the inter-marker clamps, and write the override live.
|
||||
const Rect waveArea = bands.hero;
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
if (frames <= 0) return;
|
||||
@@ -431,8 +428,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
dragStartMarkers_.loopEnd};
|
||||
std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx);
|
||||
|
||||
// Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware
|
||||
// requirement). Pure over the cached mono frames — no host types, no file I/O.
|
||||
// Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono
|
||||
// frames — no host types, no file I/O.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
if (!pcm.empty()) {
|
||||
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
|
||||
@@ -464,8 +461,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kScrollThumb) {
|
||||
// S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag
|
||||
// only happens in the Browse modal (the sole card grid). The visible-card window recomputes
|
||||
// Map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag only
|
||||
// happens in the Browse modal (the sole card grid). The visible-card window recomputes
|
||||
// at paint from scrollOffset_.
|
||||
const int dyThumb = y - dragStartY_;
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
@@ -535,9 +532,9 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the
|
||||
// dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain
|
||||
// move — its amp keeps the last clamped drag value).
|
||||
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
|
||||
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
|
||||
// its amp keeps the last clamped drag value).
|
||||
if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 &&
|
||||
curveZone < static_cast<int>(map_.zones.size())) {
|
||||
const bool off = x < curveRect.x - kCurveDragOffMargin ||
|
||||
@@ -554,12 +551,12 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
||||
// r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete
|
||||
// affordance; Alt-click and drag-off remain as landed alternates. Commits immediately
|
||||
// through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint
|
||||
// right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the
|
||||
// Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) —
|
||||
// and never during an in-flight left drag.
|
||||
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
|
||||
// and drag-off remain as landed alternates. Commits immediately through the same path as
|
||||
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
|
||||
// Right-clicks act only while the popup is open — over the Sample face or the Zone
|
||||
// surface (nothing else in the editor consumes them) — and never during an in-flight left
|
||||
// drag.
|
||||
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
|
||||
if (drag_ != DragKind::kNone) return;
|
||||
RECT rc{};
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// editor_internal.h — INTERNAL shared helpers for the ReaSamplerEditor TU family
|
||||
// (Q-W2v: the eight face-axis TUs split out of the former reasampler_editor.cpp).
|
||||
// Included ONLY by the editor's own shell TUs (editor_session / editor_controls /
|
||||
// editor_paint_* / editor_input_* / editor_platform) — never a public seam. Holds the
|
||||
// former god-TU's anonymous-namespace helpers that more than one split TU needs: the
|
||||
// Rect<->kit adapters, the small draw primitives (knob face / spectral strip / root
|
||||
// marker / title band), the label helpers, the deck group ids, and the velocity-curve
|
||||
// box derivation. All inline; behavior-identical to the pre-split definitions.
|
||||
// editor_internal.h — shared helpers for the ReaSamplerEditor TU family. Included ONLY by
|
||||
// the editor's own shell TUs (editor_session / editor_controls / editor_paint_* /
|
||||
// editor_input_* / editor_platform) — never a public seam. Holds the Rect<->kit adapters,
|
||||
// small draw primitives (knob face / spectral strip / root marker / title band), label
|
||||
// helpers, deck group ids, and the velocity-curve box derivation. All inline.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -24,7 +21,7 @@
|
||||
|
||||
#include "core/audio/peaks.h" // Envelope (drawEnvelope)
|
||||
#include "core/instrument/ui/capture_browser.h" // BrowserLayout / cardThumbnailRect (thumbBins)
|
||||
#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace, FA4)
|
||||
#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace)
|
||||
#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / isNaturalKey (spectral strip)
|
||||
#include "core/ui/component_geometry.h" // KitBox / waveformColumnCount
|
||||
#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor
|
||||
@@ -33,8 +30,7 @@
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck
|
||||
// order. Shared by the deck-desc builders (editor_controls) and the deck painter.
|
||||
// Deck group ids (shell-owned; knob_deck treats them opaquely), left-to-right order.
|
||||
enum DeckGroup {
|
||||
kGroupAmpEnv = 0,
|
||||
kGroupPitch,
|
||||
@@ -43,17 +39,14 @@ enum DeckGroup {
|
||||
kGroupMaster,
|
||||
};
|
||||
|
||||
// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the
|
||||
// curve in the POPUP (curve_popup), each summoned from its own mini preview button. The
|
||||
// INSET keeps node handles + the pick radius inside the border so an endpoint at amp 0/1
|
||||
// stays grabbable — the ONE curveBoxFromRect grammar the popup derives its mapping box
|
||||
// through. Drag-off: release beyond box+margin deletes the dragged node.
|
||||
// Velocity-curve editor box metrics. The inset keeps node handles + the pick radius
|
||||
// inside the border so an endpoint at amp 0/1 stays grabbable; drag-off beyond
|
||||
// box+margin deletes the dragged node.
|
||||
inline constexpr int kVelCurveInset = 14;
|
||||
inline constexpr int kCurveDragOffMargin = 24;
|
||||
|
||||
// The pure-module mapping Box for a drawn curve rect: inset from the border so node
|
||||
// handles and the pick radius stay inside the box. Every consumer (paint, hit-test, add,
|
||||
// drag) derives the Box through this ONE formula, so drawn nodes and grabs never drift.
|
||||
// The pure-module mapping Box for a drawn curve rect. Every consumer (paint, hit-test,
|
||||
// add, drag) derives it through this ONE formula, so drawn nodes and grabs never drift.
|
||||
inline instrument::engine::VelocityCurve::Box curveBoxFromRect(
|
||||
const instrument::ui::Rect& r) {
|
||||
return instrument::engine::VelocityCurve::Box{
|
||||
@@ -74,8 +67,8 @@ inline std::string noteLabel(int note) {
|
||||
}
|
||||
|
||||
// A display name for a bank sample id: the snapshotted bank list first, then the
|
||||
// instance-OWNED ref's displayName (pS — the label survives with the extension absent /
|
||||
// bank unreadable). "?" only when neither source knows the id.
|
||||
// instance-owned ref's displayName (survives with the extension absent). "?" if neither
|
||||
// source knows the id.
|
||||
inline std::string sampleLabel(const std::vector<instrument::map::SampleChoice>& samples,
|
||||
const instrument::map::SampleRefs& refs,
|
||||
const std::string& id) {
|
||||
@@ -90,11 +83,9 @@ inline std::string sampleLabel(const std::vector<instrument::map::SampleChoice>&
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
// --- Rect <-> kit adapters (Phase L, L3) -------------------------------------
|
||||
//
|
||||
// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against
|
||||
// `KitBox` (component_geometry). This is the single boundary that bridges them so every
|
||||
// draw routes through the L1 kit (theme roles + draw_kit).
|
||||
// draw routes through the shared kit (theme roles + draw_kit).
|
||||
inline ui::KitBox toKitBox(const instrument::ui::Rect& r) {
|
||||
return ui::KitBox{r.x, r.y, r.width, r.height};
|
||||
}
|
||||
@@ -110,23 +101,22 @@ inline void kitTextCentered(LICE_IBitmap* bmp, const instrument::ui::Rect& r,
|
||||
text(bmp, toKitBox(r), s, font, role, Align::Center);
|
||||
}
|
||||
|
||||
// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3).
|
||||
// Draw a peak envelope in `r` through the kit's shared waveform primitive.
|
||||
inline void drawEnvelope(LICE_IBitmap* bmp, const instrument::ui::Rect& r,
|
||||
const audio::Envelope& env) {
|
||||
drawWaveform(bmp, toKitBox(r), env);
|
||||
}
|
||||
|
||||
// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the
|
||||
// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins.
|
||||
// thumbnailFor clamps the request to the decoded frame count.
|
||||
// The bin count a card's thumbnail is computed at: one bin per drawn pixel column (the
|
||||
// gap-free render comes from peaks::columnMinMax's exact partition).
|
||||
inline int thumbBins(const instrument::ui::BrowserLayout& layout) {
|
||||
return (std::max)(1, kWaveformOversample *
|
||||
ui::waveformColumnCount(toKitBox(
|
||||
instrument::ui::cardThumbnailRect(layout, 0))));
|
||||
}
|
||||
|
||||
// Draw the title band with the live readout. Shared by the Sample face (nav visible) —
|
||||
// Browse/Zone draw their own back button in place of the nav.
|
||||
// Draws the title band with the live readout. Browse/Zone draw their own back button in
|
||||
// place of the nav.
|
||||
inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
|
||||
const std::string& readout) {
|
||||
fillSurface(bmp, toKitBox(title), ui::Role::BgPanel, ui::InteractionState::Rest);
|
||||
@@ -135,12 +125,10 @@ inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
|
||||
kitText(bmp, titleText, readout.c_str(), Font::Title, ui::Role::TextPrimary);
|
||||
}
|
||||
|
||||
// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle
|
||||
// map; this turns it into LICE calls through the kit's palette roles. LICE's arc
|
||||
// convention matches param_slider's (angle 0 = 12 o'clock, positive clockwise) — but LICE
|
||||
// takes RADIANS, and drawing the 7->5 o'clock sweep THROUGH the top needs a continuous
|
||||
// angle span, so the degrees convert as (deg - 360) * pi/180, mapping 210..510 onto
|
||||
// -150..+150 degrees. One conversion, both arcs.
|
||||
// Draws one radial knob face: param_slider owns the value<->angle map; this turns it into
|
||||
// LICE calls. LICE takes radians, and drawing the 7->5 o'clock sweep through the top needs
|
||||
// a continuous angle span, so degrees convert as (deg - 360) * pi/180, mapping 210..510
|
||||
// onto -150..+150 degrees.
|
||||
inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect,
|
||||
double value01, ui::InteractionState st) {
|
||||
using instrument::ui::KnobArc;
|
||||
@@ -149,14 +137,13 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
||||
const KnobGeometry kg = instrument::ui::computeKnob(knobRect);
|
||||
if (kg.radius <= 1.0) return;
|
||||
constexpr double kDegToRad = 3.14159265358979323846 / 180.0;
|
||||
const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep
|
||||
const KnobArc arc{}; // the default 7->5 o'clock sweep
|
||||
const float cx = static_cast<float>(kg.centerX);
|
||||
const float cy = static_cast<float>(kg.centerY);
|
||||
const float rOuter = static_cast<float>(kg.radius) - 0.5f;
|
||||
const bool disabled = (st == ui::InteractionState::Disabled);
|
||||
const bool hot = (st == ui::InteractionState::Dragging || st == ui::InteractionState::Hover);
|
||||
|
||||
// Face: a filled circle in the cell surface color under the interaction state.
|
||||
LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(ui::roleColorState(ui::Role::BgCell, st)),
|
||||
1.0f, 0, true);
|
||||
// Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare).
|
||||
@@ -165,8 +152,6 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
||||
(arc.startDeg + instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
|
||||
LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(ui::roleColor(ui::Role::LineHairline)), 1.0f, 0,
|
||||
true);
|
||||
// Value arc: start -> the value's angle, in the live accent (hot while under the pointer /
|
||||
// dragging, dim when disabled).
|
||||
const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01);
|
||||
if (v > 0.0) {
|
||||
const float av = static_cast<float>(
|
||||
@@ -185,11 +170,9 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
||||
toLice(ui::roleColor(needleRole)), 1.0f, 0, true);
|
||||
}
|
||||
|
||||
// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature
|
||||
// surface. Fills each MIDI key column with its spectral hue, then draws faint per-octave
|
||||
// hairline ticks. Shared by the setup face + the Zones strip so both read as the same
|
||||
// spectrum. S-VIEW-7: accidentals get a dark bg/base wash over the hue (an OVERLAY, not
|
||||
// a keyboard shape) so pitch position reads as a keyboard at a glance.
|
||||
// Draws the pastel spectral keyboard-strip background: each MIDI key column filled with
|
||||
// its spectral hue, accidentals darkened with an overlay wash so pitch position reads as
|
||||
// a keyboard at a glance. Shared by the setup face + the Zones strip.
|
||||
inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) {
|
||||
using instrument::ui::StripLayout;
|
||||
if (stripArea.width <= 0 || stripArea.height <= 0) return;
|
||||
@@ -218,8 +201,8 @@ inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& str
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the single-capture root marker on the strip: an accent-primary bar with a soft
|
||||
// STATIC glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark.
|
||||
// Draws the single-capture root marker: an accent-primary bar with a soft static glow —
|
||||
// the "this is live" mark.
|
||||
inline void drawRootMarker(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea,
|
||||
const instrument::ui::StripLayout& sl, int root) {
|
||||
const int sx = stripArea.x;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// editor_paint_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE
|
||||
// painting (Q-W2v split of reasampler_editor.cpp, T4-11): the full-window select-then-
|
||||
// confirm picker (S-VIEW-5 — wash, search box, filter tabs, card grid, scrollbar,
|
||||
// footer) and the Zone keymap surface (S-VIEW-8/FB2 — add/delete, the spectral zones
|
||||
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only
|
||||
// (D5). Shares the Sample face's painters (title band / empty state / deck / curve
|
||||
// button / popup) via the class + editor_internal.h.
|
||||
// editor_paint_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
|
||||
// painting: the full-window select-then-confirm picker (wash, search box, filter tabs, card
|
||||
// grid, scrollbar, footer) and the Zone keymap surface (add/delete, the spectral zones
|
||||
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only.
|
||||
// Shares the Sample face's painters (title band / empty state / deck / curve button /
|
||||
// popup) via the class + editor_internal.h.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -15,8 +14,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12)
|
||||
#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout (FB2)
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -27,8 +26,8 @@ using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface
|
||||
using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs
|
||||
|
||||
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
// A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying
|
||||
// Sample face with a bg/base wash, then draw the picker opaque on top.
|
||||
// A full-window modal sheet over the Sample face. Dim the underlying Sample face with a
|
||||
// bg/base wash, then draw the picker opaque on top.
|
||||
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0);
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
|
||||
@@ -84,8 +83,9 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
active ? Role::BgBase : Role::TextPrimary);
|
||||
}
|
||||
|
||||
// Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_)
|
||||
// is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border.
|
||||
// Cards (the visible window at the current scroll offset). The pending pick
|
||||
// (browsePendingId_) is marked with the accent-primary border; the currently-loaded id
|
||||
// gets a faint tertiary border.
|
||||
const int bins = thumbBins(bl);
|
||||
const int cardCount = static_cast<int>(visible_.size());
|
||||
const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_);
|
||||
@@ -192,8 +192,8 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
drawButton(bmp, box, "Delete", state, /*warn=*/false);
|
||||
}
|
||||
|
||||
// The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per
|
||||
// zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which
|
||||
// The zones strip — the same pastel spectral surface as the Sample face, with one bar per
|
||||
// zone over the spectrum. The selected zone lifts to accent-primary + a static glow ("which
|
||||
// zone is live"); the rest take the categorical secondary hue at low alpha.
|
||||
const Rect stripArea = zonesStripArea(content);
|
||||
drawSpectralStrip(bmp, stripArea);
|
||||
@@ -218,8 +218,8 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
}
|
||||
|
||||
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
|
||||
// entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it
|
||||
// (entryField_) and typed text commits via parseNoteEntry on Enter.
|
||||
// entry fields (low / high / root). Clicking a field focuses it (entryField_) and typed
|
||||
// text commits via parseNoteEntry on Enter.
|
||||
const int legendTop = stripArea.bottom() + 8;
|
||||
Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18);
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
@@ -256,19 +256,18 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// The per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck +
|
||||
// curve-preview-button/popup grammar as the Sample face — one control language over the one
|
||||
// storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only
|
||||
// the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live
|
||||
// on the Sample deck only.
|
||||
// The per-zone parameter surface for the selected zone: the same knob deck +
|
||||
// curve-preview-button/popup grammar as the Sample face — one control language over the
|
||||
// one storage site. Only the per-zone groups render here; VOICE/MASTER are per-instance
|
||||
// (ComponentState) and live on the Sample deck only.
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
|
||||
paintCurveButton(bmp, zonesCurveButton(content), z);
|
||||
}
|
||||
|
||||
// The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST —
|
||||
// the same modal grammar as the Sample face.
|
||||
// The curve popup: a centered sheet over the whole Zone surface, drawn last — the same
|
||||
// modal grammar as the Sample face.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// editor_paint_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE painting (Q-W2v split of
|
||||
// reasampler_editor.cpp, T4-11): the WM_PAINT dispatch, the r11 Sample home face (title
|
||||
// band + elastic hero waveform + root/preview cluster + bottom-anchored knob deck), the
|
||||
// S-VIEW-3 envelope overlay, the velocity-curve editor + mini preview button + popup
|
||||
// sheet (shared painters the Zone surface reuses, FB2), and the empty state. Windows-only
|
||||
// (D5); draws through the L1 kit by palette role. All layout math is pure
|
||||
// (editor_geometry / knob_deck / curve_popup) — this TU only draws.
|
||||
// editor_paint_sample.cpp — the ReaSamplerEditor's sample-face painting: the WM_PAINT
|
||||
// dispatch, the Sample home face (title band + elastic hero waveform + root/preview cluster
|
||||
// + bottom-anchored knob deck), the envelope overlay, the velocity-curve editor + mini
|
||||
// preview button + popup sheet (shared painters the Zone surface reuses), and the empty
|
||||
// state. Windows-only; draws through the shared kit by palette role. All layout math is
|
||||
// pure (editor_geometry / knob_deck / curve_popup) — this TU only draws.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -17,10 +16,10 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (hero waveform binning)
|
||||
#include "core/instrument/ui/curve_popup.h" // r11 centered curve-popup sheet geometry (FB1)
|
||||
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
|
||||
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
|
||||
#include "core/instrument/ui/waveform_view.h" // frameToX (S11 markers)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived title band, S18)
|
||||
#include "core/instrument/ui/waveform_view.h" // frameToX (waveform markers)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived title band)
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face/spectral strip/root marker
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -32,8 +31,8 @@ using namespace reasampler::instrument::map; // SampleRefs / findRef (title read
|
||||
using audio::computeEnvelope;
|
||||
|
||||
namespace {
|
||||
// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette: start = teal
|
||||
// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple.
|
||||
// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop
|
||||
// start/end = purple (tertiary). The loop-span fill is a faint purple.
|
||||
constexpr Role kRoleStartMarker = Role::AccentSecondary;
|
||||
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
|
||||
} // namespace
|
||||
@@ -48,9 +47,9 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
||||
LICE_SysBitmap bmp(w, h);
|
||||
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
|
||||
|
||||
// S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn
|
||||
// OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
|
||||
// modal reads as a sheet layered over the home face (the "picker over the document" grammar).
|
||||
// Three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn over
|
||||
// Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
|
||||
// modal reads as a sheet layered over the home face.
|
||||
if (view_ == View::kZone) {
|
||||
paintZone(&bmp, w, h);
|
||||
} else {
|
||||
@@ -58,9 +57,9 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
||||
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
|
||||
}
|
||||
|
||||
// S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window.
|
||||
// It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn
|
||||
// LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
// A transient banner flashed after a file was dropped on this window. It reiterates the
|
||||
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
|
||||
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
if (dropHintTicks_ > 0) {
|
||||
const int bannerTop = (std::min)(kTitleHeight, h);
|
||||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
||||
@@ -77,21 +76,20 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
// r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP
|
||||
// ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it).
|
||||
// The deck height comes from the pure knob_deck wrap (mode-independent — the AMP ENVELOPE
|
||||
// group reserves its 5-cell Gate width, so Gate<->Trigger never changes it).
|
||||
const PerformanceZone deckZone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(deckZone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
|
||||
// Title: product name + live readout. Standard B palette — the beta channel gets NO distinct
|
||||
// accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable
|
||||
// signal.
|
||||
std::string title = version::vstPluginName(); // channel-derived (S18)
|
||||
// Title: product name + live readout. The beta channel gets no distinct accent; the
|
||||
// channel-derived vstPluginName is the only beta-vs-stable signal.
|
||||
std::string title = version::vstPluginName();
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
// The instance's OWN loaded state outranks bank availability (pS: the bank is a
|
||||
// browser source, not the instrument's identity) — a self-contained instance names
|
||||
// its sound (refs displayName fallback) even when the bank snapshot is empty.
|
||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||
// (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
|
||||
else if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
@@ -128,17 +126,17 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
}
|
||||
|
||||
// Resolve the effective single-capture zone: the picked id's one-zone override when present,
|
||||
// else the product-default play params (S15-F2 — the single capture is a one-zone map). This
|
||||
// is the ONE storage site both Sample and Zone edit.
|
||||
// else the product-default play params (the single capture is a one-zone map). This is the
|
||||
// one storage site both Sample and Zone edit.
|
||||
const PerformanceZone& zone = deckZone;
|
||||
|
||||
// --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay -----------
|
||||
// Hero waveform band: envelope + markers + envelope overlay.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
const Rect waveArea = bands.hero;
|
||||
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
|
||||
if (frames > 0 && waveArea.width > 0) {
|
||||
// FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
||||
// Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
||||
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
|
||||
// partition — extra bins produce no visible change. Clamped to frame count below.
|
||||
const std::int64_t wantBins =
|
||||
@@ -168,14 +166,14 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
toLice(roleColor(markerRoles[i])), alpha, 0);
|
||||
}
|
||||
|
||||
// S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero.
|
||||
// Trace the amp-envelope overlay + its draggable node handles over the hero.
|
||||
paintEnvelopeOverlay(bmp, waveArea, zone, frames);
|
||||
} else {
|
||||
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// --- Root + preview cluster (r11: remainder-width root strip, preview button, radial
|
||||
// velocity knob, mini curve-preview button, channel toggle) -----------------------------
|
||||
// Root + preview cluster: remainder-width root strip, preview button, radial velocity
|
||||
// knob, mini curve-preview button, channel toggle.
|
||||
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
@@ -193,7 +191,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
drawButton(bmp, box, "Preview", st, /*warn=*/false);
|
||||
}
|
||||
// Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same
|
||||
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
|
||||
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
|
||||
{
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
|
||||
@@ -211,8 +209,8 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
// The mini curve-preview button (r11): opens the popup editor. Shared painter with the
|
||||
// Zone panel's button (FB2 — one grammar on both surfaces).
|
||||
// The mini curve-preview button: opens the popup editor. Shared painter with the Zone
|
||||
// panel's button — one grammar on both surfaces.
|
||||
paintCurveButton(bmp, cr.curveBtn, zone);
|
||||
// Mono | Stereo output-mode toggle.
|
||||
{
|
||||
@@ -227,10 +225,10 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
}
|
||||
|
||||
// --- The knob deck (r11: the fenced control groups, bottom-anchored) -------------------
|
||||
// The knob deck: the fenced control groups, bottom-anchored.
|
||||
paintKnobDeck(bmp, bands.deck, zone, deckDescs);
|
||||
|
||||
// --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ----
|
||||
// The curve popup: a centered sheet over the whole Sample face, drawn last.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
@@ -252,11 +250,11 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA
|
||||
const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x));
|
||||
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
|
||||
}
|
||||
// Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw-
|
||||
// only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is
|
||||
// in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd
|
||||
// at area.right()-1 MUST get handles); the handle SQUARE is additionally clamped inside the
|
||||
// hero rect so a 6px box on an edge node never overhangs into the neighbouring bands.
|
||||
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
|
||||
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
|
||||
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
|
||||
// the handle square is additionally clamped inside the hero rect so a 6px box on an edge
|
||||
// node never overhangs into the neighbouring bands.
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
for (const EnvVertex& v : poly) {
|
||||
@@ -274,8 +272,8 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
|
||||
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
|
||||
|
||||
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
|
||||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the
|
||||
// popup is the only host).
|
||||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup
|
||||
// is the only host).
|
||||
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
@@ -357,8 +355,8 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
};
|
||||
|
||||
// The knob's short name label (swapped for the live value during hover/drag — r11: no
|
||||
// third line, no permanent value clutter).
|
||||
// The knob's short name label (swapped for the live value during hover/drag — no third
|
||||
// line, no permanent value clutter).
|
||||
const auto knobName = [](ParamControl c) -> const char* {
|
||||
switch (c) {
|
||||
case ParamControl::kAttack: return "Attack";
|
||||
@@ -395,7 +393,7 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
}
|
||||
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
|
||||
|
||||
// The compact caption toggle (r11: right-anchored IN the caption row, never full-width).
|
||||
// The compact caption toggle (right-anchored in the caption row, never full-width).
|
||||
if (g.captionToggle.id >= 0) {
|
||||
switch (static_cast<ParamControl>(g.captionToggle.id)) {
|
||||
case ParamControl::kPlayMode:
|
||||
@@ -422,7 +420,7 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
}
|
||||
|
||||
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
|
||||
// stable geometry (r11).
|
||||
// stable geometry.
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
|
||||
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
|
||||
@@ -444,11 +442,11 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r,
|
||||
const PerformanceZone& zone) {
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
// The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone
|
||||
// panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced
|
||||
// in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE
|
||||
// (accent-primary border) while its popup is open, and re-renders live as the popup
|
||||
// edits the curve (same zone, re-read each paint).
|
||||
// The mini curve-preview button (shared by the Sample cluster and the Zone panel): a
|
||||
// hairline-bordered bg/cell square with the zone's live velocity curve traced in
|
||||
// miniature (no node markers at this scale). Hover lifts it; it draws Active
|
||||
// (accent-primary border) while its popup is open, and re-renders live as the popup edits
|
||||
// the curve (same zone, re-read each paint).
|
||||
const bool hov = isHovered(HoverKind::kCurveButton, -1);
|
||||
fillSurface(bmp, toKitBox(r), Role::BgCell,
|
||||
hov ? InteractionState::Hover : InteractionState::Rest);
|
||||
@@ -489,10 +487,10 @@ void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
|
||||
: InteractionState::Rest;
|
||||
drawButton(bmp, box, "x", st, /*warn=*/false);
|
||||
}
|
||||
// The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so
|
||||
// The full-size editor: one draw path + the one curveBoxFromRect mapping formula, so
|
||||
// trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() —
|
||||
// the picked capture's one-zone site on the Sample face, the selected zone on the Zone
|
||||
// surface (FB2).
|
||||
// surface.
|
||||
paintVelocityCurve(bmp, pl.curveBox, popupZone());
|
||||
}
|
||||
|
||||
@@ -502,9 +500,9 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
|
||||
const char* msg = samples_.empty()
|
||||
? "No captures in this project yet - capture audio into the bank to play it here."
|
||||
: "No captures in this bank filter. Choose another bank tab above.";
|
||||
// Split the area so the primary line sits centered and the S13 ingest affordance sits just
|
||||
// below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept
|
||||
// discoverable here regardless of whether a drop ever lands on THIS window.
|
||||
// Split the area so the primary line sits centered and the ingest affordance sits just
|
||||
// below it. The affordance is the shipped ingest gesture (drop onto the docked panel) —
|
||||
// kept discoverable here regardless of whether a drop ever lands on this window.
|
||||
Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2);
|
||||
Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom());
|
||||
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing (Q-W2v
|
||||
// split of reasampler_editor.cpp, T4-11): platform-type/resize negotiation, the child
|
||||
// window class + creation/destruction, the S9/S8 sync timer lifetime, the WM_* dispatch
|
||||
// (wndProc — paint, mouse, keyboard, capture-loss rollback, drop-accept, timer), and the
|
||||
// non-Windows stubs (D5 makes Windows the only build target; the TU still compiles
|
||||
// elsewhere).
|
||||
// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing:
|
||||
// platform-type/resize negotiation, the child window class + creation/destruction, the
|
||||
// sync timer lifetime, the WM_* dispatch (wndProc — paint, mouse, keyboard, capture-loss
|
||||
// rollback, drop-accept, timer), and the non-Windows stubs (Windows is the only build
|
||||
// target; the TU still compiles elsewhere).
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
||||
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S13 drop-accept
|
||||
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — drop-accept
|
||||
#endif
|
||||
|
||||
#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only)
|
||||
@@ -23,11 +22,11 @@ namespace reasampler::vst {
|
||||
namespace {
|
||||
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
|
||||
|
||||
// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency
|
||||
// UI-thread timer: responsive enough that a recapture/ingest/assign refreshes "within a
|
||||
// bounded cadence" (the S9 verify criterion) yet cheap — three small ext-state reads per
|
||||
// tick, coalescing many bumps between ticks into one reload. 500 ms is a deliberate
|
||||
// build-time residual. The id is a per-window SetTimer id (any nonzero).
|
||||
// The change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
|
||||
// timer: responsive enough that a recapture/ingest/assign refreshes within a bounded
|
||||
// cadence, yet cheap — three small ext-state reads per tick, coalescing many bumps
|
||||
// between ticks into one reload. 500 ms is a deliberate build-time residual. The id is a
|
||||
// per-window SetTimer id (any nonzero).
|
||||
constexpr UINT_PTR kSyncTimerId = 1;
|
||||
constexpr UINT kSyncTimerIntervalMs = 500;
|
||||
} // namespace
|
||||
@@ -45,12 +44,12 @@ tresult PLUGIN_API ReaSamplerEditor::canResize() {
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) {
|
||||
// Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before
|
||||
// every resize; clamp the proposed rect in place and return kResultTrue so the host applies the
|
||||
// (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title
|
||||
// + hero waveform + cluster + a few control rows visible (the control strip clips gracefully
|
||||
// below the panel bottom); anything smaller would clip essential UI. The default 840×620 is
|
||||
// above this floor.
|
||||
// Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this
|
||||
// before every resize; clamp the proposed rect in place and return kResultTrue so the host
|
||||
// applies the (possibly adjusted) rect rather than the raw user drag. 560x460 keeps the
|
||||
// Sample face's title + hero waveform + cluster + a few control rows visible (the control
|
||||
// strip clips gracefully below the panel bottom); anything smaller would clip essential UI.
|
||||
// The default 840x620 is above this floor.
|
||||
constexpr int kMinW = 560;
|
||||
constexpr int kMinH = 460;
|
||||
if (!rect) return kResultFalse;
|
||||
@@ -85,11 +84,11 @@ void ReaSamplerEditor::attachedToParent() {
|
||||
classRegistered = true;
|
||||
}
|
||||
|
||||
// Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a
|
||||
// reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on
|
||||
// editor close: the embed strip in the SAME binary shares the kit's process-global font
|
||||
// set, so a per-view shutdown could free fonts still in use by the other view. The tiny
|
||||
// static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note.
|
||||
// Create the kit's cached AA fonts before the first paint. Idempotent, so a reopen (or a
|
||||
// co-resident embed strip that also inits) is a cheap no-op. Not torn down on editor close:
|
||||
// the embed strip in the same binary shares the kit's process-global font set, so a
|
||||
// per-view shutdown could free fonts still in use by the other view. The tiny static HFONT
|
||||
// set is reclaimed by the OS at module unload.
|
||||
kitFontsInit();
|
||||
|
||||
refreshFromBank();
|
||||
@@ -99,17 +98,17 @@ void ReaSamplerEditor::attachedToParent() {
|
||||
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
|
||||
if (childHwnd_) {
|
||||
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
|
||||
// S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT
|
||||
// ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show
|
||||
// the "drop on the panel" affordance instead of the OS bouncing the drop silently.
|
||||
// Accept OS file drops on the editor window (WM_DROPFILES). The drop is not ingested
|
||||
// here (the relay is degraded — see onFilesDropped); accepting it lets us show the
|
||||
// "drop on the panel" affordance instead of the OS bouncing the drop silently.
|
||||
DragAcceptFiles(childHwnd_, TRUE);
|
||||
// Start the S9/S8 change-detection poll (UI thread). Tied to the child window's
|
||||
// lifetime — created here, killed in removedFromParent — so an instance whose editor
|
||||
// is closed does NOT poll (the editor-open-only cadence; see the handoff limitation).
|
||||
// Start the change-detection poll (UI thread). Tied to the child window's lifetime —
|
||||
// created here, killed in removedFromParent — so an instance whose editor is closed
|
||||
// does not poll.
|
||||
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
|
||||
// Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor
|
||||
// was closed) or a bank change applies the instant the editor opens, rather than waiting
|
||||
// up to one timer interval. refreshFromBank above already primed the view; this folds in
|
||||
// Poll once immediately so a pending assignment (an ingest fired while this editor was
|
||||
// closed) or a bank change applies the instant the editor opens, rather than waiting up
|
||||
// to one timer interval. refreshFromBank above already primed the view; this folds in
|
||||
// any pending assign/generation so the just-opened editor shows the assigned capture.
|
||||
onSyncTimer();
|
||||
}
|
||||
@@ -147,7 +146,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
case WM_LBUTTONDOWN:
|
||||
if (self) {
|
||||
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
|
||||
SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12)
|
||||
SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box
|
||||
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||
}
|
||||
return 0;
|
||||
@@ -155,9 +154,9 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
if (self) {
|
||||
const int mx = GET_X_LPARAM(lParam);
|
||||
const int my = GET_Y_LPARAM(lParam);
|
||||
// Hover feedback (Phase L, L3): resolve the element under the pointer and
|
||||
// repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover
|
||||
// clears when the pointer leaves the child (TrackMouseEvent is one-shot).
|
||||
// Hover feedback: resolve the element under the pointer and repaint on change.
|
||||
// Arm WM_MOUSELEAVE once per "over" cycle so the hover clears when the pointer
|
||||
// leaves the child (TrackMouseEvent is one-shot).
|
||||
if (!self->mouseTracking_) {
|
||||
TRACKMOUSEEVENT tme{};
|
||||
tme.cbSize = sizeof(tme);
|
||||
@@ -182,15 +181,15 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
}
|
||||
return 0;
|
||||
case WM_MOUSEWHEEL:
|
||||
// S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
|
||||
// Browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
|
||||
if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam));
|
||||
return 0;
|
||||
case WM_CHAR:
|
||||
// S12 type-to-filter search keystrokes (only acted on when the search box is focused).
|
||||
// Type-to-filter search keystrokes (only acted on when the search box is focused).
|
||||
if (self) self->onSearchChar(static_cast<unsigned int>(wParam));
|
||||
return 0;
|
||||
case WM_GETDLGCODE:
|
||||
// Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search).
|
||||
// Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (search).
|
||||
return DLGC_WANTCHARS | DLGC_WANTARROWS;
|
||||
case WM_LBUTTONUP:
|
||||
if (self) {
|
||||
@@ -199,8 +198,8 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
}
|
||||
return 0;
|
||||
case WM_RBUTTONDOWN:
|
||||
// r11: right-click — the curve popup's primary node-delete affordance (issue 3c).
|
||||
// Routed explicitly (the child wndproc historically handled only left-button).
|
||||
// Right-click — the curve popup's primary node-delete affordance. Routed
|
||||
// explicitly (the child wndproc historically handled only left-button).
|
||||
if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||
return 0;
|
||||
case WM_RBUTTONUP:
|
||||
@@ -232,16 +231,16 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
self->drag_ = DragKind::kNone;
|
||||
self->dragParamId_ = -1;
|
||||
self->dragParamZone_ = -1;
|
||||
self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset)
|
||||
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
|
||||
self->dragCurveZone_ = -1;
|
||||
self->invalidate();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DROPFILES: {
|
||||
// S13 (relay degraded): count the dropped files and flash the affordance. We do NOT
|
||||
// read/ingest the paths (the instrument never ingests — the relay to the extension is
|
||||
// unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
|
||||
// Count the dropped files and flash the affordance. We do not read/ingest the paths
|
||||
// (the instrument never ingests — the relay to the extension is unshipped);
|
||||
// DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
|
||||
HDROP drop = reinterpret_cast<HDROP>(wParam);
|
||||
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
|
||||
DragFinish(drop);
|
||||
@@ -258,7 +257,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
}
|
||||
}
|
||||
|
||||
#else // non-Windows: not a build target (D5), but keep the TU compilable.
|
||||
#else // non-Windows: not a build target, but keep the TU compilable.
|
||||
|
||||
void ReaSamplerEditor::attachedToParent() {}
|
||||
void ReaSamplerEditor::removedFromParent() {}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// editor_session.cpp — the ReaSamplerEditor's SESSION/BRIDGE state (Q-W2v split of
|
||||
// reasampler_editor.cpp, T4-11): construction, the live-bank snapshot (refreshFromBank /
|
||||
// rebuildVisible), the S9/S8 sync tick, the commit-and-reload seam, selection loading,
|
||||
// the picked-capture marker resolution/upsert helpers, and the decoded-PCM + peak
|
||||
// thumbnail caches (the mirror of bank_panel's, keyed through the pure ThumbnailKey —
|
||||
// T2-10 rider). UI thread only; every edit commits OFF the audio thread via the
|
||||
// processor's reloadInstrument.
|
||||
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
|
||||
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
|
||||
// commit-and-reload seam, selection loading, the picked-capture marker resolution/upsert
|
||||
// helpers, and the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits
|
||||
// off the audio thread via the processor's reloadInstrument.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -14,12 +12,12 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail)
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames
|
||||
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (T2-10: the pure key)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader
|
||||
#include "ext_keys.h"
|
||||
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (S12 type-to-filter)
|
||||
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
|
||||
#include "shell/instrument/reaper_bridge.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -40,11 +38,8 @@ using util::readFileBytes;
|
||||
|
||||
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||||
: CPluginView(nullptr), processor_(processor) {
|
||||
// Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample
|
||||
// home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate
|
||||
// mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll
|
||||
// on a 1080p screen with headroom. Wide enough that the control strip's label + value columns
|
||||
// read comfortably.
|
||||
// Default view size, tuned to the Sample-face band heights: title + hero waveform +
|
||||
// cluster + control strip. 840x620 clears the full face without scroll on 1080p.
|
||||
ViewRect r(0, 0, 840, 620);
|
||||
setRect(r);
|
||||
}
|
||||
@@ -52,7 +47,7 @@ ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||||
void ReaSamplerEditor::refreshFromBank() {
|
||||
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
|
||||
thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks
|
||||
pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source)
|
||||
pcmCache_.clear(); // and its decoded PCM (the waveform + snap source)
|
||||
if (!processor_) {
|
||||
samples_.clear();
|
||||
banks_.clear();
|
||||
@@ -69,17 +64,15 @@ void ReaSamplerEditor::refreshFromBank() {
|
||||
const auto prevZoneCount = static_cast<int>(map_.zones.size());
|
||||
map_ = processor_->performanceMap();
|
||||
channelMode_ = processor_->channelMode();
|
||||
voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot
|
||||
voiceCount_ = processor_->voiceCount();
|
||||
voiceMode_ = processor_->voiceMode();
|
||||
monoTrigger_ = processor_->monoTrigger();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
// r11: a refresh that emptied the selection (a bank change on the sync tick) closes the
|
||||
// curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible
|
||||
// modal would swallow clicks.
|
||||
// A refresh that emptied the selection closes the curve popup — an open-but-invisible
|
||||
// modal would otherwise swallow clicks on the empty state.
|
||||
if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false;
|
||||
// FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list
|
||||
// shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list
|
||||
// deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget).
|
||||
// On the Zone surface, close the popup if the zone count changed at all — a mid-list
|
||||
// deletion can leave selectedZone_ in range but silently naming a different zone.
|
||||
if (view_ == View::kZone && curvePopupOpen_) {
|
||||
const auto newZoneCount = static_cast<int>(map_.zones.size());
|
||||
if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false;
|
||||
@@ -94,8 +87,7 @@ void ReaSamplerEditor::refreshFromBank() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::rebuildVisible() {
|
||||
// S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search
|
||||
// narrows the survivors by name substring (nameMatchesQuery — empty query is the identity).
|
||||
// Bank filter first, then type-to-filter search narrows by name substring.
|
||||
visible_.clear();
|
||||
for (const SampleChoice& s : samples_) {
|
||||
const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_;
|
||||
@@ -103,39 +95,30 @@ void ReaSamplerEditor::rebuildVisible() {
|
||||
const std::string& name = s.displayName.empty() ? s.id : s.displayName;
|
||||
if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s);
|
||||
}
|
||||
// NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel
|
||||
// height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it
|
||||
// must not reset the user's scroll here.
|
||||
// scrollOffset_ is clamped at paint/wheel time (where layout is known); this runs on
|
||||
// the sync-timer refresh too, so it must not reset the user's scroll here.
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared
|
||||
// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes
|
||||
// Windows the only build target, but the TU must still compile elsewhere).
|
||||
// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path).
|
||||
void ReaSamplerEditor::onSyncTimer() {
|
||||
// UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the
|
||||
// processor (off the audio thread — the poll itself never touches process()). NEVER while a
|
||||
// drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the
|
||||
// user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release.
|
||||
// UI thread (WM_TIMER). Never while a drag is in flight: a reload mid-drag would
|
||||
// rebuild the instrument and repaint under the cursor, yanking the edit — the next
|
||||
// tick picks up the change after release.
|
||||
if (!processor_) return;
|
||||
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
|
||||
|
||||
// An open editor marks THIS instance the focused assignment target (the thundering-herd
|
||||
// policy — only an editor-open instance applies a pending assign; see the handoff). Pass
|
||||
// true so this instance consumes the request; instances with no editor open do not poll at
|
||||
// all (the timer is bound to the child window), so they never contend for the request.
|
||||
// An open editor is the focused assignment target (thundering-herd policy); instances
|
||||
// with no editor open never poll (the timer is bound to the child window).
|
||||
const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);
|
||||
|
||||
// Re-snapshot the editor's own view only when something changed (a reload from a bank
|
||||
// content change, or an applied assignment). refreshFromBank re-reads the bank blob + the
|
||||
// processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM
|
||||
// caches, then repaints — so the browser + setup surface reflect the new bank hands-free.
|
||||
// Re-snapshot only when something changed.
|
||||
if (r.reloaded || r.applied) {
|
||||
refreshFromBank();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
// S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop.
|
||||
// Decay the drop-affordance banner so it auto-dismisses a few ticks after a drop.
|
||||
if (dropHintTicks_ > 0) {
|
||||
--dropHintTicks_;
|
||||
invalidate();
|
||||
@@ -144,18 +127,16 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
#endif // _WIN32
|
||||
|
||||
void ReaSamplerEditor::commitAndReload() {
|
||||
// UI thread only. Publish the edited selection + zones to the processor, then rebuild
|
||||
// the instrument off the audio thread (reloadInstrument bakes them into the live Keymap).
|
||||
// pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank
|
||||
// blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the
|
||||
// moment the instance becomes self-contained for that sample.
|
||||
// UI thread only. Publishes the edited selection + zones, then rebuilds off the audio
|
||||
// thread. The reload also copies the picked capture's file ref + intrinsics into the
|
||||
// instance-owned refs table — a browser load is the moment the instance becomes
|
||||
// self-contained for that sample.
|
||||
if (!processor_) return;
|
||||
processor_->setSelectedSampleId(selectedId_);
|
||||
processor_->setPerformanceMap(map_);
|
||||
processor_->reloadInstrument();
|
||||
// GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's
|
||||
// channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode
|
||||
// the engine actually decoded with.
|
||||
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
|
||||
// toggle draws what the engine actually decoded with.
|
||||
channelMode_ = processor_->channelMode();
|
||||
#ifdef _WIN32
|
||||
invalidate();
|
||||
@@ -163,10 +144,9 @@ void ReaSamplerEditor::commitAndReload() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::loadSelection(const std::string& id) {
|
||||
// Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous
|
||||
// sample's materialized full-range zone must not linger — first-match resolve would
|
||||
// keep playing it while the editor draws the new pick's zone (matched by sampleId,
|
||||
// order-blind). Authored Zone-view maps (any narrow key range) are left untouched.
|
||||
// A Sample-face load REPLACES the loaded sound: the previous sample's materialized
|
||||
// full-range zone must not linger, or first-match resolve would keep playing it.
|
||||
// Authored Zone-view maps (narrow key ranges) are left untouched.
|
||||
selectedId_ = id;
|
||||
if (reconcileSingleCaptureZones(map_, selectedId_)) {
|
||||
selectedZone_ = map_.zones.empty() ? -1 : 0;
|
||||
@@ -176,11 +156,11 @@ void ReaSamplerEditor::loadSelection(const std::string& id) {
|
||||
|
||||
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
|
||||
SetupMarkers m;
|
||||
// Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
|
||||
// for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic
|
||||
// from the live bank blob (the same path selectSample uses); when that is not readable
|
||||
// (extension absent / not yet parsed) the instance-OWNED ref carries the same intrinsics
|
||||
// (pS fallback). The override lives in map_.
|
||||
// Seed from the bank's intrinsic loop (fact about the file), then let a per-zone override
|
||||
// for the picked id win (the instrument's performance choice). Read the loop intrinsic from
|
||||
// the live bank blob (the same path selectSample uses); when that is not readable (extension
|
||||
// absent / not yet parsed) the instance-owned ref carries the same intrinsics. The override
|
||||
// lives in map_.
|
||||
if (processor_) {
|
||||
std::optional<SelectedSample> sel;
|
||||
auto banksJson =
|
||||
@@ -215,10 +195,10 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
||||
// Find-or-append the zone for selectedId_ and write the loop/start override fields.
|
||||
// The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must
|
||||
// be non-empty; callers are responsible for that guard.
|
||||
// Returns the zone index (0-based) so callers can update selectedZone_.
|
||||
// Find-or-append the zone for selectedId_ and write the loop/start override fields. The
|
||||
// bank intrinsic is never written (read-only bank consumer). selectedId_ must be
|
||||
// non-empty; callers are responsible for that guard. Returns the zone index (0-based) so
|
||||
// callers can update selectedZone_.
|
||||
SampleLoop loop;
|
||||
loop.hasLoop = m.hasLoop;
|
||||
loop.start = m.loopStart;
|
||||
@@ -243,8 +223,8 @@ int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
||||
|
||||
PerformanceZone ReaSamplerEditor::effectiveSampleZone() const {
|
||||
// The picked id's one-zone override, if the map already carries one; else a product-default
|
||||
// zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes
|
||||
// it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean.
|
||||
// zone bound to the picked id (not appended — a read-only resolve; a control edit
|
||||
// materializes it via ensureSampleZone).
|
||||
for (const PerformanceZone& z : map_.zones) {
|
||||
if (z.sampleId == selectedId_) return z;
|
||||
}
|
||||
@@ -280,11 +260,10 @@ int ReaSamplerEditor::ensureSampleZone() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
|
||||
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert,
|
||||
// mirror of the root-marker path): a full-keyboard zone carrying the override. This plays
|
||||
// identically to the un-zoned single capture (one chromatic zone) and round-trips through
|
||||
// the component state; the zone becomes visible if the user opens the Zones panel. The bank
|
||||
// intrinsic is NEVER written (read-only bank consumer, D-B).
|
||||
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert):
|
||||
// a full-keyboard zone carrying the override. This plays identically to the un-zoned single
|
||||
// capture (one chromatic zone) and round-trips through the component state; the zone becomes
|
||||
// visible if the user opens the Zones panel. The bank intrinsic is never written.
|
||||
if (selectedId_.empty()) return;
|
||||
upsertPickedOverride(m);
|
||||
commitAndReload();
|
||||
@@ -294,11 +273,11 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
auto it = pcmCache_.find(sampleId);
|
||||
if (it != pcmCache_.end()) return it->second;
|
||||
|
||||
// SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so
|
||||
// SampleChoice is the browser's metadata projection and does not carry the WAV path, so
|
||||
// resolve the path from the live bank blob (selectSample) and decode via the shared WAV
|
||||
// parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY
|
||||
// vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width-
|
||||
// independent) — the thumbnail bins this at whatever width, the snap scans it directly.
|
||||
// parse. Every failure path caches an empty vector so a broken/missing file is not
|
||||
// re-decoded on every paint. Keyed by id (width-independent) — the thumbnail bins this at
|
||||
// whatever width, the snap scans it directly.
|
||||
std::string relativePath;
|
||||
std::vector<AudioSample> mono;
|
||||
if (processor_) {
|
||||
@@ -308,9 +287,9 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath;
|
||||
}
|
||||
if (relativePath.empty()) {
|
||||
// pS fallback: the bank blob is not readable (extension absent / not yet parsed)
|
||||
// or the id went stale there — the instance-OWNED ref still carries the path, so
|
||||
// a self-contained instance draws its loaded sound's waveform regardless.
|
||||
// Fallback: the bank blob is not readable (extension absent / not yet parsed) or
|
||||
// the id went stale there — the instance-owned ref still carries the path, so a
|
||||
// self-contained instance draws its loaded sound's waveform regardless.
|
||||
const SampleRefs refs = processor_->sampleRefs();
|
||||
if (const SelectedSample* r = findRef(refs, sampleId)) {
|
||||
relativePath = r->relativePath;
|
||||
@@ -319,8 +298,7 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
if (!relativePath.empty()) {
|
||||
const std::string projectDir = processor_->bridge().activeProjectDir();
|
||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||
// Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure.
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs); // empty on any failure
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (layout.valid) {
|
||||
std::vector<AudioSample> interleaved =
|
||||
@@ -334,17 +312,16 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
}
|
||||
|
||||
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
|
||||
// T2-10 rider: key through the PURE ThumbnailKey (bank_grid) instead of the former
|
||||
// ad-hoc "id|binCount" concat, so both thumbnail pipelines share one tested key
|
||||
// grammar (length-prefixed id — collision-proof). The editor invalidates by wholesale
|
||||
// clear() on refresh/resize, so the bank generation carries no information here — 0.
|
||||
// Key through the pure ThumbnailKey (bank_grid, length-prefixed id — collision-proof) so
|
||||
// both thumbnail pipelines share one tested key grammar. The editor invalidates by
|
||||
// wholesale clear() on refresh/resize, so the bank generation carries no information here.
|
||||
const std::string key =
|
||||
thumbnailKeyString(ThumbnailKey{sampleId, binCount, /*generation=*/0});
|
||||
auto it = thumbCache_.find(key);
|
||||
if (it != thumbCache_.end()) return it->second;
|
||||
|
||||
// Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by
|
||||
// every thumbnail width AND the S11 waveform surface + snap.
|
||||
// every thumbnail width AND the waveform surface + snap.
|
||||
const std::vector<AudioSample>& mono = monoPcmFor(sampleId);
|
||||
Envelope env;
|
||||
if (!mono.empty()) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// processor_reload.cpp — the ReaSamplerProcessor's OFF-AUDIO-THREAD instrument
|
||||
// lifecycle: reloadInstrument (self-contained refs resolve + WAV decode + keymap
|
||||
// build), the safety-critical publishBuiltLocked drain-slot swap, the voice-param
|
||||
// light rebuild, idle-drain retirement, the pre-v10 legacy-lift gate, the S9/S8
|
||||
// bank-sync poll, and the pS-usage publish. Split out of reasampler_processor.cpp
|
||||
// (Q-W2v, T4-12). NOTHING here runs on the audio thread — process() (the lifecycle
|
||||
// TU) only touches the atomics this family publishes; the atomic-pointer-swap
|
||||
// pattern deliberately gains NO virtual seam (T4-29).
|
||||
// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle:
|
||||
// reloadInstrument (self-contained refs resolve + WAV decode + keymap build), the
|
||||
// safety-critical publishBuiltLocked drain-slot swap, the voice-param light rebuild,
|
||||
// idle-drain retirement, the pre-v10 legacy-lift gate, the bank-sync poll, and the
|
||||
// usage publish. Nothing here runs on the audio thread — process() only touches the
|
||||
// atomics this family publishes; the atomic-pointer-swap pattern gains no virtual seam.
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -18,20 +16,19 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (pS self-contained)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse)
|
||||
#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam)
|
||||
#include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (self-contained)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader
|
||||
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse)
|
||||
#include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam)
|
||||
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives
|
||||
using namespace reasampler::wire; // assignment_request + sample_usage wire records
|
||||
// Q-W6 (shim retired): the shared WAV parse + file loader by their real homes.
|
||||
using capture::extractFloatFrames;
|
||||
using capture::parseWavLayout;
|
||||
using capture::resolveBankFile;
|
||||
@@ -40,20 +37,15 @@ using util::readFileBytes;
|
||||
|
||||
namespace {
|
||||
|
||||
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
||||
// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather
|
||||
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
|
||||
// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the
|
||||
// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays
|
||||
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
|
||||
// Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
||||
// materially heavier than a Varispeed voice, so a note-on past the cap is dropped rather
|
||||
// than glitching. 8 is conservative pending DAW profiling; fixed regardless of the
|
||||
// user-set voiceCount (1..32) so raising polyphony never multiplies shifter CPU past budget.
|
||||
constexpr std::size_t kPreserveVoiceCap = 8;
|
||||
|
||||
// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy
|
||||
// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the
|
||||
// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic
|
||||
// strength) is the requirement: two instances sharing a key is the copy-collision
|
||||
// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision
|
||||
// vanishingly unlikely. Off-thread only.
|
||||
// Mints a fresh publish identity (32 lowercase hex chars) for either the persisted
|
||||
// instanceGuid_ or the in-memory usageNonce_. Uniqueness, not cryptographic strength, is
|
||||
// the requirement — planUsagePublish resolves a collision fail-safe anyway.
|
||||
std::string mintUsageInstanceGuid() {
|
||||
std::random_device rd;
|
||||
std::mt19937_64 gen((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
|
||||
@@ -65,17 +57,11 @@ std::string mintUsageInstanceGuid() {
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03).
|
||||
// Off-thread only (blocking file I/O). Empty on any failure — the caller treats
|
||||
// an unreadable WAV as "nothing to play".
|
||||
|
||||
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
|
||||
// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode
|
||||
// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for
|
||||
// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path
|
||||
// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no
|
||||
// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by
|
||||
// the zoned build and the single-capture path so both decode identically for the active mode.
|
||||
// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only),
|
||||
// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono
|
||||
// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any
|
||||
// resolve/read/decode failure — the caller drops the zone or plays silence. Shared by the
|
||||
// zoned build and the single-capture path.
|
||||
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
const std::string& relativePath,
|
||||
ChannelMode mode) {
|
||||
@@ -95,22 +81,19 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
} // namespace
|
||||
|
||||
std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so
|
||||
// the retired-slot free is single-writer. This mutex is NEVER taken on the audio
|
||||
// thread — process() only touches the atomic.
|
||||
// OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the
|
||||
// retired-slot free is single-writer; never taken on the audio thread.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
|
||||
// Mint this reload's generation number first so we can stamp the built instrument
|
||||
// with it before publishing. Under reloadMutex_ no other reload races here.
|
||||
// Mint this reload's generation number first so the built instrument is stamped
|
||||
// before publishing.
|
||||
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
|
||||
// 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of
|
||||
// truth for what to decode. The live bank blob, WHEN readable, is folded into the
|
||||
// table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in
|
||||
// mechanism and the S9 recapture sync in one — but its absence changes NOTHING
|
||||
// below: a project restored before the extension's PROJEXTSTATE parses (or with
|
||||
// the extension absent entirely) resolves + plays from the persisted refs. The
|
||||
// project dir comes from REAPER itself (EnumProjects), not from the extension.
|
||||
// 1. Self-contained resolution: the instance-owned refs table is the source of truth.
|
||||
// The live bank blob, when readable, is folded in first (refreshRefsFromBank — the
|
||||
// browser's copy-the-ref-in + recapture-sync mechanism), but its absence changes
|
||||
// nothing below — a project restored before PROJEXTSTATE parses (or with the
|
||||
// extension absent) resolves + plays from the persisted refs.
|
||||
const std::string selId = selectedSampleId();
|
||||
const PerformanceMap map = performanceMap();
|
||||
const std::vector<std::string> ids = referencedSampleIds(selId, map);
|
||||
@@ -120,20 +103,18 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
||||
std::lock_guard<std::mutex> rl(refsMutex_);
|
||||
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
|
||||
// The LOAD path never prunes the owned table: dropping entries here on a transient
|
||||
// bank miss could destroy the owned intrinsics of the previous selection — the ONE
|
||||
// copy that survives with the extension absent. Entries for de-referenced ids stay
|
||||
// in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary,
|
||||
// where getState filters its snapshot via retainRefs to what the instance plays.
|
||||
// The LOAD path never prunes the owned table: dropping entries on a transient bank
|
||||
// miss could destroy the owned intrinsics of the previous selection — the ONE copy
|
||||
// that survives with the extension absent. Hygiene lives at the PERSIST boundary
|
||||
// (getState filters via retainRefs to what the instance plays).
|
||||
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
|
||||
}
|
||||
const std::string projectDir = bridge_.activeProjectDir();
|
||||
// The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
|
||||
// Read once under its mutex, off the audio thread, before the decode loop. The single-
|
||||
// capture branch below may auto-default it (GA) before its decode.
|
||||
// Governs how each WAV decodes (mono downmix vs 2-channel); the single-capture branch
|
||||
// below may auto-default it before its decode.
|
||||
ChannelMode mode = channelMode();
|
||||
// Phase S: snapshot the voice-system parameters once — they are baked into the built
|
||||
// engine's construction (the engine's config is immutable; a later change rebuilds).
|
||||
// Snapshot the voice-system parameters once — baked into the built engine's
|
||||
// construction (immutable config; a later change rebuilds).
|
||||
int builtVoiceCount = kDefaultVoiceCount;
|
||||
VoiceMode builtVoiceMode = VoiceMode::Poly;
|
||||
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
|
||||
@@ -149,12 +130,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
Keymap km;
|
||||
bool haveKeymap = false;
|
||||
|
||||
// 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
|
||||
// zones against the OWNED refs (an id with no ref drops cleanly), decode each
|
||||
// zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays
|
||||
// its sample repitched from its effective root note (override > ref intrinsic >
|
||||
// C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped
|
||||
// (not the whole map): the defined no-play, no crash, no retry loop.
|
||||
// 2. Zoned build: if the performance map is non-empty, resolve its zones against the
|
||||
// owned refs (an id with no ref drops cleanly), decode each zone's WAV off-thread,
|
||||
// and build the keymap. A zone whose WAV fails to decode is dropped, not the whole
|
||||
// map — the defined no-play, no crash, no retry loop.
|
||||
if (!map.empty()) {
|
||||
const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map);
|
||||
if (!resolved.zones.empty()) {
|
||||
@@ -174,19 +153,15 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Single-capture fast path (S10): an empty performance map plays the ONE
|
||||
// deliberately-selected capture chromatically across the whole keyboard, resolved
|
||||
// against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a
|
||||
// selection with no ref) resolves to nothing, so an un-picked instrument stays
|
||||
// SILENT (the editor shows its "pick a capture" empty state) rather than
|
||||
// auto-playing sample #1 (S10 policy reversal of the S4 convenience default).
|
||||
// 3. Single-capture fast path: an empty performance map plays the one selected capture
|
||||
// chromatically across the whole keyboard. No first-sample fallback: an empty
|
||||
// selection (or one with no ref) resolves to nothing, so an un-picked instrument
|
||||
// stays silent rather than auto-playing sample #1.
|
||||
if (!haveKeymap) {
|
||||
if (const SelectedSample* sel = findRef(refs, selId)) {
|
||||
// GA auto-default: channelModeFor computes the mode from the loaded capture's
|
||||
// REQUESTED channel count (always 2 for extension captures; mono only for
|
||||
// ingest-imported mono files). An unknown count (0) or explicit user choice
|
||||
// returns the current mode unchanged. Decode-only: the output bus is fixed
|
||||
// stereo, so no bus work follows a flip.
|
||||
// Auto-default: channelModeFor computes the mode from the loaded capture's
|
||||
// channel count (always 2 for extension captures; mono only for ingest-imported
|
||||
// mono files). An unknown count (0) or explicit user choice keeps the mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||
@@ -206,10 +181,9 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
}
|
||||
|
||||
if (haveKeymap) {
|
||||
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
|
||||
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
|
||||
// note-on never allocates. Floored at 2 so a valid window is always a real ring
|
||||
// (which also covers a pathological host rate <= 0 — no rate literal needed).
|
||||
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs),
|
||||
// pre-sized here so process()-time note-on never allocates. Floored at 2 so a
|
||||
// valid window is always a real ring, covering a pathological host rate <= 0 too.
|
||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
@@ -218,21 +192,14 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||
}
|
||||
|
||||
// 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the
|
||||
// DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices —
|
||||
// a reload never cuts a sounding note; the next note-on plays the new state. The
|
||||
// instrument evicted FROM the drain slot (two reloads old) goes to the graveyard
|
||||
// (process may still be mid-block reading it). A null `built` (no ref / unreadable
|
||||
// WAV) installs silence while the displaced tails still ring out via the drain.
|
||||
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
|
||||
// pointer is re-owned by the graveyard.
|
||||
// 4. Publish: atomically install the new instrument via the drain-slot swap (see the
|
||||
// header). A null `built` (no ref / unreadable WAV) installs silence while any
|
||||
// displaced tails still ring out via the drain.
|
||||
publishBuiltLocked(std::move(built));
|
||||
|
||||
// 5. pS-usage: publish this instance's held captures so the extension's prune can
|
||||
// never reclaim them (see publishUsage). AFTER the instrument swap, still off the
|
||||
// audio thread and under reloadMutex_. Publishes regardless of decode success:
|
||||
// the holds are the refs the instance RETAINS (its play-set), not what decoded —
|
||||
// a transiently unreadable WAV must stay protected.
|
||||
// 5. Publish this instance's held captures so the extension's prune can never reclaim
|
||||
// them. Regardless of decode success: the holds are the refs the instance retains
|
||||
// (its play-set), not what decoded — a transiently unreadable WAV stays protected.
|
||||
publishUsage(refs, ids);
|
||||
return resolvedId;
|
||||
}
|
||||
@@ -252,15 +219,13 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||
// A never-published instance with nothing held writes nothing — no key litter for
|
||||
// fresh/empty instances. Once an identity exists, empties DO publish (they release
|
||||
// holds the prune would otherwise keep protecting).
|
||||
// A never-published instance with nothing held writes nothing (no key litter); once an
|
||||
// identity exists, empties do publish (releasing protected holds).
|
||||
if (instanceGuid_.empty() && mine.holds.empty()) return;
|
||||
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
|
||||
// The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so
|
||||
// planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track
|
||||
// sibling's byte-identical hold set can never pass as ours (its nonce differs), so
|
||||
// siblings always union and never clean-replace over each other's held paths.
|
||||
// The per-lifetime owner nonce (UsageRecord.ownerNonce) lets planUsagePublish prove
|
||||
// "exactly this incarnation wrote the key" — a same-track sibling's byte-identical hold
|
||||
// set can never pass as ours, so siblings always union rather than clean-replace.
|
||||
if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid();
|
||||
mine.ownerNonce = usageNonce_;
|
||||
|
||||
@@ -268,10 +233,9 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||
bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
|
||||
const UsagePublishPlan plan = planUsagePublish(existing, mine);
|
||||
if (plan.remint) {
|
||||
// This state was cloned onto another track (FX copy / track duplication): take a
|
||||
// fresh identity and leave the original's record untouched. The abandoned old
|
||||
// identity's record dies by the extension's liveness rule when its track no
|
||||
// longer hosts an instance. getState persists the new guid on the next save.
|
||||
// Cloned onto another track (FX copy / track duplication): take a fresh identity;
|
||||
// the abandoned old record dies by the extension's liveness rule once its track no
|
||||
// longer hosts an instance.
|
||||
instanceGuid_ = mintUsageInstanceGuid();
|
||||
} else if (plan.skipWrite) {
|
||||
return; // idle tick, or a union that adds nothing — no ext-state churn
|
||||
@@ -280,15 +244,8 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
||||
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
|
||||
// reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance.
|
||||
//
|
||||
// Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is
|
||||
// the minimum installedAt process() published over the pointers it holds. Both
|
||||
// slots are monotone in installedAt, so seen is monotone and any future process()
|
||||
// load yields installedAt >= seen — an entry below seen is provably unreachable
|
||||
// (see the header proof). Remaining entries drain at setActive(false) / terminate()
|
||||
// when process is guaranteed stopped.
|
||||
// REQUIRES reloadMutex_ held. Shared by reloadInstrument and rebuildVoiceEngine — the
|
||||
// one safety-critical swap dance (see the header's drain-slot proof).
|
||||
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
||||
graveyard_.erase(
|
||||
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
||||
@@ -302,10 +259,9 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
// OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract:
|
||||
// a voice-param change touches NO audio data, so this rebuilds the engine
|
||||
// around a COPY of the live instrument's already-decoded keymap — no bridge, no disk —
|
||||
// and publishes through the same drain-slot swap, so ringing tails survive.
|
||||
// Off the audio thread. A voice-param change touches no audio data, so this rebuilds
|
||||
// the engine around a copy of the live instrument's already-decoded keymap — no
|
||||
// bridge, no disk — and publishes through the same drain-slot swap.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
|
||||
if (!cur) return; // nothing loaded: the new params bake into the next real reload.
|
||||
@@ -321,13 +277,14 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
}
|
||||
|
||||
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
// Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate).
|
||||
// Same Preserve-window derivation as reloadInstrument.
|
||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
|
||||
// Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap
|
||||
// is immutable after construction, and under reloadMutex_ nobody can free `cur`.
|
||||
// Deep-copy the decoded PCM + zones: safe to read concurrently with process() because
|
||||
// the keymap is immutable after construction and reloadMutex_ prevents `cur` from
|
||||
// being freed.
|
||||
Keymap km = cur->keymap;
|
||||
auto built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
@@ -336,23 +293,19 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::retireIdleDrain() {
|
||||
// Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or
|
||||
// it still sounds" — the common case costs one relaxed load and no mutex.
|
||||
// Cheap early-out before the lock: 0 means "no drain, or it still sounds".
|
||||
const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
|
||||
if (idleGen == 0) return;
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
LoadedInstrument* drain = draining_.load(std::memory_order_acquire);
|
||||
// Retire ONLY if the publication names the drain currently in the slot. A stale value
|
||||
// (about an already-evicted, older drain) can never match the newer occupant's
|
||||
// installedAt — the slot is monotone in generation — so a mid-swap race is closed by
|
||||
// this identity check, not by timing.
|
||||
// Retire only if the publication names the drain currently in the slot — a stale value
|
||||
// (an already-evicted, older drain) can never match the newer occupant's installedAt
|
||||
// (monotone in generation), closing a mid-swap race by identity rather than timing.
|
||||
if (!drain || drain->installedAt != idleGen) return;
|
||||
draining_.store(nullptr, std::memory_order_release);
|
||||
graveyard_.push_back(std::unique_ptr<LoadedInstrument>(drain));
|
||||
// Prune what is now provably unreachable — the same monotone-generation proof as the
|
||||
// reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be
|
||||
// held by process() now or ever again. The just-parked drain frees here immediately when
|
||||
// process() has already published past it; otherwise on the next reload/retire/deactivate.
|
||||
// Prune what is now provably unreachable (same monotone-generation proof as
|
||||
// reloadInstrument's reclaim).
|
||||
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
||||
graveyard_.erase(
|
||||
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
||||
@@ -363,18 +316,16 @@ void ReaSamplerProcessor::retireIdleDrain() {
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::legacyLiftShouldRun() {
|
||||
// #A terminating guard for the pre-v10 legacy lift. The caller has already established
|
||||
// refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before
|
||||
// paying for a full reload. Once concluded, the steady state is this one relaxed load —
|
||||
// no bank read, no parse, no reload churn.
|
||||
// Terminating guard for the pre-v10 legacy lift (caller has already established
|
||||
// refs-empty + intent). Once concluded, the steady state is one relaxed load — no bank
|
||||
// read, no parse, no reload churn.
|
||||
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
|
||||
const LegacyLiftDecision decision = legacyLiftDecision(
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey),
|
||||
referencedSampleIds(selectedSampleId(), performanceMap()));
|
||||
if (decision == LegacyLiftDecision::Stale) {
|
||||
// Provably stale (the bank parses and knows none of the referenced ids): give up
|
||||
// PERMANENTLY. A later bank change that re-introduces an id bumps the generation,
|
||||
// and the genChanged reload refreshes the refs without consulting this latch.
|
||||
// Provably stale: give up permanently. A later bank change that re-introduces an
|
||||
// id bumps the generation, and genChanged refreshes the refs without this latch.
|
||||
legacyLiftConcluded_.store(true, std::memory_order_relaxed);
|
||||
return false;
|
||||
}
|
||||
@@ -383,21 +334,18 @@ bool ReaSamplerProcessor::legacyLiftShouldRun() {
|
||||
|
||||
ReaSamplerProcessor::BankSyncResult
|
||||
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
// OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call
|
||||
// REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER
|
||||
// host, or before connect) yields nullopt for both reads, so this no-ops cleanly.
|
||||
// Off the audio thread (editor's UI timer only). A disconnected bridge yields nullopt
|
||||
// for both reads, so this no-ops cleanly.
|
||||
BankSyncResult result;
|
||||
|
||||
// Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer
|
||||
// cadence that drives reloads — an edited-away instrument stops costing memory as soon
|
||||
// as its tails die instead of squatting in the drain slot until the next reload.
|
||||
// Park an idle drain snapshot in the graveyard on the same cadence that drives
|
||||
// reloads, so an edited-away instrument stops costing memory as soon as tails die.
|
||||
retireIdleDrain();
|
||||
|
||||
// --- S8: assignment-request consume FIRST -------------------------------------
|
||||
// Decode the pending assignment request (nullopt when absent/malformed). Resolve its
|
||||
// (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when
|
||||
// the sampleId names an existing sample (the reader requirement — an unresolvable pair is
|
||||
// dropped). Then run the pure consume decision against this instance's persisted marker.
|
||||
// --- Assignment-request consume first -------------------------------------------
|
||||
// Decodes the pending assignment request (nullopt if absent/malformed), resolves its
|
||||
// sampleId against the live bank blob (an unresolvable pair is dropped), then runs the
|
||||
// pure consume decision against this instance's persisted marker.
|
||||
std::optional<AssignmentRequest> request;
|
||||
if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) {
|
||||
request = decodeAssignmentRequest(*raw);
|
||||
@@ -405,26 +353,24 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
|
||||
bool resolves = false;
|
||||
if (request) {
|
||||
// Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request
|
||||
// whose sample was rolled back by an extension undo resolves to nullopt -> dropped).
|
||||
// Resolve against the CURRENT bank blob (a fresh read, so a request whose sample
|
||||
// was rolled back by an extension undo resolves to nullopt -> dropped).
|
||||
if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) {
|
||||
resolves = selectSample(*banksJson, request->sampleId).has_value();
|
||||
}
|
||||
}
|
||||
|
||||
// Read lastConsumed and conditionally write it back under a single lock scope so there
|
||||
// is no interleave window between the read and the write (a concurrent getState could
|
||||
// otherwise observe a stale marker between the two separate lock acquisitions).
|
||||
// Read + conditionally write lastConsumed under one lock scope so a concurrent
|
||||
// getState cannot observe a stale marker between two separate acquisitions.
|
||||
std::int64_t lastConsumed = 0;
|
||||
const AssignConsumeDecision decision = [&] {
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
lastConsumed = lastConsumedAssignGeneration_;
|
||||
const AssignConsumeDecision d =
|
||||
consumeDecision(request, lastConsumed, resolves, isFocusedTarget);
|
||||
// Advance the persisted consumed marker whenever the decision consumed the request
|
||||
// (applied OR dropped-as-seen). getState will persist it on the next project save so
|
||||
// a re-open does not re-apply. A non-target instance leaves the marker (decision
|
||||
// returns it unchanged) so it stays eligible if focus later lands here.
|
||||
// Advance the persisted marker whenever the decision consumed the request
|
||||
// (applied or dropped-as-seen); a non-target instance leaves it unchanged so it
|
||||
// stays eligible if focus later lands here.
|
||||
if (d.consumedGeneration != lastConsumed) {
|
||||
lastConsumedAssignGeneration_ = d.consumedGeneration;
|
||||
}
|
||||
@@ -432,13 +378,12 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
}();
|
||||
|
||||
if (decision.apply) {
|
||||
// Apply the assignment as this instance's own selection (the same path a user card-pick
|
||||
// takes) — the instrument updates its OWN state, never the bank. reloadInstrument below
|
||||
// rebuilds against the new selection, so skip a redundant reload here.
|
||||
// Apply as this instance's own selection (the instrument updates its own state,
|
||||
// never the bank); reloadInstrument below rebuilds against it.
|
||||
setSelectedSampleId(decision.sampleId);
|
||||
// Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone
|
||||
// materialized for the previously loaded sample would shadow the assigned pick under
|
||||
// first-match resolve. Authored maps (any narrow key range) are untouched.
|
||||
// Peer of the editor's Browse Load: a stale full-range zone from the previous
|
||||
// sample would shadow the assigned pick under first-match resolve. Authored maps
|
||||
// (narrow key ranges) are untouched.
|
||||
PerformanceMap reconciled = performanceMap();
|
||||
if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) {
|
||||
setPerformanceMap(reconciled);
|
||||
@@ -446,13 +391,10 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
result.applied = true;
|
||||
}
|
||||
|
||||
// --- S9: bank-generation change-detection -------------------------------------
|
||||
// Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll
|
||||
// (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload —
|
||||
// setState already loaded the instrument from its OWNED refs (pS), so a redundant reload
|
||||
// on open would only churn. A later
|
||||
// generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the
|
||||
// reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced).
|
||||
// --- Bank-generation change-detection -------------------------------------------
|
||||
// First poll (lastSeenBankGeneration_ == -1 sentinel) baselines without a reload —
|
||||
// setState already loaded from owned refs, so a redundant reload on open would only
|
||||
// churn. A later generation change (recapture/ingest/remove/undo) drives the reload.
|
||||
std::int64_t currentGen = kBankGenerationAbsent;
|
||||
if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) {
|
||||
currentGen = parseBankGeneration(*rawGen);
|
||||
@@ -462,18 +404,12 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
!firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen);
|
||||
lastSeenBankGeneration_ = currentGen;
|
||||
|
||||
// LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones)
|
||||
// but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had
|
||||
// nothing to decode unless the bank happened to be readable already. Reload on this
|
||||
// editor tick until the lift lands: reloadInstrument folds the bank blob into the refs
|
||||
// when readable, after which the table is non-empty and this never fires again (the
|
||||
// next save is then self-contained). A deliberately-empty instance has no intent and
|
||||
// never churns; a bank that is not readable YET retries a cheap null publish on the
|
||||
// editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob
|
||||
// PARSES and no referenced id resolves in it, the ids are provably stale — there is
|
||||
// nothing to lift, so the lift concludes permanently instead of churning a full bank
|
||||
// read + reload every tick forever. This is a MIGRATION convenience for old projects,
|
||||
// NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS).
|
||||
// Legacy lift (pre-v10 blob): restored state carries intent but no owned refs (old
|
||||
// blobs had no path table). Reload on this tick until reloadInstrument folds the bank
|
||||
// blob into the refs (after which this never fires again — the next save is
|
||||
// self-contained). legacyLiftShouldRun concludes permanently once the bank parses and
|
||||
// no referenced id resolves — a migration convenience only, never a playback
|
||||
// dependency (a v10 blob plays from its refs with no poll at all).
|
||||
bool legacyLift = false;
|
||||
if (!genChanged && !result.applied && sampleRefs().empty()) {
|
||||
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
|
||||
@@ -481,10 +417,9 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
}
|
||||
|
||||
if (genChanged || result.applied || legacyLift) {
|
||||
reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
|
||||
// Report the reload distinctly from an S8 apply so the editor re-snapshots its bank
|
||||
// view. A legacy lift counts only when it actually landed an instrument (otherwise
|
||||
// every retry tick would churn the editor's caches for nothing).
|
||||
reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play
|
||||
// Reported distinctly from an applied assignment so the editor re-snapshots its
|
||||
// bank view; a legacy lift counts only when it actually landed an instrument.
|
||||
result.reloaded =
|
||||
genChanged ||
|
||||
(legacyLift && live_.load(std::memory_order_acquire) != nullptr);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// processor_state.cpp — the ReaSamplerProcessor's COMPONENT-STATE I/O (setState /
|
||||
// getState against the component_state_io codec) and its UI-thread parameter
|
||||
// accessors/setters (selection, performance map, channel mode, preview velocity,
|
||||
// voice-system params, master gain, preview-note mailbox posts). Split out of
|
||||
// reasampler_processor.cpp (Q-W2v, T4-12). Everything here runs OFF the audio
|
||||
// thread (UI / host load-save); the setters hand work to the reload family
|
||||
// (processor_reload.cpp) or store atomics process() picks up at block start.
|
||||
// processor_state.cpp — ReaSamplerProcessor's component-state I/O (setState/getState
|
||||
// against the component_state_io codec) and its UI-thread parameter accessors/setters
|
||||
// (selection, performance map, channel mode, preview velocity, voice-system params,
|
||||
// master gain, preview-note mailbox posts). Everything here runs off the audio thread;
|
||||
// setters hand work to the reload family (processor_reload.cpp) or store atomics
|
||||
// process() picks up at block start.
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -14,8 +13,8 @@
|
||||
|
||||
#include "pluginterfaces/base/ibstream.h"
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
|
||||
#include "core/instrument/map/component_state_io.h" // the ComponentState codec (Q-W2v split)
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp)
|
||||
#include "core/instrument/map/component_state_io.h" // the ComponentState codec
|
||||
#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds
|
||||
|
||||
using namespace Steinberg;
|
||||
@@ -24,135 +23,107 @@ using namespace Steinberg::Vst;
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace instrument::map; // the codec + resolution vocabulary this TU marshals
|
||||
using instrument::engine::masterGainMaxLinear; // FB1 taper ceiling (Q-W6: shim retired)
|
||||
using instrument::engine::masterGainMaxLinear; // taper ceiling
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Read the whole component-state blob (the performance map, versioned). The blob is
|
||||
// small; read in one shot into a growable buffer.
|
||||
// The blob is small; read it in one shot into a growable buffer.
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::uint8_t chunk[256];
|
||||
int32 got = 0;
|
||||
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
|
||||
bytes.insert(bytes.end(), chunk, chunk + got);
|
||||
}
|
||||
// Component state (v3, S10) is {single-capture selection id, opt-in zones}. The
|
||||
// selection and the zones are DISTINCT — the default face is one picked capture, zones
|
||||
// are a demoted overlay — so both are restored explicitly (no more inferring a selection
|
||||
// from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only
|
||||
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
|
||||
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
|
||||
// silent empty state (no first-sample fallback in reloadInstrument).
|
||||
// Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at
|
||||
// the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing
|
||||
// before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a
|
||||
// pre-setup call would assert inside readZonesPayload (a programming error, not a field case).
|
||||
// Component state is {single-capture selection id, opt-in zones}, restored explicitly
|
||||
// since they're distinct (default face vs. a demoted overlay). deserializeComponentState
|
||||
// lifts older blobs cleanly (no first-sample fallback in reloadInstrument). sampleRate_
|
||||
// is the real host rate here — REAPER calls setupProcessing before setState on load.
|
||||
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
||||
setSelectedSampleId(cs.selectionId);
|
||||
// Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a
|
||||
// pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the
|
||||
// saved selection under first-match resolve. Reconciling here restores "the sample the
|
||||
// editor shows is the sample the engine plays" for already-affected projects; authored
|
||||
// Zone-view maps (any narrow key range) pass through untouched.
|
||||
// Heal-on-load: a blob saved under the pre-fix editor may carry stale full-range zones
|
||||
// (one per sample ever browsed), the oldest shadowing the saved selection under
|
||||
// first-match resolve. Authored Zone-view maps (narrow key ranges) pass through untouched.
|
||||
PerformanceMap restored = cs.map;
|
||||
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load
|
||||
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: reload below runs unconditionally
|
||||
setPerformanceMap(restored);
|
||||
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||
// stale assign_request (the user may have manually changed the selection after the assign).
|
||||
// Restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||
// stale assign_request.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration;
|
||||
}
|
||||
// Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see
|
||||
// initialize) — the mode only governs how the reload below decodes, so no bus work here.
|
||||
// The output bus is fixed stereo (see initialize) — the mode only governs decode below.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
channelMode_ = cs.channelMode;
|
||||
channelModeExplicit_ = cs.channelModeExplicit;
|
||||
}
|
||||
// S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2
|
||||
// the editor's velocity knob is a concurrent UI-thread writer.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(previewMutex_);
|
||||
previewVelocity_ = cs.previewVelocity;
|
||||
}
|
||||
// Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly,
|
||||
// Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the
|
||||
// reload below so the rebuilt engine is born with the saved polyphony/mode.
|
||||
// Restore before the reload so the rebuilt engine is born with the saved polyphony/mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
voiceCount_ = cs.voiceCount;
|
||||
voiceMode_ = cs.voiceMode;
|
||||
monoTrigger_ = cs.monoTrigger;
|
||||
}
|
||||
// FB1: restore the post-mixer master gain (v8; older blobs lift to unity in
|
||||
// deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks
|
||||
// it up at the next block start.
|
||||
setMasterGainLinear(cs.masterGainLinear);
|
||||
// pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the
|
||||
// reload so it decodes straight from them — no bank read required to play. A pre-v10
|
||||
// blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob
|
||||
// becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift),
|
||||
// after which the next save is self-contained.
|
||||
// Restore the instance-owned sample refs before the reload so it decodes straight from
|
||||
// them — no bank read required. A pre-v10 blob lifts to an empty table; the reload
|
||||
// resolves nothing until the bank blob becomes readable (opportunistic refresh, or
|
||||
// pollBankSync's legacy lift), after which the next save is self-contained.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(refsMutex_);
|
||||
sampleRefs_ = cs.sampleRefs;
|
||||
}
|
||||
// pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty —
|
||||
// minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME
|
||||
// for the copy-collision analysis (the fresh nonce means this incarnation can never
|
||||
// be mistaken for the previous one's writes — or for a copy-sibling's).
|
||||
// Restore the publish identity (pre-v11 lifts to empty, minted on first publish).
|
||||
// usageNonce_ resets: a restored blob is a new lifetime, so this incarnation can never
|
||||
// be mistaken for the previous one's writes or a copy-sibling's.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||
instanceGuid_ = cs.instanceGuid;
|
||||
usageNonce_.clear();
|
||||
}
|
||||
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
|
||||
// not carry over (#A — the legacy lift gets one fresh run per restored state).
|
||||
// A new blob is new facts — the legacy lift gets one fresh run per restored state.
|
||||
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
||||
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
||||
reloadInstrument();
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Persist the full instance state (v3, S10): the single-capture selection id AND the
|
||||
// opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler"
|
||||
// bank ext-state. An instance with no pick and no zones serializes to {"", no zones}
|
||||
// and restores as the S10 empty state (silence + "pick a capture"), never auto-playing
|
||||
// sample #1.
|
||||
// Persists the full instance state — never written to the "reasampler" bank ext-state.
|
||||
// No pick + no zones serializes to {"", no zones}, restoring as silence (never
|
||||
// auto-playing sample #1).
|
||||
ComponentState state_out;
|
||||
state_out.selectionId = selectedSampleId();
|
||||
state_out.map = performanceMap();
|
||||
{
|
||||
// S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9).
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
state_out.channelMode = channelMode_;
|
||||
state_out.channelModeExplicit = channelModeExplicit_;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
|
||||
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_;
|
||||
}
|
||||
state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity
|
||||
state_out.previewVelocity = previewVelocity();
|
||||
{
|
||||
// Phase S: persist the voice-system parameters (component state v7).
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
state_out.voiceCount = voiceCount_;
|
||||
state_out.voiceMode = voiceMode_;
|
||||
state_out.monoTrigger = monoTrigger_;
|
||||
}
|
||||
state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8)
|
||||
// pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to
|
||||
// decode + play with no extension present. Filtered (on the snapshot copy, the member is
|
||||
// untouched) to exactly what the instance currently plays, so the table cannot grow with
|
||||
// browsing history.
|
||||
state_out.masterGainLinear = masterGainLinear();
|
||||
// Persist the owned sample refs — the saved blob decodes + plays with no extension
|
||||
// present. Filtered (snapshot copy only) to what the instance currently plays, so the
|
||||
// table cannot grow with browsing history.
|
||||
state_out.sampleRefs = sampleRefs();
|
||||
retainRefs(state_out.sampleRefs,
|
||||
referencedSampleIds(state_out.selectionId, state_out.map));
|
||||
// pS-usage: persist the publish identity (v11) so the instance's usage key is
|
||||
// stable across sessions (records do not proliferate per reopen).
|
||||
// Persist the publish identity so the usage key is stable across sessions.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||
state_out.instanceGuid = instanceGuid_;
|
||||
@@ -202,8 +173,7 @@ std::uint8_t ReaSamplerProcessor::previewVelocity() {
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) {
|
||||
// Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview
|
||||
// strike must sound). The editor's knob maps its 0..1 domain into this range before calling.
|
||||
// Clamp to [1,127] — 0 would be a note-off by convention, and a preview strike must sound.
|
||||
if (velocity < 1) velocity = 1;
|
||||
if (velocity > 127) velocity = 127;
|
||||
std::lock_guard<std::mutex> lock(previewMutex_);
|
||||
@@ -216,8 +186,8 @@ int ReaSamplerProcessor::voiceCount() {
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setVoiceCount(int count) {
|
||||
// Clamp to the shared pure-core range so the engine, the state bytes, and the editor's
|
||||
// control can never disagree about the legal polyphony span.
|
||||
// Clamp to the shared pure-core range so the engine, state bytes, and editor control
|
||||
// can never disagree about the legal polyphony span.
|
||||
if (count < kMinVoiceCount) count = kMinVoiceCount;
|
||||
if (count > kMaxVoiceCount) count = kMaxVoiceCount;
|
||||
{
|
||||
@@ -225,11 +195,8 @@ void ReaSamplerProcessor::setVoiceCount(int count) {
|
||||
if (voiceCount_ == count) return; // no-op: don't churn a rebuild
|
||||
voiceCount_ = count;
|
||||
}
|
||||
// LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from
|
||||
// the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change
|
||||
// touches no audio data) and the displaced instrument keeps rendering its ringing tails,
|
||||
// so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every
|
||||
// zone from disk. Same contract for the mode/trigger setters below.
|
||||
// Light rebuild through the drain-slot swap (no bridge re-read, no WAV re-decode) so a
|
||||
// voice-param change never cuts a sounding tail. Same contract below.
|
||||
rebuildVoiceEngine();
|
||||
}
|
||||
|
||||
@@ -262,9 +229,8 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
|
||||
// Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap =
|
||||
// +24 dB). One relaxed atomic store — the audio thread reads it at the next block start;
|
||||
// no rebuild, no lock (a post-sum output trim is not a keymap fact).
|
||||
// Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic
|
||||
// store — no rebuild, no lock (a post-sum trim is not a keymap fact).
|
||||
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
|
||||
const double maxLin = masterGainMaxLinear();
|
||||
if (linear > maxLin) linear = maxLin;
|
||||
@@ -275,9 +241,8 @@ void ReaSamplerProcessor::previewNoteOn(int note) {
|
||||
if (note < 0) note = 0;
|
||||
if (note > 127) note = 127;
|
||||
const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request
|
||||
// Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as
|
||||
// long as we never land back on the exact value the audio thread last consumed in one step —
|
||||
// 16 bits gives 65535 posts between collisions, unreachable at UI-click rates).
|
||||
// Advance the sequence (wrapping; process compares for inequality — 16 bits gives 65535
|
||||
// posts between collisions, unreachable at UI-click rates).
|
||||
const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_;
|
||||
const std::uint32_t packed = (static_cast<std::uint32_t>(seq) << 16) |
|
||||
(static_cast<std::uint32_t>(vel) << 8) |
|
||||
@@ -297,15 +262,14 @@ void ReaSamplerProcessor::previewNoteOff(int note) {
|
||||
void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
// The editor toggle is a DELIBERATE choice either way: latch explicit even on a
|
||||
// same-mode click (the user confirmed the mode; the GA auto-default stops fighting it).
|
||||
// A deliberate choice either way: latch explicit even on a same-mode click so
|
||||
// auto-default stops fighting it.
|
||||
channelModeExplicit_ = true;
|
||||
if (channelMode_ == mode) return; // no decode change: don't churn a reload
|
||||
channelMode_ = mode;
|
||||
}
|
||||
// The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no
|
||||
// restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread
|
||||
// (mono = downmix, stereo = L/R split) and the RT path just keeps rendering.
|
||||
// The output bus is fixed stereo (no bus repoint): reloading re-decodes off-thread
|
||||
// under the new mode and the RT path just keeps rendering.
|
||||
reloadInstrument();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,41 +5,33 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/map/bridge_marshal.h"
|
||||
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04 grow-loop policy)
|
||||
#include "core/capture/capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation)
|
||||
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (grow-loop policy)
|
||||
#include "core/capture/capture_paths.h" // projectDirOfRpp (shared 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.
|
||||
// VST3 base types must be included before REAPER's VST3 interface header, which uses
|
||||
// unqualified Steinberg types (FUnknown, CStringA, uint32, DECLARE_CLASS_IID, PLUGIN_API).
|
||||
#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.
|
||||
// REAPER's VST3-side bridge interface (vendored): IReaperHostApplication is the
|
||||
// IHostApplication REAPER passes to IComponent::initialize, exposing getReaperApi
|
||||
// (resolve-by-name) and getReaperParent (host context). Pulled into namespace Steinberg
|
||||
// (the header's unqualified types), 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.
|
||||
// DECLARE_CLASS_IID in the REAPER header only declares the iid; this is the only TU that
|
||||
// queries for the interface, so the DEFINE 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.
|
||||
// The ext-state namespace is the shared wire contract with the extension — ext_keys.h's
|
||||
// kProjExtNamespace() (pure, REAPER-free), channel-derived so both artifacts read one
|
||||
// symbol and cannot drift.
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
|
||||
using capture::projectDirOfRpp;
|
||||
using instrument::map::decodeGetProjExtState;
|
||||
|
||||
@@ -53,27 +45,24 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
||||
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.
|
||||
// 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.
|
||||
// getReaperApi returns the same function pointers the extension resolves via
|
||||
// rec->GetFunc; a null return means the symbol is unavailable (very old REAPER).
|
||||
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
|
||||
// the persist shell (ext_state_io.cpp) uses, so the instrument derives the project
|
||||
// directory identically.
|
||||
// EnumProjects(-1, ...) yields the active project + its .rpp path — same convention
|
||||
// the persist shell 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).
|
||||
// The (prefix-guarded) usage publish write + the track-identity pair it stamps. All
|
||||
// degrade to null gracefully — an old REAPER never publishes usage.
|
||||
setProjExtState_ = reinterpret_cast<SetProjExtStateFn>(
|
||||
reaper->getReaperApi("SetProjExtState"));
|
||||
getTrackGuid_ = reinterpret_cast<GetTrackGuidFn>(
|
||||
@@ -87,23 +76,16 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
||||
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).
|
||||
// getReaperParent(3) reads the live "reasampler" ext-state against the active project
|
||||
// the instrument was instantiated in, so it follows project switches for free. A null
|
||||
// project is legitimate (REAPER treats it as the current project) — pass it through
|
||||
// rather than bailing; a fruitless read still yields nullopt to the caller.
|
||||
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. The retry policy is the SHARED pure wire::readProjExtStateGrowing
|
||||
// (T2-04 — one loop for the
|
||||
// extension's persist/usage reads and this bridge read; the rules cannot drift):
|
||||
// absent (rv <= 0) and the >16 MB ceiling both fold to nullopt here, and a
|
||||
// complete value still runs through decodeGetProjExtState (the stale/empty-buffer
|
||||
// guard) exactly as before.
|
||||
// The bank blob can be large, so grow the buffer until it fits rather than risk a
|
||||
// silent truncation. The shared wire::readProjExtStateGrowing loop keeps this bridge
|
||||
// read and the extension's persist/usage reads from drifting.
|
||||
const auto read = wire::readProjExtStateGrowing(
|
||||
[&](char* buf, int cap) {
|
||||
return getProjExtState_(proj, kProjExtNamespace(), key.c_str(), buf, cap);
|
||||
@@ -116,24 +98,21 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
|
||||
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).
|
||||
// Read-only-bank guard: this module writes usage keys and nothing else. A non-
|
||||
// "rsusage_" key is refused rather than widening 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.
|
||||
// SetProjExtState returns the size of the extname's state — after storing a
|
||||
// non-empty value that's necessarily > 0, so <= 0 means the write did not land (the
|
||||
// publish path retries next reload tick; a silent drop would leave 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.
|
||||
// Deliberately NO MarkProjectDirty: a usage change always rides a component-state
|
||||
// change that already dirties the project.
|
||||
return rv > 0;
|
||||
}
|
||||
|
||||
@@ -151,11 +130,8 @@ std::string ReaperBridge::currentTrackGuid() {
|
||||
|
||||
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 the persist shell; 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).
|
||||
// idx=-1 is the current project tab; the out-buffer is empty for a never-saved
|
||||
// project. projectDirOfRpp keeps that empty (no default-location fallback).
|
||||
std::vector<char> buf(4096, '\0');
|
||||
enumProjects_(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
// 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).
|
||||
// reaper_bridge.h — the REAPER VST-host bridge. 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.
|
||||
// Bridge mechanism: REAPER passes an IHostApplication as `context` to
|
||||
// IComponent::initialize; querying it for IReaperHostApplication yields getReaperApi
|
||||
// (resolve a REAPER API function pointer by name) and getReaperParent(3) (the host
|
||||
// ReaProject*; 1=track, 2=take, 3=project, 4=fxdsp, 5=trackchan) — not VST2 hostcb opcodes.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -32,48 +24,39 @@ 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.
|
||||
// Binds to the host (`context` is the FUnknown* IComponent::initialize hands us).
|
||||
// Returns true when the 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.
|
||||
// Reads a "reasampler" ext-state value by key from the host's active project.
|
||||
// Returns nullopt when unconnected, unresolvable, or the key is absent.
|
||||
//
|
||||
// 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).
|
||||
// NOT REAL-TIME SAFE (allocates + calls into REAPER): audio-thread callers MUST NOT
|
||||
// invoke this. The instrument reads on the main/UI thread and hands a snapshot to
|
||||
// the process path.
|
||||
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.
|
||||
// The active project's directory (forward-slashed, no trailing slash) — the same
|
||||
// convention persist uses to place the bank alongside the .rpp. Empty for an unsaved
|
||||
// project or when unconnected. 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.
|
||||
// Writes THIS INSTANCE's usage record: the ONE sanctioned instrument-side ext-state
|
||||
// write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's usageKeyFor); any
|
||||
// other key is refused, enforcing the read-only-bank invariant structurally (banks/
|
||||
// view/tail/assign stay unwritable from the instrument). Returns true iff written
|
||||
// (the SetProjExtState return is checked). NOT RT-safe — 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.
|
||||
// The canonical GUID string of the track hosting this FX instance (same rendering as
|
||||
// the extension's track_guid::guidString, so usage records compare byte-equal
|
||||
// against its live-FX enumeration). Empty when unconnected or no track context (the
|
||||
// usage reader then falls back to any-instance liveness). Not RT-safe.
|
||||
std::string currentTrackGuid();
|
||||
|
||||
private:
|
||||
@@ -84,18 +67,14 @@ private:
|
||||
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 the persist shell
|
||||
// (ext_state_io.cpp) does.
|
||||
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path. idx=-1 (current
|
||||
// tab) follows the active project, same convention as the persist shell.
|
||||
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.
|
||||
// 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.
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
// reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000
|
||||
// capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the
|
||||
// host's IPlugView seat and routes host paint/mouse into the pure geometry modules
|
||||
// (capture_browser, keyboard_strip) + the pure mapping (sample_map). Windows-only (D5).
|
||||
//
|
||||
// The default face is the CAPTURE BROWSER: a bank-filter tab strip over a grid of
|
||||
// scannable capture cards (peak thumbnail + name + root/key badge). A fresh instance with
|
||||
// no pick shows a "pick a capture" EMPTY STATE and plays silence (the S10 policy reversal
|
||||
// of the S4 first-sample auto-play). Picking a card loads that one capture and reveals a
|
||||
// guided SINGLE-CAPTURE SETUP surface (a keyboard strip with the capture's root marker +
|
||||
// a level readout). Multi-zone keymap editing is a demoted, opt-in ZONES panel (S10-Z),
|
||||
// reached by a toggle and driven by the same keyboard_strip drag machine.
|
||||
//
|
||||
// All layout/hit-test/drag math lives in the pure modules; this shell only draws + routes
|
||||
// (a LICE_SysBitmap blitted in WM_PAINT, a WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP
|
||||
// drag-state machine hit-testing via the pure resolvers). Peak thumbnails are computed
|
||||
// shell-side from the decoded WAV (bank_model's Sample carries no envelope) and cached —
|
||||
// the mirror of bank_panel::thumbnailFor. Every edit commits OFF the audio thread via the
|
||||
// processor's reloadInstrument (RT path untouched).
|
||||
//
|
||||
// Subclasses CPluginView for the IPlugView boilerplate; overrides the attach/remove hooks
|
||||
// to create/destroy the child window and onSize to resize it.
|
||||
// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
|
||||
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules
|
||||
// (capture_browser, keyboard_strip, sample_map) — default face is the capture browser, then
|
||||
// single-capture setup, with an opt-in zones panel. All layout/hit-test/drag math lives in
|
||||
// the pure modules; every edit commits off the audio thread via reloadInstrument.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -30,13 +13,13 @@
|
||||
|
||||
#include "public.sdk/source/common/pluginview.h"
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
|
||||
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit)
|
||||
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (r11 knob deck — Sample FB1, Zone FB2)
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type)
|
||||
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
|
||||
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (Sample + Zone knob deck)
|
||||
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
|
||||
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state)
|
||||
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state)
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
@@ -46,10 +29,6 @@ class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim
|
||||
// is retired from the editor family; engine symbols — ChannelMode, VoiceMode, MonoTrigger,
|
||||
// the voice-count constants, VelocityCurve via the engine re-export — stay in flat
|
||||
// `reasampler` and resolve via the enclosing namespace).
|
||||
using audio::AudioSample;
|
||||
using audio::Envelope;
|
||||
using instrument::map::BankChoice;
|
||||
@@ -69,9 +48,9 @@ class ReaSamplerProcessor;
|
||||
|
||||
class ReaSamplerEditor : public Steinberg::CPluginView {
|
||||
public:
|
||||
// `processor` owns this editor's lifetime domain and outlives it; the editor reads the
|
||||
// live bank through it and drives selection/zone edits + reload on user input. May be
|
||||
// null (defensive — a real host always supplies one).
|
||||
// `processor` outlives this editor; the editor reads the live bank through it and drives
|
||||
// selection/zone edits + reload on user input. May be null (defensive; a real host always
|
||||
// supplies one).
|
||||
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||
~ReaSamplerEditor() override;
|
||||
|
||||
@@ -86,67 +65,51 @@ protected:
|
||||
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
|
||||
|
||||
private:
|
||||
// Which face the editor shows (S-VIEW-1, three-view model). Sample is the HOME/default
|
||||
// face (the loaded capture). Browse is a full-window MODAL picker overlaid on Sample
|
||||
// (select + confirm/cancel changes the loaded capture, then dismisses). Zone is the
|
||||
// dedicated multi-zone keymap surface, button-summoned. All three draw over the same
|
||||
// snapshotted bank; Browse + Zone return to Sample when dismissed.
|
||||
// Sample is the home/default face. Browse is a full-window modal picker overlaid on
|
||||
// Sample. Zone is the dedicated multi-zone keymap surface, button-summoned.
|
||||
enum class View { kSample, kBrowse, kZone };
|
||||
|
||||
// What a mouse drag is currently editing (the drag-state machine). kNone = no drag in
|
||||
// flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the
|
||||
// single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop
|
||||
// marker on the S11 waveform surface (which marker is in waveMarker_); kEnvNode is a
|
||||
// draggable envelope breakpoint on the Sample-view hero overlay (S-VIEW-3, which node in
|
||||
// envNode_); kCurveNode is a draggable velocity-curve control point in the S-VIEW-10
|
||||
// transfer-curve editor (which point in curvePointIndex_); kDeckKnob is a GRAB-ANCHORED
|
||||
// vertical radial-knob drag on an r11 knob deck — the Sample face's deck/cluster (FB1)
|
||||
// or the Zone panel's per-zone deck (FB2) — (which control in dragParamId_; the value at
|
||||
// grab in dragKnobStartValue_ — no jump on grab, FA4).
|
||||
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
|
||||
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
|
||||
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
|
||||
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
|
||||
kScrollThumb, kEnvNode, kCurveNode, kDeckKnob };
|
||||
|
||||
// The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces).
|
||||
// The int value is the opaque control id the pure knob_deck hit-test returns; the shell
|
||||
// maps it to the picked zone's play params (or a processor-side per-instance setter).
|
||||
// Controls on the setup surface. The int value is the opaque control id the pure
|
||||
// knob_deck hit-test returns; the shell maps it to the zone's play params or a
|
||||
// processor-side per-instance setter.
|
||||
enum class ParamControl {
|
||||
kPlayMode = 0, // Gate | Trigger toggle (S15)
|
||||
kPitchEngine, // Varispeed | Preserve toggle (S16)
|
||||
kPlayMode = 0, // Gate | Trigger toggle
|
||||
kPitchEngine, // Varispeed | Preserve toggle
|
||||
kAttack, // AHDSR attack (Gate) / —
|
||||
kHold, // AHDSR hold (Gate, S15)
|
||||
kHold, // AHDSR hold (Gate)
|
||||
kDecay, // AHDSR decay (Gate)
|
||||
kSustain, // AHDSR sustain (Gate)
|
||||
kRelease, // AHDSR release (Gate)
|
||||
kTrigLength, // Trigger %-length (Trigger, S15)
|
||||
kTrigFadeIn, // Trigger fade-in (Trigger, S15)
|
||||
kTrigFadeOut, // Trigger fade-out (Trigger, S15)
|
||||
kPitchEnvEnable, // AD pitch envelope on|off (S16)
|
||||
kPitchEnvAttack, // AD pitch attack (S16)
|
||||
kPitchEnvDecay, // AD pitch decay (S16)
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones (S16)
|
||||
kKeyTrack, // S-VIEW-6 key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds)
|
||||
// r11 deck-only controls (FB1): processor-side per-instance params, NOT zone params —
|
||||
// routed to the processor setters, never through applyZoneControl / the map.
|
||||
kVoiceCount, // Phase S polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||
kTrigLength, // Trigger %-length
|
||||
kTrigFadeIn, // Trigger fade-in
|
||||
kTrigFadeOut, // Trigger fade-out
|
||||
kPitchEnvEnable, // AD pitch envelope on|off
|
||||
kPitchEnvAttack, // AD pitch attack
|
||||
kPitchEnvDecay, // AD pitch decay
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
||||
kKeyTrack, // key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds)
|
||||
// Deck-only controls: processor-side per-instance params, NOT zone params — routed to
|
||||
// the processor setters, never through applyZoneControl / the map.
|
||||
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
|
||||
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
|
||||
kMasterGain, // FB1 post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
|
||||
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
|
||||
kCount
|
||||
};
|
||||
|
||||
// The waveform markers on the single-capture setup surface (S11). Order is the draw + hit
|
||||
// order (start first). Named generically per the spec so S15 can repurpose the surface with
|
||||
// a different marker set; here it is start-point + the sustain loop's two ends.
|
||||
// The waveform markers on the single-capture setup surface: start-point + the sustain
|
||||
// loop's two ends, in draw + hit order.
|
||||
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
|
||||
|
||||
// --- Hover model (Phase L, L3) ------------------------------------------------
|
||||
//
|
||||
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE so the kit
|
||||
// draws its hover state on that element only ("hover on every interactive element" +
|
||||
// "sub-frame feedback = the perception of speed", §3.3/§3.5). Cleared to kNone on
|
||||
// WM_MOUSELEAVE (tracked via TrackMouseEvent). `index` disambiguates within a kind
|
||||
// (tab ordinal, visible-card index, control-row id); -1 when not applicable. Mirror of
|
||||
// bank_panel's L2 hover model.
|
||||
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index`
|
||||
// disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when
|
||||
// not applicable.
|
||||
enum class HoverKind {
|
||||
kNone,
|
||||
kNavBrowse, // the Sample-view "Browse" title-band button (opens the Browse modal)
|
||||
@@ -163,10 +126,10 @@ private:
|
||||
kAddZone, // the "+ Add Zone" button
|
||||
kDeleteZone, // the "Delete" zone button
|
||||
kControl, // a knob-deck element (index = control id)
|
||||
kCurveNode, // a velocity-curve control point (index = point index, S-VIEW-10)
|
||||
kVelKnob, // the cluster preview-velocity radial knob (r11)
|
||||
kCurveButton, // the cluster mini curve-preview button (r11 — opens the popup)
|
||||
kPopupClose, // the curve popup's Close (x) button (r11)
|
||||
kCurveNode, // a velocity-curve control point (index = point index)
|
||||
kVelKnob, // the cluster preview-velocity radial knob
|
||||
kCurveButton, // the cluster mini curve-preview button (opens the popup)
|
||||
kPopupClose, // the curve popup's Close (x) button
|
||||
};
|
||||
struct HoverTarget {
|
||||
HoverKind kind = HoverKind::kNone;
|
||||
@@ -177,96 +140,72 @@ private:
|
||||
|
||||
#ifdef _WIN32
|
||||
void paint(HDC hdc);
|
||||
void paintSample(LICE_IBitmap* bmp, int w, int h); // S-VIEW-2/r11 home face
|
||||
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // S-VIEW-5 modal picker overlay
|
||||
void paintZone(LICE_IBitmap* bmp, int w, int h); // S-VIEW-8 zone surface
|
||||
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face
|
||||
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay
|
||||
void paintZone(LICE_IBitmap* bmp, int w, int h); // zone surface
|
||||
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
||||
|
||||
// --- r11 knob-deck rendering (FB1 Sample face; FB2 Zone panel) -------------------
|
||||
// The knob deck: the fenced task groups drawn through the L1 kit — group fence + caption +
|
||||
// compact caption toggles + radial knobs (param_slider's FA4 primitive) with label<->value
|
||||
// swap on hover/drag. `descs` picks the group set: the full Sample deck (deckGroupDescs)
|
||||
// or the Zone panel's per-zone groups (zoneDeckGroupDescs). Lays out from deckArea's
|
||||
// top-left; the caller anchors (Sample bottom-anchors, Zone top-anchors).
|
||||
// The knob deck: group fence + caption + compact caption toggles + radial knobs with
|
||||
// label<->value swap on hover/drag. `descs` picks the group set (Sample's deckGroupDescs
|
||||
// or the Zone panel's zoneDeckGroupDescs); caller anchors (Sample bottom, Zone top).
|
||||
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone,
|
||||
const std::vector<DeckGroupDesc>& descs);
|
||||
// The mini curve-preview button (shared by the Sample cluster + the Zone panel, FB2): a
|
||||
// hairline bg/cell square tracing the zone's live curve; Active border while the popup is up.
|
||||
// The mini curve-preview button shared by the Sample cluster + the Zone panel.
|
||||
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
|
||||
// The centered curve-popup sheet (wash + title + close + full-size curve editor). Edits
|
||||
// popupZone() — the Sample face's one-zone site or the Zone surface's selected zone (FB2).
|
||||
// The centered curve-popup sheet. Edits popupZone() — the Sample face's one-zone site
|
||||
// or the Zone surface's selected zone.
|
||||
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
|
||||
// Trace the S-VIEW-3 amp-envelope overlay + its draggable node handles over `waveArea` for
|
||||
// `zone`'s play params, at the sample's wall-clock duration. Shared by the Sample hero band.
|
||||
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`.
|
||||
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone,
|
||||
std::int64_t frames);
|
||||
// S-VIEW-10: the velocity->amp transfer-curve editor — a bordered box (X = velocity 0-127,
|
||||
// Y = amp 0-1), the monotone spline traced by eval, one draggable node handle per control
|
||||
// point. Since FB2 its ONLY host is the r11 popup sheet (both surfaces summon it via the
|
||||
// mini preview button); all mapping / hit-test / clamp math lives in the pure
|
||||
// velocity_curve module. `r` empty -> draws nothing.
|
||||
// The velocity->amp transfer-curve editor (X = velocity 0-127, Y = amp 0-1); its only
|
||||
// host is the popup sheet. `r` empty -> draws nothing.
|
||||
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
|
||||
|
||||
// Route a mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
|
||||
// starts a kCurveNode drag; Alt-click on an interior node deletes it (committed at once);
|
||||
// an empty-space click ADDS a point at the cursor and grabs it for an immediate drag.
|
||||
// `zoneIndex` must be a valid index into map_.zones (callers materialize first).
|
||||
// Mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
|
||||
// starts a kCurveNode drag; Alt-click on an interior node deletes it at once; an
|
||||
// empty-space click adds a point and grabs it. `zoneIndex` must be valid (callers
|
||||
// materialize first).
|
||||
void handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y);
|
||||
|
||||
// Route a left-click while the curve popup is open (the popup is MODAL over the Sample
|
||||
// face AND the Zone surface, FB2): Close / outside-wash dismiss, in-box clicks into the
|
||||
// shared curve machinery against popupZoneIndex(), everything else on the sheet swallowed.
|
||||
// Returns true when the popup consumed the click (i.e. whenever it is open).
|
||||
// Left-click while the curve popup is open (modal over both faces): Close /
|
||||
// outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed.
|
||||
// Returns true whenever the popup is open (it consumed the click).
|
||||
bool handlePopupMouseDown(int w, int h, int x, int y);
|
||||
|
||||
void onMouseDown(int x, int y);
|
||||
// The Browse-modal and Zone-surface halves of the mouse-down dispatch (Q-W2v: the
|
||||
// input TUs split along the face axis — onMouseDown keeps the Sample-face branch and
|
||||
// delegates these two; bodies in editor_input_browse_zone.cpp). Behavior-identical
|
||||
// to the former inline branches.
|
||||
// The Browse-modal and Zone-surface halves of the mouse-down dispatch (bodies in
|
||||
// editor_input_browse_zone.cpp).
|
||||
void mouseDownBrowse(int w, int h, int x, int y);
|
||||
void mouseDownZone(int w, int h, int x, int y);
|
||||
void onMouseMove(int x, int y);
|
||||
void onMouseUp(int x, int y);
|
||||
// r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only
|
||||
// acts while the popup is open (over the Sample face OR the Zone surface, FB2); a
|
||||
// right-click on a popup curve node deletes it through the same commit path as Alt-click
|
||||
// (deletePoint's endpoint guard makes endpoint right-clicks a safe no-op). Everything
|
||||
// else ignores right-clicks.
|
||||
// Right-click is the curve popup's primary node-delete affordance; only acts while the
|
||||
// popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op).
|
||||
void onMouseRDown(int x, int y);
|
||||
|
||||
// Apply a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: routes ordinary
|
||||
// controls through applyControl against the zone's play struct, and kKeyTrack against the
|
||||
// zone's keyTrack scalar (0..200% over the knob's 0..1). Used by both the click + drag paths.
|
||||
// Applies a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: ordinary
|
||||
// controls route through applyControl; kKeyTrack writes the zone's keyTrack scalar
|
||||
// (0..200% over the knob's 0..1).
|
||||
void applyZoneControl(int zoneIndex, int id, double value, int segment);
|
||||
|
||||
// Resolve the interactive element under (x, y) into hover_ (Phase L, L3). Called from
|
||||
// WM_MOUSEMOVE (also while a drag is in flight — the resolved element just isn't used
|
||||
// for a hover repaint mid-drag). Repaints only when the hovered element changed, so an
|
||||
// idle mouse-move is free. Windows-only (the hit-tests use the shell's Win32 client rect).
|
||||
// Resolves the interactive element under (x, y) into hover_, called from WM_MOUSEMOVE.
|
||||
// Repaints only on change, so an idle move is free. Windows-only.
|
||||
void resolveHover(int x, int y);
|
||||
// True iff element (kind, index) is the live hover_ target — the shell maps this to the
|
||||
// kit's Hover interaction state when the element has no more-specific state (Active, etc.).
|
||||
bool isHovered(HoverKind kind, int index) const {
|
||||
return hover_.kind == kind && hover_.index == index;
|
||||
}
|
||||
void onMouseWheel(int delta); // S12 browser scroll (wheel)
|
||||
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
|
||||
void onMouseWheel(int delta); // browser scroll (wheel)
|
||||
void onSearchChar(unsigned int ch); // type-to-filter search keystroke
|
||||
|
||||
// S13 (relay degraded): an OS file drop landed on the editor window. We do NOT ingest (the
|
||||
// instrument is a read-only bank consumer and the relay is unshipped) — we flash the "drop
|
||||
// on the ReaSampler panel to add" affordance so the drop is never silently swallowed and the
|
||||
// shipped ingest gesture stays discoverable. `droppedCount` is how many files were dropped
|
||||
// (drawn into the banner). NEVER inserts a timeline item / never touches the bank.
|
||||
// An OS file drop landed on the editor window. We do NOT ingest (read-only bank
|
||||
// consumer) — flash a "drop on the ReaSampler panel to add" affordance instead of
|
||||
// silently swallowing it. Never inserts a timeline item.
|
||||
void onFilesDropped(int droppedCount);
|
||||
|
||||
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
|
||||
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
|
||||
// a new assignment request -> apply as this instance's selection) and, when anything
|
||||
// changed, re-snapshots the editor's own view (refreshFromBank) + repaints so the browser /
|
||||
// setup surface reflect the new bank. An open editor means THIS instance is the focused
|
||||
// assignment target (the thundering-herd policy — see the handoff), so it passes true.
|
||||
// Suppressed WHILE A DRAG IS IN FLIGHT so a mid-drag reload does not yank the edit surface.
|
||||
// The change-detection tick (WM_TIMER, UI thread only): polls the processor's bank-sync
|
||||
// and re-snapshots + repaints when anything changed. Suppressed mid-drag so a reload
|
||||
// never yanks the edit surface.
|
||||
void onSyncTimer();
|
||||
|
||||
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
@@ -279,37 +218,31 @@ private:
|
||||
// selection + performance map. Main/UI thread only. Called on attach and after any edit.
|
||||
void refreshFromBank();
|
||||
|
||||
// Publish the edited zones/selection to the processor, then rebuild the instrument OFF
|
||||
// the audio thread. UI thread only. One place so every edit commits identically.
|
||||
// Publishes the edited zones/selection to the processor, then rebuilds the instrument
|
||||
// off the audio thread. UI thread only.
|
||||
void commitAndReload();
|
||||
|
||||
// Commit `id` as the loaded single-capture selection (the Browse Load confirm and the
|
||||
// double-click accelerator both route here). Runs reconcileSingleCaptureZones first so
|
||||
// the previous sample's materialized full-range zone cannot linger and shadow the new
|
||||
// pick under first-match resolve (the zone-bleed fix, issue 3a), then publishes + reloads.
|
||||
// Commits `id` as the loaded single-capture selection. Runs reconcileSingleCaptureZones
|
||||
// first so the previous sample's materialized full-range zone cannot linger and shadow
|
||||
// the new pick under first-match resolve, then publishes + reloads.
|
||||
void loadSelection(const std::string& id);
|
||||
|
||||
// Recompute the capture cards visible under the current bank filter (samples_ narrowed by
|
||||
// activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change.
|
||||
// Recomputes the visible capture cards (samples_ narrowed by activeFilterBankId_ then
|
||||
// search) into visible_. Called on refresh + filter change.
|
||||
void rebuildVisible();
|
||||
|
||||
// The peak thumbnail for a bank sample id at `binCount` bins, computed once from the
|
||||
// decoded WAV (mirror of bank_panel::thumbnailFor) and cached by (id, binCount). Returns
|
||||
// an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O).
|
||||
// The peak thumbnail for a bank sample id at `binCount` bins, cached by (id, binCount).
|
||||
// Empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O).
|
||||
const Envelope& thumbnailFor(const std::string& sampleId, int binCount);
|
||||
|
||||
// The decoded MONO PCM for a bank sample id, decoded once from the WAV and cached by id.
|
||||
// Feeds the S11 waveform surface: the full-res envelope binned at view width AND the
|
||||
// zero-crossing snap (both need the raw frames, not the binned thumbnail). Returns an empty
|
||||
// vector when the WAV can't be resolved/decoded. UI thread only (file I/O). Reuses the same
|
||||
// decode path as thumbnailFor (no new WAV reader), keyed by id (not width — snap is width-
|
||||
// independent). Cleared with the thumbnail cache on refresh.
|
||||
// The decoded mono PCM for a bank sample id, cached by id — feeds both the binned
|
||||
// waveform envelope and the zero-crossing snap. Empty vector on decode failure. UI
|
||||
// thread only (file I/O); cleared with the thumbnail cache on refresh.
|
||||
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
|
||||
|
||||
// The effective loop + start markers for the picked single capture (S11): the per-zone
|
||||
// OVERRIDE for the picked id when one exists in map_, else the bank's S2 loop intrinsic
|
||||
// (loop) / frame 0 (start). Absent loop -> loopStart==loopEnd==0 (the "no loop" state).
|
||||
// frames is the decoded length (for defaulting loopEnd when the bank left the loop empty).
|
||||
// The effective loop + start markers for the picked capture: the per-zone override when
|
||||
// one exists in map_, else the bank's loop intrinsic / frame 0. Absent loop ->
|
||||
// loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty.
|
||||
struct SetupMarkers {
|
||||
std::int64_t start = 0;
|
||||
std::int64_t loopStart = 0;
|
||||
@@ -318,125 +251,93 @@ private:
|
||||
};
|
||||
SetupMarkers pickedMarkers(std::int64_t frames) const;
|
||||
|
||||
// Commit an edited marker set for the picked capture as a per-zone loop/start override
|
||||
// (upsert on the picked id — mirror of the root-marker path), then reload off-thread.
|
||||
// Commits an edited marker set for the picked capture as a per-zone loop/start override
|
||||
// (upsert on the picked id), then reloads off-thread.
|
||||
void commitPickedMarkers(const SetupMarkers& m);
|
||||
|
||||
// Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
|
||||
// Does NOT call commitAndReload — callers decide whether this is a live-drag update or a
|
||||
// final commit. selectedId_ must be non-empty before calling. Returns the zone index
|
||||
// (0-based) that was updated or appended, so callers can set selectedZone_.
|
||||
// Writes `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
|
||||
// Does NOT call commitAndReload — callers decide live-drag vs final commit. selectedId_
|
||||
// must be non-empty. Returns the updated/appended zone index.
|
||||
int upsertPickedOverride(const SetupMarkers& m);
|
||||
|
||||
// --- S12/S15/S16 parameter value domains (both deck surfaces) ------------------
|
||||
//
|
||||
// The deck knobs edit a zone's ZonePlaySeconds (S15 play mode + AHDSR; S16 pitch engine +
|
||||
// AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap build resolves
|
||||
// them to frames at the live rate. Instrument-owned (D-B), never a bank fact.
|
||||
// Deck knobs edit a zone's ZonePlaySeconds (play mode + AHDSR; pitch engine + AD pitch
|
||||
// envelope) — wall-clock seconds, rate-free; the keymap build resolves to frames.
|
||||
|
||||
// The normalized [0,1] display value for control `id` given `play` (the shell's domain
|
||||
// mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade
|
||||
// frames->0..1, semitone depth centered at 0.5).
|
||||
// The normalized [0,1] display value for control `id` given `play` (seconds -> 0..1 over
|
||||
// a fixed ceiling, sustain 0..1 as-is, %-length/fade frames -> 0..1, semitone depth
|
||||
// centered at 0.5).
|
||||
double controlValue(int id, const ZonePlaySeconds& play) const;
|
||||
|
||||
// Apply a committed control interaction to `play`: a knob's normalized `value` (mapped back
|
||||
// into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
|
||||
// Applies a committed control interaction to `play`: a knob's normalized `value` or a
|
||||
// toggle's `segment` (0/1). Mutates `play` in place.
|
||||
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
|
||||
|
||||
// The Trigger fade-in/out knob full-scale, in SOURCE frames: kFadeMaxSeconds (2 s
|
||||
// wall-clock) resolved against the live rate at use (Q-W0 T3-03 — never a baked-in
|
||||
// rate). 44.1 kHz fallback before setupProcessing has run. Storage stays frames.
|
||||
// The Trigger fade-in/out knob full-scale, in source frames: kFadeMaxSeconds resolved
|
||||
// against the live rate — never a baked-in rate. 44.1 kHz fallback pre-setupProcessing.
|
||||
double fadeMaxFrames() const;
|
||||
|
||||
// --- S-VIEW-3 envelope overlay seam (frames <-> fraction converter) ----------
|
||||
//
|
||||
// envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the
|
||||
// Trigger fades as FRACTIONS of the played span, while the zone stores them as SOURCE FRAMES.
|
||||
// These two members own the non-trivial conversion on BOTH paths (documented in
|
||||
// envelope_overlay.h's TRIGGER SEAM note). `frames` is the sample's total source frame count;
|
||||
// `rate` is the live sample rate (the wall-clock AHDSR seconds are rate-free and copy 1-to-1,
|
||||
// but the Trigger played-span math needs the frame count).
|
||||
// envelope_overlay's AmpEnvelope stores Trigger fades as fractions of the played span,
|
||||
// while the zone stores source frames — pack/unpack own that conversion (see
|
||||
// envelope_overlay.h's trigger-seam note). `frames` is total source frames; AHDSR
|
||||
// seconds are rate-free and copy 1-to-1.
|
||||
|
||||
// PACK (draw): zone play params -> AmpEnvelope. Copies AHDSR seconds directly; derives the
|
||||
// Trigger fade fractions from the source-frame fades over the played span.
|
||||
// `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)).
|
||||
// PACK (draw): zone play params -> AmpEnvelope. `startFrame` is the zone's effective
|
||||
// start point (zone.startPoint.value_or(0)).
|
||||
AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
|
||||
std::int64_t startFrame) const;
|
||||
|
||||
// UNPACK (commit): an edited AmpEnvelope -> the zone's play params. Copies AHDSR seconds
|
||||
// directly; converts the Trigger fade fractions back to source frames over the played span.
|
||||
// `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)).
|
||||
// Mutates `play` in place; only the mode-relevant fields are written.
|
||||
// UNPACK (commit): an edited AmpEnvelope -> the zone's play params, in place.
|
||||
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
|
||||
ZonePlaySeconds& play) const;
|
||||
|
||||
// The clamp bounds envelope_edit uses, matching the control-panel sliders' own domains (so a
|
||||
// node drag can never produce a param a slider couldn't — the S-VIEW-F2 invariant).
|
||||
// Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
|
||||
// never produce a param a slider couldn't.
|
||||
EnvClampBounds envClampBounds() const;
|
||||
|
||||
// --- Sample-view resolution helpers (the ONE storage site, S15-F2) -----------
|
||||
//
|
||||
// The single-capture Sample face reads/writes the same one-zone map site as the Zone surface.
|
||||
// These resolve the effective values for the picked id: effectiveSampleZone returns the picked
|
||||
// id's one-zone override (found in map_) or a product-default PerformanceZone bound to the
|
||||
// picked id (not yet materialized — a control edit materializes it, mirroring the Zone path).
|
||||
// The Sample face and the Zone surface read/write the same one-zone map site.
|
||||
// effectiveSampleZone returns the picked id's override if present in map_, else a
|
||||
// product-default zone (not yet materialized — a control edit does that).
|
||||
PerformanceZone effectiveSampleZone() const;
|
||||
// The effective root: the picked id's rootOverride, else its bank intrinsic, else middle C.
|
||||
// The effective root: rootOverride, else the bank intrinsic, else middle C.
|
||||
int effectiveRoot() const;
|
||||
// The live sample rate from the bridge (for the envelope overlay's seconds<->frames time base),
|
||||
// or 0 when unavailable (the caller guards). Matches the voice engine's resolution rate.
|
||||
// The live sample rate from the bridge, or 0 when unavailable (caller guards).
|
||||
double liveSampleRate() const;
|
||||
// The persisted preview velocity as a 0..1 slider value (MIDI 1..127 mapped onto [0,1]).
|
||||
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
||||
double previewVelocity01() const;
|
||||
|
||||
// Find-or-materialize the one-zone override for the picked id and return a mutable index into
|
||||
// map_.zones (appending a product-default zone if none exists). selectedId_ must be non-empty.
|
||||
// The mirror of upsertPickedOverride for a control edit — used when a Sample-face control edit
|
||||
// needs a concrete zone to write. Returns -1 if selectedId_ is empty.
|
||||
// Find-or-materializes the one-zone override for the picked id, appending a
|
||||
// product-default zone if none exists. Mirror of upsertPickedOverride for a control
|
||||
// edit. Returns -1 if selectedId_ is empty.
|
||||
int ensureSampleZone();
|
||||
|
||||
// --- Curve-popup target resolution (r11 FB1 + FB2) -----------------------------
|
||||
//
|
||||
// The popup edits ONE zone per open: the Zone surface's SELECTED zone (FB2) or the Sample
|
||||
// face's picked one-zone site. popupZone is the read-only resolve (paint/hover/right-click
|
||||
// hit-test); popupZoneIndex is the edit target — it materializes the Sample-face zone via
|
||||
// ensureSampleZone but NEVER materializes on the Zone surface (the button only shows for
|
||||
// an explicit selection). Returns -1 when there is no valid target (callers guard).
|
||||
// The popup edits ONE zone per open: the Zone surface's selected zone or the Sample
|
||||
// face's picked site. popupZone is the read-only resolve; popupZoneIndex is the edit
|
||||
// target — materializes on the Sample face via ensureSampleZone, never on the Zone
|
||||
// surface (button only shows for an explicit selection). -1 = no valid target.
|
||||
PerformanceZone popupZone() const;
|
||||
int popupZoneIndex();
|
||||
|
||||
// --- r11 knob-deck plumbing (FB1 Sample face; FB2 Zone panel) -------------------
|
||||
//
|
||||
// The deck is the r11 replacement for the slider control strips on BOTH surfaces: the pure
|
||||
// knob_deck module lays out the fenced groups, param_slider's FA4 primitive owns the
|
||||
// value<->needle map, and these members own the control-id <-> value binding.
|
||||
|
||||
// The PER-ZONE deck groups (FB2 — the set both surfaces share): AMP ENVELOPE (Gate:
|
||||
// A/H/D/S/R; Trigger: Fade In / Length % / Fade Out + two RESERVED blanks so a mode flip
|
||||
// never reflows the neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth).
|
||||
// The Zone panel renders exactly these — per-instance state stays off it.
|
||||
// The per-zone deck groups both surfaces share: AMP ENVELOPE (Gate A/H/D/S/R; Trigger
|
||||
// Fade In/Length %/Fade Out + two reserved blanks so a mode flip never reflows
|
||||
// neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth).
|
||||
std::vector<DeckGroupDesc> zoneDeckGroupDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE (Voices
|
||||
// knob + Poly|Mono caption toggle + Retrig|Legato row toggle) and MASTER (the FB1
|
||||
// post-mixer Gain knob) groups.
|
||||
// The full Sample-face deck: the shared groups + the per-instance VOICE (Voices knob +
|
||||
// Poly|Mono + Retrig|Legato) and MASTER (Gain knob) groups.
|
||||
std::vector<DeckGroupDesc> deckGroupDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The normalized [0,1] value a deck knob shows for `zone` — zone params route through
|
||||
// controlValue/keyTrack; the processor-side ids (voice count, master gain, and the
|
||||
// cluster's preview velocity via the -2 sentinel) read the processor's live value, so
|
||||
// the knob and its storage are two views on one model (re-read each paint).
|
||||
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
|
||||
// via the -2 sentinel) read the processor's live value.
|
||||
double deckControlNorm(int id, const PerformanceZone& zone) const;
|
||||
|
||||
// Apply a deck-knob value: zone params write map_.zones[zoneIndex] (live-drag semantics,
|
||||
// commit on release); processor params (voice count / master gain / preview velocity)
|
||||
// write through the processor setters immediately (transient — no map edit, no reload).
|
||||
// zoneIndex is ignored for processor-side ids.
|
||||
// Applies a deck-knob value: zone params write map_.zones[zoneIndex] (commit on
|
||||
// release); processor params write through the processor setters immediately
|
||||
// (transient — no map edit, no reload). zoneIndex ignored for processor-side ids.
|
||||
void applyDeckKnob(int zoneIndex, int id, double norm);
|
||||
|
||||
// The knob's live value label (shown in place of the name label during hover/drag):
|
||||
// seconds ("0.123s"), percents ("85%"), source frames ("8820f"), signed semitones
|
||||
// ("+3.5st"), a voice count ("16"), or the master-gain dB ("-inf"/"+2.4dB").
|
||||
// The knob's live value label shown during hover/drag: seconds, percents, source
|
||||
// frames, signed semitones, a voice count, or the master-gain dB.
|
||||
std::string deckValueLabel(int id, const PerformanceZone& zone) const;
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
@@ -447,64 +348,51 @@ private:
|
||||
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
|
||||
std::string selectedId_; // the single-capture pick ("" = empty state)
|
||||
PerformanceMap map_; // the opt-in zones (empty = no zones)
|
||||
ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot
|
||||
ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
|
||||
|
||||
// --- Phase S voice-deck snapshot (PROVISIONAL controls — the Wave B recompose owns the
|
||||
// final deck). Mirrors of the processor's persisted voice-system params, refreshed with
|
||||
// the rest of the live snapshot; every edit writes through the processor setters (which
|
||||
// rebuild the engine off-thread via the drain-slot swap).
|
||||
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
|
||||
// live snapshot; every edit writes through the processor setters (which rebuild the engine
|
||||
// off-thread via the drain-slot swap).
|
||||
int voiceCount_ = kDefaultVoiceCount;
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
|
||||
// --- Transient UI state (not persisted; component state carries selection + zones) ---
|
||||
View view_ = View::kSample; // default face is the loaded-sample home (S-VIEW-1)
|
||||
// Transient UI state (not persisted; component state carries selection + zones).
|
||||
View view_ = View::kSample; // default face is the loaded-sample home
|
||||
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
||||
int selectedZone_ = -1; // highlighted zone in the Zone surface; -1 = none
|
||||
|
||||
// --- S-VIEW-5 Browse modal picker (a selection PENDING confirm) ---------------
|
||||
// The Browse overlay is a select-then-confirm picker: a click marks a pending pick without
|
||||
// loading it; Confirm (or double-click) commits it to selectedId_ + reloads and returns to
|
||||
// Sample; Cancel discards it and returns to Sample unchanged. "" = nothing picked yet.
|
||||
// The Browse overlay is a select-then-confirm picker: a click marks a pending pick;
|
||||
// Confirm/double-click commits it + reloads; Cancel discards it. "" = nothing picked.
|
||||
std::string browsePendingId_;
|
||||
int lastBrowseClickCard_ = -1; // for double-click-to-load detection (visible_ index)
|
||||
|
||||
// --- S-VIEW-4 preview-trigger note (transient) -------------------------------
|
||||
// The MIDI note the preview button is currently sounding (a held Gate voice), or -1 when the
|
||||
// button is up. Set on preview-button press (note-on posted to the processor), cleared on
|
||||
// release (note-off posted). One note at a time — a fresh press releases the prior.
|
||||
// The MIDI note the preview button is currently sounding (held Gate voice), or -1 when
|
||||
// up. One note at a time — a fresh press releases the prior.
|
||||
int previewingNote_ = -1;
|
||||
|
||||
// --- S13 drop-to-load affordance (relay DEGRADED — transient, never persisted) ----
|
||||
// S13's cross-artifact ingest relay (editor drop -> extension ingest) is NOT shipped: the
|
||||
// instrument's REAPER bridge is deliberately READ-ONLY (it never writes the bank / ext
|
||||
// state), so an editor drop cannot relay a bank-ingest request without a new write seam +
|
||||
// an extension-side poller (surfaced as a decision, not crossed here). The DEGRADE path per
|
||||
// the spec: the editor ACCEPTS the drop (WM_DROPFILES) and, rather than silently swallowing
|
||||
// it, flashes a clear affordance pointing at the shipped ingest gesture (drop onto the
|
||||
// docked ReaSampler panel). When > 0, the affordance banner is shown; each sync tick decays
|
||||
// it so it auto-dismisses. No file is ingested, no timeline item is ever inserted.
|
||||
// The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only):
|
||||
// an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts
|
||||
// down via the sync tick). Never ingests, never inserts a timeline item.
|
||||
int dropHintTicks_ = 0; // remaining sync ticks to show the drop affordance
|
||||
|
||||
// --- S12 browser scroll + search (transient UI state, never persisted) --------
|
||||
// Browser scroll + search (transient UI state, never persisted).
|
||||
int scrollOffset_ = 0; // vertical px offset into the card grid (clamped)
|
||||
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||
bool searchFocused_ = false; // whether the search box has keyboard focus
|
||||
|
||||
// --- S12 numeric note entry (LICE text-entry idiom, transient) ----------------
|
||||
// When >= 0, a low/high/root field is being typed; entryText_ accumulates the keystrokes
|
||||
// and commits (parseNoteEntry) on Enter. -1 = no field editing. The field id is a
|
||||
// ParamControl-independent small enum encoded inline (see the .cpp: 0=low,1=high,2=root).
|
||||
// When >= 0, a low/high/root field is being typed (0=low,1=high,2=root); entryText_
|
||||
// accumulates keystrokes and commits via parseNoteEntry on Enter. -1 = no field editing.
|
||||
int entryField_ = -1;
|
||||
std::string entryText_;
|
||||
|
||||
// --- Hover state (Phase L, L3; transient, never persisted) --------------------
|
||||
// Hover state (transient, never persisted).
|
||||
HoverTarget hover_; // the interactive element under the pointer
|
||||
#ifdef _WIN32
|
||||
bool mouseTracking_ = false; // TrackMouseEvent armed for WM_MOUSELEAVE this "over" cycle
|
||||
#endif
|
||||
|
||||
// --- Drag-state machine ------------------------------------------------------
|
||||
// Drag-state machine.
|
||||
DragKind drag_ = DragKind::kNone;
|
||||
int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver
|
||||
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag
|
||||
@@ -515,54 +403,46 @@ private:
|
||||
int dragStartRoot_ = 60;
|
||||
PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss
|
||||
|
||||
// S11 waveform-marker drag: which marker + the marker set snapshotted at grab time (so the
|
||||
// pixel-delta resolver shifts the grabbed frame from its grab-time value, and inter-marker
|
||||
// clamps use the sibling markers).
|
||||
// Waveform-marker drag: which marker + the marker set snapshotted at grab time, so the
|
||||
// pixel-delta resolver shifts from the grab-time value and inter-marker clamps use the
|
||||
// sibling markers.
|
||||
WaveMarker waveMarker_ = WaveMarker::kStart;
|
||||
SetupMarkers dragStartMarkers_;
|
||||
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
|
||||
std::int64_t dragStartFrame_ = 0; // zone startPoint at grab time (0 if absent); for env-node drag
|
||||
|
||||
// S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts
|
||||
// from it). kDeckKnob drag: which control id + the zone it edits.
|
||||
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id + zone.
|
||||
int dragStartScrollOffset_ = 0;
|
||||
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob
|
||||
int dragParamZone_ = -1; // the zone index a kDeckKnob drag edits; -1 = processor-side
|
||||
|
||||
// S-VIEW-3 envelope-node drag: which node is grabbed + the AmpEnvelope snapshotted at grab
|
||||
// (so the pixel delta is absolute, per envelope_edit's grabEnv contract). The overlay rect +
|
||||
// sample frame count are re-derived at move time from the live Sample-view layout.
|
||||
// Envelope-node drag: which node + the AmpEnvelope snapshotted at grab (absolute-delta
|
||||
// contract, per envelope_edit's grabEnv).
|
||||
EnvNode envNode_ = EnvNode::Origin;
|
||||
AmpEnvelope dragStartEnv_{};
|
||||
|
||||
// S-VIEW-10 velocity-curve node drag: which point is grabbed, the curve snapshotted at grab
|
||||
// (resolvePointDrag's absolute-delta contract), the box rect the grab happened in (the Sample
|
||||
// and Zone views place the editor differently — the drag resolves against the grab-time box),
|
||||
// and which zone the edit lands on. Mirror of the envelope-node drag state.
|
||||
// Velocity-curve node drag: which point, the curve snapshotted at grab
|
||||
// (resolvePointDrag's absolute-delta contract), the grab-time box rect (Sample and Zone
|
||||
// place the editor differently), and which zone the edit lands on.
|
||||
int curvePointIndex_ = -1;
|
||||
VelocityCurve dragStartCurve_ = VelocityCurve::flat();
|
||||
Rect dragCurveRect_{};
|
||||
int dragCurveZone_ = -1;
|
||||
|
||||
// r11 deck-knob drag (FB1): the control's normalized value AT GRAB — knobDragValue maps
|
||||
// the vertical pixel delta from this anchor, so a grab never jumps the value (FA4).
|
||||
// Deck-knob drag: the normalized value at grab — knobDragValue maps the vertical pixel
|
||||
// delta from this anchor, so a grab never jumps the value.
|
||||
double dragKnobStartValue_ = 0.0;
|
||||
|
||||
// r11 curve popup (FB1 + FB2): open flag — editor-local, never persisted. The popup edits
|
||||
// popupZone() — the picked capture's one-zone site on the Sample face, the SELECTED zone
|
||||
// on the Zone surface — re-resolved each paint so a sync-tick refresh mid-open stays
|
||||
// coherent (a refresh that drops the target closes it; see refreshFromBank).
|
||||
// Curve popup open flag, never persisted. Edits popupZone(), re-resolved each paint so a
|
||||
// sync-tick refresh mid-open stays coherent (a refresh that drops the target closes it).
|
||||
bool curvePopupOpen_ = false;
|
||||
|
||||
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
|
||||
// Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so
|
||||
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
|
||||
// Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize
|
||||
// recomputes at the new width. Cleared on refresh so a stale sample never shows.
|
||||
std::unordered_map<std::string, Envelope> thumbCache_;
|
||||
|
||||
// --- Decoded mono-PCM cache (S11; id -> full-res frames) ------------------------------
|
||||
// Keyed by id (width-independent, unlike thumbCache_). Feeds the waveform envelope binning
|
||||
// + the zero-crossing snap. Cleared alongside thumbCache_ on refresh so a re-captured or
|
||||
// deleted sample does not show/snap against stale PCM.
|
||||
// Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope
|
||||
// binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh.
|
||||
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
// 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.
|
||||
// Windows-only; 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/version/app_version.h" // vstPluginName (channel-derived embed label)
|
||||
#include "core/instrument/map/bank_sync.h" // parseBankGeneration (dirty-guard over the per-paint refresh)
|
||||
#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box
|
||||
#include "shell/panel/draw_kit.h" // the shared draw kit: fillSurface/text
|
||||
#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 "shell/instrument/reasampler_processor.h"
|
||||
#include "core/ui/theme.h" // Role / InteractionState / spectralColor (L3)
|
||||
#include "core/ui/theme.h" // Role / InteractionState / spectralColor
|
||||
|
||||
// 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.
|
||||
// wdltypes.h first: it defines INT_PTR portably (needed by REAPER_FXEMBED_IBitmap::Extended's
|
||||
// return type in the header below).
|
||||
#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.
|
||||
// REAPER's embed message/bitmap contract (vendored): REAPER_FXEMBED_IBitmap aliases
|
||||
// LICE_IBitmap; WM_* / DrawInfo / SizeHints live here.
|
||||
#include "reaper_plugin_fx_embed.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -34,17 +34,12 @@
|
||||
|
||||
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).
|
||||
// This is the only TU that answers queryInterface for IReaperUIEmbedInterface, so the
|
||||
// DEFINE lives here (mirrors reaper_bridge.cpp's IReaperHostApplication).
|
||||
DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface)
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Real-namespace-home using-directives (Q-W6: the namespaces.h shim is retired):
|
||||
// the embed strip speaks the map vocabulary (listSamples / parseBankGeneration) and
|
||||
// the pure UI layout (embed_strip / editor_geometry Rect) wholesale.
|
||||
using namespace reasampler::instrument::map;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using reasampler::ui::spectralColor;
|
||||
@@ -52,15 +47,13 @@ using version::vstPluginName;
|
||||
|
||||
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.
|
||||
// Kit adapter: the embed shell's Rect -> the kit's KitBox.
|
||||
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).
|
||||
// A short display name for a bank sample id (small duplicate of the editor's helper
|
||||
// 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;
|
||||
@@ -69,9 +62,8 @@ std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::str
|
||||
}
|
||||
#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.
|
||||
// Projects the performance map into the strip's minimal zone shape (key ranges only).
|
||||
// Kept shell-side because it reads PerformanceMap; embed_strip stays free of it.
|
||||
std::vector<EmbedZone> toEmbedZones(const PerformanceMap& map) {
|
||||
std::vector<EmbedZone> out;
|
||||
out.reserve(map.zones.size());
|
||||
@@ -104,28 +96,24 @@ void ReaSamplerEmbed::refresh() {
|
||||
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.
|
||||
// The performance map is a cheap in-process accessor, and the editor may edit zones
|
||||
// with no bank-content change — always re-snapshot it so an 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.
|
||||
// The expensive part is the bank-blob bridge read: gate it on the bank-generation
|
||||
// stamp, re-reading only when it changed (or on the first paint). A project with no
|
||||
// stamp reads generation 0; the first paint folds it and idle paints skip the read.
|
||||
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
|
||||
currentGen = 0; // unprimed + no stamp: 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.
|
||||
// Intentional asymmetry: a transient bridge failure after priming leaves currentGen
|
||||
// unchanged, skipping the read — a stale-but-intact list beats clearing samples_ on
|
||||
// every hiccup.
|
||||
|
||||
if (lastSeenBankGeneration_ < 0 || currentGen != lastSeenBankGeneration_) {
|
||||
auto banks =
|
||||
@@ -145,9 +133,8 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
#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.
|
||||
// Idempotent + process-global (shared with the editor); 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
|
||||
@@ -157,8 +144,7 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
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.
|
||||
// The keymap must not collapse below its floor plus the level band.
|
||||
hints->min_width = 64;
|
||||
hints->max_width = 0; // 0 = unconstrained
|
||||
hints->min_height = kEmbedKeymapMinHeight + kEmbedLevelBandHeight;
|
||||
@@ -172,7 +158,7 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
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.
|
||||
// Selection at most: map the click to a zone; force a redraw if it changed.
|
||||
return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0;
|
||||
#endif
|
||||
default:
|
||||
@@ -190,35 +176,30 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
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.
|
||||
// Re-read live state each paint (no own timer) — REAPER repaints the embed surface on
|
||||
// its own cadence.
|
||||
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.
|
||||
// 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).
|
||||
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.
|
||||
// No opt-in zones authored: a faint band 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 = version::vstPluginName() + // channel-derived (S18)
|
||||
const std::string label = version::vstPluginName() + // channel-derived
|
||||
(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).
|
||||
// Each zone draws as a segment (first-match order, matching selection/playback),
|
||||
// colored by its key span's spectral hue so it reads as the same spectrum as the
|
||||
// editor's keyboard strip. The selected zone lifts to accent-primary + a static glow.
|
||||
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);
|
||||
@@ -237,9 +218,8 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
}
|
||||
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.
|
||||
// Label when wide enough to read; the selected (accent-fill) segment labels in
|
||||
// bg/base for contrast, 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(),
|
||||
@@ -248,8 +228,8 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// The level band: a recessed channel with an accent-primary fill tracking the live
|
||||
// activity level (the one permitted "motion").
|
||||
if (layout.levelBand.height > 0) {
|
||||
fillSurface(bmp, toKitBox(layout.levelBand), Role::BgCell, InteractionState::Pressed);
|
||||
const double level = processor_ ? processor_->embedActivityLevel() : 0.0;
|
||||
|
||||
@@ -1,34 +1,9 @@
|
||||
// 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.
|
||||
// reasampler_embed.h — the embedded TCP/MCP UI shell. Implements REAPER's
|
||||
// IReaperUIEmbedInterface so the instrument draws a compact keymap/level strip inline in
|
||||
// the track/mixer control panel. All embed messages arrive on REAPER's UI thread; nothing
|
||||
// here runs in process(). Windows-only, 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.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -51,27 +26,30 @@ namespace reasampler::vst {
|
||||
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::SampleChoice;
|
||||
|
||||
// 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.
|
||||
// Implements IReaperUIEmbedInterface. Lifetime is owned by the processor (sole unique_ptr,
|
||||
// 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.
|
||||
// The one embed entry point, verified against reaper_plugin_fx_embed.h +
|
||||
// reaper_vst3_interfaces.h: our IEditController answers queryInterface for
|
||||
// IReaperUIEmbedInterface. msg is a REAPER_FXEMBED_WM_* value (aliased to Win32 WM_*)
|
||||
// — WM_IS_SUPPORTED, WM_CREATE/WM_DESTROY, WM_PAINT (parm2 = IBitmap*, parm3 =
|
||||
// DrawInfo*), WM_GETMINMAXINFO (parm3 = SizeHints*), mouse WM_* (return
|
||||
// RETNOTIFY_INVALIDATE to force a redraw). No plugin-owned HWND here (unlike the
|
||||
// IPlugView editor): REAPER hands a LICE bitmap per paint. 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.
|
||||
// FUnknown: lifetime is owned by the processor, not the host refcount, so
|
||||
// AddRef/release are no-ops and queryInterface answers only FUnknown +
|
||||
// IReaperUIEmbedInterface — the owning processor guarantees this 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; }
|
||||
@@ -79,37 +57,29 @@ public:
|
||||
|
||||
private:
|
||||
#ifdef _WIN32
|
||||
// Draw the current strip into REAPER's supplied LICE bitmap. Returns true if it drew.
|
||||
// Draws 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).
|
||||
// A mouse-down inside the strip: maps to a zone and selects it (no new editing
|
||||
// semantics). Returns true if the selection changed (caller then invalidates).
|
||||
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).
|
||||
// Snapshots the live bank + the instrument's performance map for the next paint.
|
||||
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.
|
||||
// Dirty-guard over refresh(): re-reads the bank blob only when the (cheap) generation
|
||||
// stamp changed since the last paint. The performance map is always refreshed (cheap
|
||||
// in-process accessor) so a zone edit 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).
|
||||
// The bank generation last folded into samples_. -1 is an "unprimed" sentinel distinct
|
||||
// from a real generation 0, forcing the first maybeRefresh() to do a full read.
|
||||
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.
|
||||
// The zone the last click selected (local/visual only); -1 = none. Drives the strip's
|
||||
// highlight.
|
||||
int selectedZone_ = -1;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// reasampler_processor.cpp — see reasampler_processor.h. Since Q-W2v (T4-12) this TU is
|
||||
// the VST3 LIFECYCLE + the REAL-TIME process() path ONLY: factory/queryInterface,
|
||||
// initialize/terminate/setActive, bus setup, and the block render (MIDI marshal, preview
|
||||
// mailbox drain, engine + drain sum, master-gain ramp). Component-state I/O + parameter
|
||||
// accessors live in processor_state.cpp; the off-thread reload/publish family lives in
|
||||
// processor_reload.cpp. process() and its per-block work stay ONE TU (T4-29): no virtual
|
||||
// seam, no cross-TU call on the per-sample path.
|
||||
// reasampler_processor.cpp — see reasampler_processor.h. This TU is the VST3 lifecycle +
|
||||
// the real-time process() path only: factory/queryInterface, initialize/terminate/
|
||||
// setActive, bus setup, and the block render. Component-state I/O + parameter accessors
|
||||
// live in processor_state.cpp; the off-thread reload/publish family lives in
|
||||
// processor_reload.cpp. process() and its per-block work stay one TU on purpose — no
|
||||
// virtual seam, no cross-TU call on the per-sample path.
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -17,8 +16,8 @@
|
||||
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
|
||||
#include "pluginterfaces/vst/vstspeaker.h"
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor
|
||||
#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
||||
#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor
|
||||
#include "shell/instrument/reasampler_embed.h" // embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
||||
|
||||
using namespace Steinberg;
|
||||
using namespace Steinberg::Vst;
|
||||
@@ -27,20 +26,15 @@ namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a
|
||||
// linear per-sample step derived from this at setupProcessing (gainRampStep_ =
|
||||
// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing
|
||||
// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as
|
||||
// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is
|
||||
// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_
|
||||
// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks.
|
||||
// Post-mixer gain ramp time (wall-clock): gainRampStep_ = 1/(kGainRampSeconds *
|
||||
// sampleRate_), per the no-hardcoded-rate ruling — ~20 ms full ramp at every host rate.
|
||||
constexpr double kGainRampSeconds = 0.020;
|
||||
|
||||
} // namespace
|
||||
|
||||
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
||||
// The host owns the returned reference. Cast up to the combined interface the SDK
|
||||
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
|
||||
// The host owns the returned reference; cast to IAudioProcessor so the FUnknown
|
||||
// refcount is correctly rooted.
|
||||
return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
|
||||
}
|
||||
|
||||
@@ -48,10 +42,8 @@ FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
||||
ReaSamplerProcessor::~ReaSamplerProcessor() = default;
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) {
|
||||
// S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for
|
||||
// IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed
|
||||
// shell. We own the shell (unique_ptr); the borrowed reference is valid because the
|
||||
// processor outlives it. All other iids fall through to the SDK's queryInterface.
|
||||
// REAPER queries the IEditController for IReaperUIEmbedInterface; hand it our
|
||||
// lazily-created embed shell (the processor outlives the borrowed reference).
|
||||
if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) {
|
||||
if (!embed_) embed_ = std::make_unique<ReaSamplerEmbed>(this);
|
||||
embed_->addRef();
|
||||
@@ -69,17 +61,11 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
||||
// instrument still loads, it just has no live bank to play.
|
||||
bridge_.connect(context);
|
||||
|
||||
// Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no
|
||||
// audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of
|
||||
// the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders
|
||||
// dual-mono through the stereo bus (both channels equal, centered), which is audibly
|
||||
// identical to a mono bus but never asks the host to re-map a live instance's pins. The
|
||||
// prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every
|
||||
// mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The
|
||||
// in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer
|
||||
// write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's
|
||||
// re-routing of the live instance's pins across the arrangement change. A fixed arrangement
|
||||
// is the maximally-standard VSTi shape and removes that whole negotiation surface.
|
||||
// One event input (MIDI, 16 channels), one audio output, no audio input. The output
|
||||
// bus is fixed stereo regardless of channel mode (mono renders dual-mono, centered).
|
||||
// Do not reintroduce per-mode bus renegotiation: flipping kMono<->kStereo via
|
||||
// restartComponent previously panned a dual-mono capture hard right in the host's pin
|
||||
// re-routing (see testDualMonoStereoSampleRendersCentered).
|
||||
addEventInput(STR16("MIDI In"), 16);
|
||||
addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo);
|
||||
|
||||
@@ -87,9 +73,8 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
||||
// process() is not running at terminate: free the live + draining instruments and
|
||||
// drain the graveyard. Take the pointers out of the atomics first so nothing else
|
||||
// races them.
|
||||
// process() is guaranteed stopped at terminate: free the live + draining instruments
|
||||
// and drain the graveyard.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
delete live_.exchange(nullptr);
|
||||
delete draining_.exchange(nullptr);
|
||||
@@ -98,33 +83,24 @@ tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
||||
// Activating: build the instrument from the currently-selected sample so the first
|
||||
// block after activation can play. Deactivating: process is now GUARANTEED stopped by
|
||||
// the host, so this is the safe point to reclaim the graveyard (the displaced engines
|
||||
// no reload could free while active). The build/drain are off the audio thread —
|
||||
// setActive is a main/UI-thread call.
|
||||
// Activating: build from the currently-selected sample so the first block after
|
||||
// activation can play. Deactivating: process is now guaranteed stopped, so this is
|
||||
// the safe point to reclaim the graveyard. Main/UI-thread call.
|
||||
if (state) {
|
||||
// Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED
|
||||
// sample refs — it needs no bank read, so it plays regardless of whether the
|
||||
// extension's PROJEXTSTATE has parsed yet (or the extension exists at all).
|
||||
//
|
||||
// #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a
|
||||
// pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic
|
||||
// refreshRefsFromBank copies the refs in when the bank blob is readable by
|
||||
// activation time, so an upgraded project plays on load without the instrument
|
||||
// ever being opened (and the next save is self-contained). Residual load-order
|
||||
// race, DAW-verifiable only: if the host activates this instance BEFORE the
|
||||
// project's ext-state lines parse, the lift misses here and — with no editor open —
|
||||
// nothing retries until the next activation or editor tick. MIGRATION NOTE: open a
|
||||
// pre-v10 instrument once after upgrading if it restores silent.
|
||||
// Resolves + decodes from the instance-owned refs — no bank read needed, so it
|
||||
// plays regardless of PROJEXTSTATE parse state. Also doubles as the non-editor
|
||||
// legacy-lift trigger for a pre-v10 blob: reloadInstrument's opportunistic
|
||||
// refreshRefsFromBank copies refs in when the bank blob is readable by now.
|
||||
// Residual load-order race (DAW-verifiable only): if the host activates before the
|
||||
// project's ext-state parses, nothing retries until the next activation or editor
|
||||
// tick — open a pre-v10 instrument once after upgrading if it restores silent.
|
||||
reloadInstrument();
|
||||
} else {
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
// process is guaranteed stopped: free EVERYTHING. The live instrument too — its
|
||||
// voices are frozen mid-flight, and if it survived deactivation the reactivate
|
||||
// reload would displace it into the DRAIN slot, resurrecting stale sustained
|
||||
// voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above),
|
||||
// so nothing is lost by clearing here.
|
||||
// Free EVERYTHING, including live_: its voices are frozen mid-flight, and if it
|
||||
// survived deactivation the reactivate reload would displace it into the drain
|
||||
// slot, resurrecting stale sustained voices as ghosts. Reactivation rebuilds from
|
||||
// scratch above, so nothing is lost.
|
||||
delete live_.exchange(nullptr);
|
||||
delete draining_.exchange(nullptr);
|
||||
graveyard_.clear();
|
||||
@@ -135,9 +111,8 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
||||
sampleRate_ = setup.sampleRate;
|
||||
maxBlockSize_ = setup.maxSamplesPerBlock;
|
||||
// T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at
|
||||
// every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host
|
||||
// guarantees setupProcessing never overlaps process), read on the audio thread only.
|
||||
// Resolve the gain-ramp step against the live host rate (host guarantees
|
||||
// setupProcessing never overlaps process).
|
||||
if (sampleRate_ > 0.0) {
|
||||
gainRampStep_ = static_cast<float>(1.0 / (kGainRampSeconds * sampleRate_));
|
||||
}
|
||||
@@ -147,11 +122,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
|
||||
SpeakerArrangement* inputs, int32 numIns,
|
||||
SpeakerArrangement* outputs, int32 numOuts) {
|
||||
// ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a
|
||||
// decode policy, never a bus fact). We take NO audio input, so any inputs are rejected.
|
||||
// Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse)
|
||||
// and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a
|
||||
// proposal keeps a valid arrangement of its own) — the host adapts its routing to us.
|
||||
// Fixed stereo output bus (channel mode is a decode policy, never a bus fact); no audio
|
||||
// input, so any inputs are rejected. Accept only a single stereo output proposal;
|
||||
// otherwise reject and keep stereo (per the VST3 contract, a plug-in that can't honor a
|
||||
// proposal keeps a valid arrangement of its own) — the host adapts to us.
|
||||
if (numIns < 0 || numOuts < 0) return kInvalidArgument;
|
||||
if (numIns > 0) return kResultFalse; // no audio input bus to arrange
|
||||
if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue;
|
||||
@@ -159,23 +133,19 @@ tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
// REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments
|
||||
// once for the whole block (two atomic acquires), then publish the MINIMUM installedAt
|
||||
// over the pointers held so the off-thread graveyard pruner knows exactly which
|
||||
// generations this block is holding (see the header proof).
|
||||
// Real-time: no allocation, no IO, no locks. Load live + draining once for the whole
|
||||
// block (two atomic acquires), then publish the minimum installedAt over the pointers
|
||||
// held so the off-thread graveyard pruner knows which generations this block holds (see
|
||||
// the header's drain-slot proof). We publish installedAt rather than re-reading
|
||||
// reloadGeneration_ to close an ordering race: a fresh read could observe a generation
|
||||
// newer than the pointers actually held, letting the pruner free an instrument still
|
||||
// in use.
|
||||
//
|
||||
// We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an
|
||||
// ordering race: reading reloadGeneration_ after the slots could observe a generation
|
||||
// newer than the pointers we actually hold, causing the pruner to free an instrument
|
||||
// process is still reading. installedAt was set on the reload path before the atomic
|
||||
// exchange that made the instrument visible.
|
||||
//
|
||||
// The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the
|
||||
// last reload: its already-sounding voices keep rendering (and receive note-offs) so a
|
||||
// curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons.
|
||||
// A racing reload can briefly leave the same pointer in both slots (live_ was loaded
|
||||
// before the swap, draining_ after); collapse that to live-only so one engine is never
|
||||
// advanced twice per frame.
|
||||
// The drain instrument is the previously-live snapshot displaced by the last reload:
|
||||
// its already-sounding voices keep rendering (and receive note-offs) so an edit never
|
||||
// cuts a ringing note; it receives no note-ons. A racing reload can briefly leave the
|
||||
// same pointer in both slots (live_ loaded before the swap, draining_ after); collapse
|
||||
// that to live-only so one engine is never advanced twice per frame.
|
||||
LoadedInstrument* inst = live_.load(std::memory_order_acquire);
|
||||
LoadedInstrument* drain = draining_.load(std::memory_order_acquire);
|
||||
if (drain == inst) drain = nullptr;
|
||||
@@ -190,20 +160,16 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
processGeneration_.store(heldGen, std::memory_order_release);
|
||||
|
||||
// Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine
|
||||
// voice silent) by naming its OWN installedAt (0 = no drain / still
|
||||
// sounding). Evaluated at block START — idleness is monotone for a drain (it receives no
|
||||
// note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply
|
||||
// publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe.
|
||||
// Publish whether the drain snapshot is fully idle, naming its own installedAt (0 = no
|
||||
// drain / still sounding). Idleness is monotone for a drain (no note-ons), so a
|
||||
// snapshot observed idle here stays idle. Bounded scan, relaxed store — RT-safe.
|
||||
drainIdleGeneration_.store(
|
||||
(drain && drain->fullyIdle()) ? drain->installedAt : 0,
|
||||
std::memory_order_relaxed);
|
||||
|
||||
// Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps
|
||||
// events at block granularity (no per-event sample-offset split) — audible timing is
|
||||
// within one block, adequate for Tier 0; sample-accurate scheduling is a later tier.
|
||||
// Note-offs also route to the DRAIN engine so a note held across a reload releases
|
||||
// its old-snapshot voice too (otherwise it would sustain until the next reload).
|
||||
// Marshal MIDI note-on/off at block granularity (no per-event sample-offset split;
|
||||
// sample-accurate scheduling is a later tier). Note-offs also route to the drain
|
||||
// engine so a note held across a reload releases its old-snapshot voice too.
|
||||
if (data.inputEvents) {
|
||||
const int32 count = data.inputEvents->getEventCount();
|
||||
for (int32 i = 0; i < count; ++i) {
|
||||
@@ -222,18 +188,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
if (inst) inst->engine.noteOff(e.noteOff.pitch);
|
||||
if (drain) drain->engine.noteOff(e.noteOff.pitch);
|
||||
} else if (e.type == Event::kLegacyMIDICCOutEvent) {
|
||||
// PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a
|
||||
// VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism
|
||||
// — the type is nominally an output event; DAW-verify, see handoff).
|
||||
// CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR
|
||||
// release tail; Trigger one-shots play through their bounded play length.
|
||||
// CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless
|
||||
// of play mode, including Trigger one-shots that ignore CC 123. This is the
|
||||
// true "panic" for a ringing one-shot (e.g. a full-length capture).
|
||||
// Both clear the mono held stack. Both apply to live AND drain. A ringing
|
||||
// preview note is a real engine voice since the PreviewCard retirement, so
|
||||
// the panics cover it with no separate routing. allNotesOff / allSoundsOff
|
||||
// are RT-safe (no allocation, bounded scans).
|
||||
// Panic: REAPER delivers raw input MIDI CC as kLegacyMIDICCOut events on the
|
||||
// INPUT event list (a REAPER-ism, DAW-verified). CC 123 (All Notes Off):
|
||||
// release semantics (Gate -> release tail; Trigger plays through). CC 120
|
||||
// (All Sounds Off): immediate hard silence, including Trigger. Both apply to
|
||||
// live + drain and cover a ringing preview note.
|
||||
const auto cc = static_cast<int>(e.midiCCOut.controlNumber);
|
||||
if (cc == kCtrlAllSoundsOff) {
|
||||
if (inst) inst->engine.allSoundsOff();
|
||||
@@ -246,16 +205,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
}
|
||||
|
||||
// S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed
|
||||
// atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last
|
||||
// one we consumed; fire it once, then latch the sequence so the same request never re-fires.
|
||||
// Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact
|
||||
// noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real
|
||||
// voice: it counts against the voice count, can steal / be stolen, and respects
|
||||
// Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's
|
||||
// isolation). The editor posts the root note, so it plays at unity.
|
||||
// Consume (advance the sequence) even when inst is null so a note-on posted while no instrument
|
||||
// is loaded does not re-fire stale on the next instrument load.
|
||||
// Preview mailbox: drain off-thread preview-trigger requests (one relaxed atomic load
|
||||
// each). A request is new when its packed sequence differs from the last consumed; fire
|
||||
// once, then latch the sequence. Drives the main VoiceEngine — same noteOn/noteOff as
|
||||
// host MIDI, so a preview note is a real voice. Consume even when inst is null so a
|
||||
// note-on posted while nothing is loaded does not re-fire stale later.
|
||||
{
|
||||
const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire);
|
||||
const std::uint16_t onSeq = static_cast<std::uint16_t>(on >> 16);
|
||||
@@ -272,16 +226,12 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire);
|
||||
const std::uint16_t offSeq = static_cast<std::uint16_t>(off >> 16);
|
||||
if (offSeq != 0 && offSeq != previewOffConsumed_) {
|
||||
// Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending
|
||||
// while nothing was loaded would otherwise survive until a (heal) reload lands
|
||||
// and release the NEXT preview press in the same block.
|
||||
previewOffConsumed_ = offSeq;
|
||||
// Route the preview note-off to BOTH engines (mirror of the host note-off): a
|
||||
// preview held across a reload — e.g. a curve edit committed mid-press — must
|
||||
// release the old-snapshot voice now draining, not just the (fresh) live one.
|
||||
// NOTE: preview shares the host-MIDI note space — noteOff releases the newest
|
||||
// voice at that pitch, so a preview release can release a host-held note at
|
||||
// Consume unconditionally (mirror of the on path) so a stale off does not
|
||||
// survive to release the NEXT preview press. Routes to both engines: a preview
|
||||
// held across a reload must release the old-snapshot voice too. NOTE: preview
|
||||
// shares the host-MIDI note space, so a release can release a host-held note at
|
||||
// the same pitch (inherent to routing preview through the real note path).
|
||||
previewOffConsumed_ = offSeq;
|
||||
if (inst) inst->engine.noteOff(static_cast<int>(off & 0xFF));
|
||||
if (drain) drain->engine.noteOff(static_cast<int>(off & 0xFF));
|
||||
}
|
||||
@@ -309,27 +259,22 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
// Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked
|
||||
// into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here
|
||||
// we simply match the buffers the host handed us: >=2 channels -> true stereo render into
|
||||
// ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the
|
||||
// render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here.
|
||||
// Render per the host's negotiated channel count (mode was baked into the decode
|
||||
// off-thread, so the mode itself is never read here): >=2 channels -> stereo into
|
||||
// ch0/ch1 (then mirror extras); exactly 1 -> mono. Adds into a cleared buffer.
|
||||
float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
|
||||
float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr;
|
||||
if (ch0 && ch1) {
|
||||
// Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo
|
||||
// path (both channels equal), so a mono capture in stereo mode is centered, not silent.
|
||||
// The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer).
|
||||
// A mono sample plays dual-mono via the engine's stereo path, so a mono capture in
|
||||
// stereo mode is centered, not silent. The drain engine's ringing tails add on top.
|
||||
for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; }
|
||||
if (inst) inst->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
|
||||
if (drain) drain->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
|
||||
// FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so
|
||||
// continuous knob drags produce no zipper noise and the true-zero bottom causes no click.
|
||||
// Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the
|
||||
// actual output. Branch-free inner loop; early-out when already at target. RT-safe.
|
||||
// Post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so
|
||||
// knob drags produce no zipper noise. Early-out when already at target.
|
||||
{
|
||||
const float gTarget = masterGain_.load(std::memory_order_relaxed);
|
||||
const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step
|
||||
const float gStep = gainRampStep_; // rate-derived per-sample step
|
||||
const float gSnap = 0.5f * gStep;
|
||||
const float diff = gTarget - gainCurrent_;
|
||||
if (diff < -gSnap || diff > gSnap) {
|
||||
@@ -349,13 +294,13 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
|
||||
// Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
|
||||
for (int32 ch = 2; ch < out.numChannels; ++ch) {
|
||||
if (float* buf = out.channelBuffers32[ch]) {
|
||||
for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
|
||||
}
|
||||
}
|
||||
// Block peak (max across L/R) for the embed strip's level indicator; RT-safe.
|
||||
// Block peak (max across L/R) for the embed strip's level indicator.
|
||||
float peak = 0.f;
|
||||
for (int32 i = 0; i < frames; ++i) {
|
||||
const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i];
|
||||
@@ -365,16 +310,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
embedPeak_.store(peak, std::memory_order_relaxed);
|
||||
} else if (ch0) {
|
||||
// Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel;
|
||||
// the replicate is defensive for a host that still hands >1 channel on a mono bus).
|
||||
// Mono: render into channel 0, replicate to any extra channels (defensive).
|
||||
for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
|
||||
if (inst) inst->engine.render(ch0, static_cast<std::size_t>(frames));
|
||||
if (drain) drain->engine.render(ch0, static_cast<std::size_t>(frames));
|
||||
// FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch:
|
||||
// post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe.
|
||||
// Same gain-ramp contract as the stereo branch above.
|
||||
{
|
||||
const float gTarget = masterGain_.load(std::memory_order_relaxed);
|
||||
const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step
|
||||
const float gStep = gainRampStep_; // rate-derived per-sample step
|
||||
const float gSnap = 0.5f * gStep;
|
||||
const float diff = gTarget - gainCurrent_;
|
||||
if (diff < -gSnap || diff > gSnap) {
|
||||
@@ -405,9 +348,8 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Report silence only when nothing is loaded (lets the host optimize when idle).
|
||||
// With an instrument loaded — or a drain snapshot still ringing out — we clear the
|
||||
// flag so a ringing voice is not skipped.
|
||||
// Report silence only when nothing is loaded (lets the host optimize when idle); with
|
||||
// a drain snapshot still ringing out, clear the flag so it is not skipped.
|
||||
out.silenceFlags = (inst || drain) ? 0
|
||||
: ((out.numChannels >= 64)
|
||||
? ~0ULL
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the
|
||||
// pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio
|
||||
// output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the
|
||||
// engine's audio into the output bus — so a chosen bank sample plays chromatically from
|
||||
// its root note in REAPER's routing/record/render path.
|
||||
//
|
||||
// SingleComponentEffect is the SDK's combined processor+controller base — sanctioned
|
||||
// for a non-distributable, REAPER-only plugin under D5/D6. It gives us
|
||||
// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own
|
||||
// state (the selected sample), and the IEditController seat so createView() can hand the
|
||||
// host our IPlugView LICE editor.
|
||||
//
|
||||
// SELF-CONTAINED PLAYBACK (pS architecture correction). The instance OWNS its sample: the
|
||||
// component state persists, per referenced bank sample, the project-relative WAV path +
|
||||
// decode intrinsics (SampleRefs), and reloadInstrument decodes straight from that table.
|
||||
// The extension's bank blob is a BROWSER SOURCE that opportunistically refreshes the refs
|
||||
// when readable — NEVER a runtime requirement for playback. A project restored before the
|
||||
// extension's PROJEXTSTATE parses (or with the extension absent) plays on load; the old
|
||||
// reopen-heal timer + poll-to-play machinery that papered over the bank dependency is gone.
|
||||
//
|
||||
// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO
|
||||
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — ref resolve, WAV
|
||||
// decode, keymap build, VoiceEngine construction — all happens OFF the audio thread
|
||||
// (reloadInstrument, driven from the main/UI thread) and is handed to process via a
|
||||
// single atomic pointer swap. See the LoadedInstrument handoff below.
|
||||
// reasampler_processor.h — VST3 SingleComponentEffect wiring the pure sampler core into
|
||||
// a playable instrument: event-input + stereo output bus, MIDI -> VoiceEngine, render.
|
||||
// Self-contained playback: component state owns per-sample WAV path + decode intrinsics
|
||||
// (SampleRefs); the bank blob is an opportunistic browser source, never a playback
|
||||
// dependency. Audio thread (process()) does no allocation/file-IO/bridge calls/locks;
|
||||
// loading happens off-thread (reloadInstrument) and hands off via one atomic pointer swap.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -42,37 +23,24 @@
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim
|
||||
// is retired from the processor family; the engine family's symbols — Keymap, VoiceEngine,
|
||||
// ChannelMode, VoiceMode, MonoTrigger, the voice-count constants — still live in flat
|
||||
// `reasampler` and resolve via the enclosing namespace).
|
||||
using instrument::map::ComponentState;
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::SampleRefs;
|
||||
using instrument::map::kPreviewVelocityDefault;
|
||||
|
||||
class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
|
||||
// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice
|
||||
// engine that plays it. The engine holds references into the keymap, so the two MUST live
|
||||
// and die together at a STABLE address — hence this is heap-allocated and neither copyable
|
||||
// nor movable. The audio thread only ever reads it through an atomic pointer; it is built
|
||||
// and destroyed off the audio thread.
|
||||
//
|
||||
// installedAt: the reloadGeneration_ value at which this instrument was atomically
|
||||
// installed into live_. Set on the reload path before the exchange. process() publishes
|
||||
// this field (not a fresh re-read of reloadGeneration_) so the published generation is
|
||||
// exactly the generation of the instrument actually in hand for the block.
|
||||
// Decoded keymap + the voice engine playing it. The engine holds references into the
|
||||
// keymap, so both must live/die together at a stable address — heap-allocated,
|
||||
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
|
||||
struct LoadedInstrument {
|
||||
Keymap keymap;
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reload generation at which this was installed
|
||||
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
|
||||
|
||||
// The takeover declick (GA fix, rev 2) is opted IN here — the PRODUCT default: any
|
||||
// restart of a sounding voice (mono Retrigger takeover/fallback, cross-sample legato
|
||||
// restart, POLY at-cap steal — the preview note included, now that it is a real pool
|
||||
// voice) smooths the cut via the difference-seeded ramp instead of clicking. The pure
|
||||
// core defaults it off (regression baseline) — same layering as kDefaultPitchEngine.
|
||||
// Takeover declick is on by default here (product default; the pure core defaults it
|
||||
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
|
||||
// of clicking.
|
||||
LoadedInstrument(Keymap km, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0,
|
||||
@@ -83,9 +51,8 @@ struct LoadedInstrument {
|
||||
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
|
||||
installedAt(gen) {}
|
||||
|
||||
// True when nothing in this snapshot is sounding. process() publishes this for the
|
||||
// drain slot so the off-thread retirer can park an idle drain in the graveyard early
|
||||
// (FA1-review Major #2). Bounded scan (<= maxVoices).
|
||||
// True when nothing in this snapshot is sounding; lets the off-thread retirer park an
|
||||
// idle drain early. Bounded scan (<= maxVoices).
|
||||
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
|
||||
|
||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||
@@ -95,8 +62,8 @@ struct LoadedInstrument {
|
||||
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
|
||||
public:
|
||||
ReaSamplerProcessor() = default;
|
||||
// Out-of-line so the owned ReaSamplerEmbed (held by unique_ptr, forward-declared here)
|
||||
// is a complete type at the destruction point (defined in the .cpp).
|
||||
// Out-of-line so the owned ReaSamplerEmbed (unique_ptr, forward-declared here) is
|
||||
// complete at the destruction point (defined in the .cpp).
|
||||
~ReaSamplerProcessor() override;
|
||||
|
||||
// The factory create function (registered in vst_entry.cpp).
|
||||
@@ -109,9 +76,9 @@ public:
|
||||
Steinberg::tresult PLUGIN_API terminate() override;
|
||||
Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override;
|
||||
|
||||
// Instance state = the selected bank sample id (D-B: a performance choice the
|
||||
// instrument owns; NEVER written back to the bank). Component-state, so a saved
|
||||
// REAPER project restores which sample each instance plays.
|
||||
// Instance state = the selected bank sample id (a performance choice the instrument
|
||||
// owns; never written back to the bank). Component-state, so a saved project restores
|
||||
// which sample each instance plays.
|
||||
Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override;
|
||||
Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override;
|
||||
|
||||
@@ -122,11 +89,9 @@ public:
|
||||
Steinberg::tresult PLUGIN_API process(
|
||||
Steinberg::Vst::ProcessData& data) override;
|
||||
|
||||
// Output-bus negotiation. The instrument has ONE canonical output arrangement: a FIXED
|
||||
// stereo bus (GA fix — the channel mode is a decode policy, never a bus fact; mono mode
|
||||
// renders dual-mono through it). We accept the host's proposal only when it is a single
|
||||
// stereo output; otherwise we reject (kResultFalse) but keep our stereo arrangement, so
|
||||
// getBusArrangement / getBusInfo always report 2 channels and the host routes accordingly.
|
||||
// Fixed stereo output bus — channel mode is a decode policy, never a bus fact; mono
|
||||
// renders dual-mono through it. Do not reintroduce per-instance bus renegotiation.
|
||||
// Accepts only a single stereo output proposal; otherwise rejects and keeps stereo.
|
||||
Steinberg::tresult PLUGIN_API setBusArrangements(
|
||||
Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns,
|
||||
Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override;
|
||||
@@ -135,107 +100,73 @@ public:
|
||||
// Hands the host our LICE IPlugView editor.
|
||||
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
||||
|
||||
// Override queryInterface to additionally expose REAPER's IReaperUIEmbedInterface (S6):
|
||||
// REAPER queries the IEditController for it to drive the inline TCP/MCP embed surface.
|
||||
// All other iids delegate to SingleComponentEffect's implementation unchanged.
|
||||
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
|
||||
// inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged.
|
||||
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
|
||||
void** obj) override;
|
||||
|
||||
// The embedded-strip activity level (0..1), read by the S6 embed shell on the UI thread.
|
||||
// Backed by embedPeak_, the per-block mono peak the audio thread stores relaxed — a
|
||||
// lock-free advisory readout, never touched with a lock the audio thread could contend.
|
||||
// The embedded-strip activity level (0..1) for the embed shell, UI thread. Backed by
|
||||
// embedPeak_, a lock-free relaxed atomic the audio thread writes each block.
|
||||
double embedActivityLevel() const {
|
||||
return static_cast<double>(embedPeak_.load(std::memory_order_relaxed));
|
||||
}
|
||||
|
||||
// Called by the editor (main/UI thread) when the user picks a sample, and internally
|
||||
// on load. SELF-CONTAINED (pS): resolves the selection/zones against the instance-OWNED
|
||||
// SampleRefs table, decodes each WAV OFF the audio thread, and publishes the built
|
||||
// instrument to process() via an atomic swap — NO bank read is required for playback.
|
||||
// When the live bank blob IS readable it is first folded into the refs table
|
||||
// (refreshRefsFromBank), which is both the browser's copy-the-ref-in mechanism and the
|
||||
// S9 live-recapture sync. A missing/unreadable WAV is the defined no-play (silence, no
|
||||
// retry). Returns the resolved selection id ("" if nothing was loaded) for the editor.
|
||||
// Resolves selection/zones against the instance-owned SampleRefs, decodes each WAV
|
||||
// off-thread, and publishes the built instrument via atomic swap — no bank read
|
||||
// required. When the bank blob is readable it's first folded into the refs table
|
||||
// (refreshRefsFromBank; the browser's copy-the-ref-in + recapture-sync mechanism). A
|
||||
// missing/unreadable WAV is the defined no-play (silence, no retry). Returns the
|
||||
// resolved selection id ("" if nothing loaded).
|
||||
std::string reloadInstrument();
|
||||
|
||||
// The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor
|
||||
// can react (repaint / re-snapshot its own view) only when something actually changed.
|
||||
// What pollBankSync did this tick, so the editor can react only when something changed.
|
||||
struct BankSyncResult {
|
||||
// The bank generation changed (or a pre-v10 legacy lift landed an instrument) ->
|
||||
// reloadInstrument ran and the editor should re-snapshot its bank view.
|
||||
bool reloaded = false;
|
||||
bool reloaded = false; // bank generation changed (or a legacy lift landed) -> reloaded
|
||||
bool applied = false; // a new assignment request was applied -> selection changed
|
||||
};
|
||||
|
||||
// Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF
|
||||
// THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). This is an
|
||||
// EDITOR/BROWSER sync path — playback never depends on it (pS). Semantics:
|
||||
// * S9: if the bank generation differs from what we last saw, call reloadInstrument() so
|
||||
// a recapture/ingest refreshes playback hands-free (atomic swap, glitch-free).
|
||||
// * S8: if a NEW (generation > last consumed) assignment request names a resolvable
|
||||
// sample AND this instance is the target (isFocusedTarget), apply it as the selection
|
||||
// and reload; an unresolvable request is DROPPED silently (marker advanced, no change);
|
||||
// a non-target instance neither applies nor advances its marker.
|
||||
// * LEGACY LIFT: a pre-v10 blob restored with intent but no refs retries the (cheap)
|
||||
// bank read until the blob is parseable, then reloads ONCE to copy the refs in.
|
||||
// TERMINATING: once the blob parses and NO referenced id resolves, the ids are
|
||||
// provably stale — the lift concludes permanently (legacyLiftShouldRun) instead of
|
||||
// churning a full bank read + reload every tick forever.
|
||||
// The consumed marker advances in component state (marked dirty via the host handler) so a
|
||||
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
|
||||
// (the editor passes true only for the instance whose editor is open — see the handoff).
|
||||
// Idempotent on an idle tick (generation unchanged + no new request -> no work).
|
||||
// Off-thread poll (editor's UI timer only) of the bank generation + assignment request;
|
||||
// playback never depends on it. Generation change -> reload; a resolvable NEW assignment
|
||||
// targeting this instance (isFocusedTarget) -> apply as selection + reload (unresolvable
|
||||
// ones drop silently, marker still advances); pre-v10 legacy blobs retry the bank read
|
||||
// until the refs lift in, then stop (legacyLiftShouldRun). The consumed marker persists
|
||||
// so a re-open does not re-apply. Idempotent on an idle tick.
|
||||
BankSyncResult pollBankSync(bool isFocusedTarget);
|
||||
|
||||
// The bridge, for the editor's live-state readout + sample list. Owned here; the
|
||||
// editor borrows it (outlives the editor).
|
||||
ReaperBridge& bridge() { return bridge_; }
|
||||
|
||||
// The live host sample rate latched from setupProcessing (the SAME rate reloadInstrument
|
||||
// resolves seconds->frames against). The editor's S-VIEW-3 envelope overlay reads it to place
|
||||
// its wall-clock seconds on the same time base the voice engine plays them over. 0.0 before
|
||||
// setupProcessing runs (the editor guards). Read on the UI thread; a plain load — sampleRate_
|
||||
// is set once by setupProcessing before any audio and does not change under the editor.
|
||||
// The live host sample rate latched from setupProcessing; the editor's envelope overlay
|
||||
// shares this time base. 0.0 before setupProcessing runs.
|
||||
double sampleRate() const { return sampleRate_; }
|
||||
// The current single-capture selection id (main/UI thread reads for the editor). Guarded
|
||||
// by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked
|
||||
// capture the default face plays chromatically when the performance map is empty; an EMPTY
|
||||
// id resolves to SILENCE (no first-sample fallback). A non-empty zoned map supersedes it.
|
||||
// The single-capture selection id (guarded by selectionMutex_, never read on the audio
|
||||
// thread): the default face's pick when the performance map is empty; a non-empty map
|
||||
// supersedes it. Empty id -> silence, no first-sample fallback.
|
||||
std::string selectedSampleId();
|
||||
void setSelectedSampleId(const std::string& id);
|
||||
|
||||
// The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written
|
||||
// by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the
|
||||
// audio thread — reloadInstrument bakes it into the LoadedInstrument's Keymap off-thread.
|
||||
// The performance map (zoned keymap). UI thread, guarded by performanceMutex_; never
|
||||
// read on the audio thread — reloadInstrument bakes it into the Keymap off-thread.
|
||||
PerformanceMap performanceMap();
|
||||
void setPerformanceMap(const PerformanceMap& map);
|
||||
|
||||
// The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread
|
||||
// (the editor toggle) and read off-thread by getState/reloadInstrument; guarded by
|
||||
// channelModeMutex_. NEVER read on the audio thread — process() renders against the host's
|
||||
// negotiated output channel count, and reloadInstrument bakes the mode into the decode.
|
||||
// GA fix: the mode is a DECODE policy only (downmix vs L/R split). The output bus is a
|
||||
// FIXED stereo bus — mono mode renders dual-mono through it (centered) — so a mode change
|
||||
// never renegotiates host I/O (the mono<->stereo bus flip's live pin remap was the
|
||||
// hard-right-pan defect).
|
||||
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
|
||||
// on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
|
||||
// fixed stereo, so a mode change never renegotiates host I/O.
|
||||
ChannelMode channelMode();
|
||||
// Sets the mode from the EDITOR TOGGLE (a deliberate user choice): latches the mode
|
||||
// EXPLICIT (the GA auto-default stops fighting it), and on a CHANGE reloads the instrument
|
||||
// so the next block decodes the new channel count. UI thread only.
|
||||
// Editor toggle: latches the mode explicit (auto-default stops fighting it) and
|
||||
// reloads so the next block decodes the new channel count. UI thread only.
|
||||
void setChannelMode(ChannelMode mode);
|
||||
|
||||
// The per-instance preview-trigger velocity (S-VIEW-4, MIDI 1..127). Read/written on the
|
||||
// UI thread (the Sample-view velocity knob) and by getState/setState (host load-save thread);
|
||||
// guarded by previewMutex_. Persisted in component state (v6). NOT read on the audio thread.
|
||||
// Per-instance preview-trigger velocity (MIDI 1..127), guarded by previewMutex_, not
|
||||
// read on the audio thread.
|
||||
std::uint8_t previewVelocity();
|
||||
void setPreviewVelocity(std::uint8_t velocity);
|
||||
|
||||
// --- Phase S voice-system parameters (per-instance, persisted in component state v7) ---
|
||||
// Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded
|
||||
// by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine
|
||||
// OFF-thread via rebuildVoiceEngine (a LIGHT rebuild around the already-decoded keymap; no
|
||||
// bridge read, no WAV re-decode) published through the same tail-preserving drain-slot swap,
|
||||
// so changing polyphony / mode / the retrigger toggle never cuts a ringing tail.
|
||||
// Voice-system parameters (per-instance), guarded by voiceParamsMutex_, not read on the
|
||||
// audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded keymap, no
|
||||
// bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail.
|
||||
int voiceCount();
|
||||
void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode();
|
||||
@@ -243,251 +174,160 @@ public:
|
||||
MonoTrigger monoTrigger();
|
||||
void setMonoTrigger(MonoTrigger trigger);
|
||||
|
||||
// --- FB1 post-mixer master gain (per-instance, persisted in component state v8) ---------
|
||||
// LINEAR gain in [0, masterGainMaxLinear()] (0.0 = -inf/true silence, 1.0 = unity, cap =
|
||||
// +24 dB; the pure master_gain module owns the dB knob taper). Held in an atomic so the
|
||||
// audio thread applies it with ONE relaxed load per block as a post-sum multiply over the
|
||||
// rendered output (engine + drain + preview) — no lock, no rebuild, no per-voice cost.
|
||||
// Written by the editor's Gain knob (UI thread) and setState; read by getState + process().
|
||||
// Post-mixer master gain, linear in [0, masterGainMaxLinear()] (0 = true silence, 1 =
|
||||
// unity, cap +24 dB). Atomic — the audio thread applies it as a per-block post-sum
|
||||
// multiply, no lock, no rebuild.
|
||||
double masterGainLinear() const {
|
||||
return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
|
||||
}
|
||||
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
|
||||
|
||||
// Fire a one-shot PREVIEW note-on / note-off through the live instrument's MAIN
|
||||
// VoiceEngine — the SAME noteOn/noteOff calls host MIDI takes, so a preview is a REAL
|
||||
// voice: it counts against the voice count, can steal / be stolen, and respects
|
||||
// Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's
|
||||
// isolation — preview must obey voicing). The editor posts the loaded capture's /
|
||||
// selected zone's ROOT note (plays at unity); previewNoteOn plays it at the current
|
||||
// previewVelocity() (the velocity curve applies); previewNoteOff releases it (Gate) —
|
||||
// Trigger zones ignore note-off and play through. OFF the audio thread (the editor's
|
||||
// preview-trigger button, UI thread); the request is handed to process() via a
|
||||
// lock-free single-slot mailbox drained at block start — no allocation, no lock on the
|
||||
// audio thread. A momentary button (down = on, up = off) reads as a natural key press.
|
||||
// This is PLAYBACK ONLY: it never captures, never inserts a timeline item.
|
||||
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
|
||||
// noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
|
||||
// count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
|
||||
// thread; handed to process() via a lock-free single-slot mailbox drained at block
|
||||
// start. Never captures, never inserts a timeline item.
|
||||
void previewNoteOn(int note);
|
||||
void previewNoteOff(int note);
|
||||
|
||||
// The instance-owned sample refs (pS self-contained playback): a snapshot copy for the
|
||||
// editor (waveform/loop-intrinsic fallback when the bank blob is not readable). UI
|
||||
// thread; guarded by refsMutex_.
|
||||
// Snapshot copy of the instance-owned sample refs, for the editor's waveform/loop
|
||||
// fallback when the bank blob is unreadable. Guarded by refsMutex_.
|
||||
SampleRefs sampleRefs();
|
||||
|
||||
private:
|
||||
// Phase S drain retirement (FA1-review Major #2): if process() has published that the
|
||||
// CURRENT drain instrument is fully idle (every engine voice silent),
|
||||
// move it out of the drain slot into the graveyard and prune — so an edited-away snapshot
|
||||
// stops costing resident memory as soon as its tails die, instead of squatting in the slot
|
||||
// until the NEXT reload. Off the audio thread only (takes reloadMutex_); driven from
|
||||
// pollBankSync's UI-timer tick (the same cadence that drives reloads — an idle drain with
|
||||
// no editor open simply waits for the next reload/deactivate, exactly the pre-fix bound).
|
||||
// Safe against a racing process(): idleness is monotone (the drain receives no note-ons)
|
||||
// and the published value names the drain's OWN installedAt, so a stale publication about
|
||||
// an OLDER drain can never retire a newer one; the graveyard prune's monotone-generation
|
||||
// proof (see below) covers the free.
|
||||
// If process() published that the drain instrument is fully idle, move it into the
|
||||
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
|
||||
// tails die. Off the audio thread only (driven by pollBankSync); safe against a racing
|
||||
// process() because idleness is monotone and the publication names the drain's own
|
||||
// installedAt (a stale value can never retire a newer occupant).
|
||||
void retireIdleDrain();
|
||||
|
||||
// Phase S voice-param LIGHT rebuild (voice-review Major #3): rebuild the engine
|
||||
// around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no
|
||||
// filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot
|
||||
// swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the
|
||||
// full reloadInstrument (which re-decodes every zone WAV from disk on the UI thread) was
|
||||
// pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe:
|
||||
// it is immutable after construction and, under reloadMutex_, the live instrument can
|
||||
// neither be swapped nor freed while we read it. When nothing is loaded this is a no-op —
|
||||
// the new params bake into the next real reload. Off the audio thread only.
|
||||
// Light voice-param rebuild: rebuilds the engine around a copy of the live instrument's
|
||||
// already-decoded Keymap (no bridge/disk) and publishes through the same drain-slot
|
||||
// swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
|
||||
void rebuildVoiceEngine();
|
||||
|
||||
// The pre-v10 LEGACY LIFT gate (#A): true when a lift attempt this tick could make
|
||||
// progress. Latches legacyLiftConcluded_ on a Stale proof (see the member below); the
|
||||
// pure decision itself is sample_map's legacyLiftDecision. Off the audio thread only
|
||||
// (bridge read + bank parse).
|
||||
// Pre-v10 legacy-lift gate: true when a lift attempt this tick could make progress
|
||||
// (see legacyLiftConcluded_). Off the audio thread only (bridge read + bank parse).
|
||||
bool legacyLiftShouldRun();
|
||||
|
||||
// Publish `built` (null = install silence) into live_: prune the graveyard by the last
|
||||
// process()-published generation, swap `built` into live_, displace the previous live into
|
||||
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
|
||||
// reloadMutex_ held — factored out so reloadInstrument and rebuildVoiceEngine share the ONE
|
||||
// safety-critical swap dance (see the handoff proof below).
|
||||
// Publishes `built` (null = install silence) into live_: prunes the graveyard by the
|
||||
// last process()-published generation, swaps `built` into live_, displaces the previous
|
||||
// live into the drain slot, and parks the evicted drain instrument in the graveyard.
|
||||
// Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine.
|
||||
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
||||
|
||||
// pS-usage: publish this instance's held captures to its per-instance ext-state key
|
||||
// ("rsusage_<instanceGuid>") so the extension's prune counts them as referenced — a
|
||||
// capture a live instance holds can never be pruned. Called at the end of every
|
||||
// reloadInstrument (the ONE choke point every play-set change funnels through:
|
||||
// selection change, zone edits, assignment consume, bank refresh, setState load), so
|
||||
// publishing is EAGER and needs no timer — a closed-editor instance's record is
|
||||
// already in ext-state from its last change/load. OFF THE AUDIO THREAD only (bridge
|
||||
// calls). Mints instanceGuid_ on first need; RE-mints when planUsagePublish detects
|
||||
// this state was cloned onto another track (FX copy / track duplication). Idempotent
|
||||
// on an unchanged play-set (skipWrite). `refs`/`ids` are reloadInstrument's own
|
||||
// snapshot — the refs table and the id set the instance currently plays.
|
||||
// Publishes this instance's held captures to its per-instance ext-state key
|
||||
// ("rsusage_<instanceGuid>") so the extension's prune can never reclaim them. Called at
|
||||
// the tail of every reloadInstrument, off the audio thread. Mints instanceGuid_ on
|
||||
// first need; re-mints on a detected clone (FX copy / track duplication).
|
||||
void publishUsage(const SampleRefs& refs, const std::vector<std::string>& ids);
|
||||
|
||||
ReaperBridge bridge_;
|
||||
|
||||
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
|
||||
// process() atomically loads `live_` AND `draining_` at block start and marshals/renders
|
||||
// against them — two atomic acquires, no lock, no free on the audio thread.
|
||||
// --- The audio-thread handoff (drain slot) ---
|
||||
// process() atomically loads live_ + draining_ at block start (two acquires, no lock).
|
||||
// reloadInstrument() (off-thread, serialized by reloadMutex_) swaps a new build into
|
||||
// live_; the displaced instrument moves to draining_, where process() keeps rendering
|
||||
// its already-sounding voices (and routes note-offs to it) so a reload never cuts a
|
||||
// ringing note — new note-ons go only to live_. The instrument evicted from draining_
|
||||
// (two reloads old) parks in graveyard_ for reclaim.
|
||||
//
|
||||
// reloadInstrument() (off-thread, serialized by reloadMutex_) builds a new
|
||||
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
|
||||
// NOT freed and NOT silenced: it moves into `draining_`, where process() keeps
|
||||
// rendering its already-sounding voices (and routes note-offs to it) so a reload —
|
||||
// a curve/param edit, a bank-generation refresh, an applied assignment — never cuts a
|
||||
// ringing note (FA1, bug 3b). New note-ons go ONLY to the live instrument, so the next
|
||||
// trigger plays the new state. The instrument evicted FROM the drain slot (two reloads
|
||||
// old) is parked in `graveyard_` for reclaim — a rapid second reload hard-cuts only the
|
||||
// oldest edit's tails (bounded compromise, documented).
|
||||
// Reclaim: process() publishes the minimum installedAt it holds via processGeneration_
|
||||
// (one relaxed store); the reload path frees graveyard entries older than that. Safe
|
||||
// because both slots are monotone in installedAt, so the published minimum is monotone
|
||||
// and an entry only reaches the graveyard after leaving both slots under reloadMutex_ —
|
||||
// an entry below the published minimum can never be loaded again.
|
||||
//
|
||||
// Bounded reclaim: process() publishes the MINIMUM installedAt over the (non-null)
|
||||
// pointers it holds this block via processGeneration_ — a single atomic store, RT-safe.
|
||||
// The reload path frees graveyard entries whose installedAt < seen (the last published
|
||||
// value).
|
||||
//
|
||||
// Safety argument: both slots are monotone in installedAt over time (live_ receives
|
||||
// successively newer builds; draining_ receives successively newer displaced lives), so
|
||||
// the published minimum is monotone across blocks, and any future process() load yields
|
||||
// installedAt >= seen. An entry only reaches the graveyard by leaving BOTH slots
|
||||
// (single-writer under reloadMutex_), so a graveyard entry with installedAt < seen can
|
||||
// never again be loaded and is not currently held — freeing it is safe. process()
|
||||
// publishes BEFORE rendering, so the pointers it renders with are covered by the value
|
||||
// the pruner reads (a stale lower read is merely conservative).
|
||||
//
|
||||
// The graveyard's upper bound is the number of reloads since process last ran
|
||||
// (typically 0–1 in normal use). Remaining entries drain at setActive(false) /
|
||||
// terminate(), when the host guarantees process is stopped.
|
||||
// Graveyard upper bound: reloads since process last ran (typically 0-1). Remaining
|
||||
// entries drain at setActive(false) / terminate(), when process is guaranteed stopped.
|
||||
std::atomic<LoadedInstrument*> live_{nullptr};
|
||||
std::atomic<LoadedInstrument*> draining_{nullptr}; // displaced instrument still rendering its tails
|
||||
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
|
||||
std::atomic<std::uint64_t> processGeneration_{0}; // min installedAt held by process (written on audio thread, read off-thread)
|
||||
// Phase S: the installedAt of the drain instrument process() last observed FULLY IDLE
|
||||
// (every engine voice silent; 0 = none / the current drain still sounds). Written relaxed on the audio thread each
|
||||
// block; read by retireIdleDrain() off-thread. Naming the generation (not a bool) closes
|
||||
// the swap race: a publication about an old drain can never retire its successor.
|
||||
// The installedAt of the drain instrument process() last observed fully idle (0 = none /
|
||||
// still sounds). Written relaxed on the audio thread each block; read by retireIdleDrain()
|
||||
// off-thread. Naming the generation (not a bool) closes the swap race: a publication about
|
||||
// an old drain can never retire its successor.
|
||||
std::atomic<std::uint64_t> drainIdleGeneration_{0};
|
||||
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
|
||||
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||
|
||||
// The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence).
|
||||
// Off-thread only; a small mutex guards the string against a getState/editor race. NOT
|
||||
// read on the audio thread.
|
||||
// The single-capture selection id ("" = no pick -> silence). Off-thread only, not read
|
||||
// on the audio thread.
|
||||
std::mutex selectionMutex_;
|
||||
std::string selectedSampleId_;
|
||||
|
||||
// The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only;
|
||||
// guarded against a getState/editor race. NOT read on the audio thread — reloadInstrument
|
||||
// bakes it into the LoadedInstrument's Keymap under the reload lock.
|
||||
// The performance map (zoned keymap). Off-thread only; reloadInstrument bakes it into
|
||||
// the Keymap under the reload lock, never read directly on the audio thread.
|
||||
std::mutex performanceMutex_;
|
||||
PerformanceMap performanceMap_;
|
||||
|
||||
// The instance-OWNED sample refs (pS self-contained playback): the path + intrinsics
|
||||
// per referenced bank sample that setState restores, reloadInstrument resolves/decodes
|
||||
// from, and getState persists (v10). Refreshed opportunistically from the bank blob
|
||||
// when it is readable; NEVER a bank dependency for playback. Off-thread only (UI +
|
||||
// load/save + reload); guarded against a getState/reload race. NOT read on the audio
|
||||
// thread.
|
||||
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
|
||||
// opportunistically from the bank blob when readable; never a bank dependency for
|
||||
// playback. Off-thread only.
|
||||
std::mutex refsMutex_;
|
||||
SampleRefs sampleRefs_;
|
||||
|
||||
// pS-usage publish identity + lifetime nonce (see publishUsage). instanceGuid_ is
|
||||
// the persisted per-instance identity (ComponentState v11; empty until first
|
||||
// publish); usageNonce_ is THIS incarnation's per-LIFETIME owner nonce, carried
|
||||
// INSIDE the published wire (UsageRecord.ownerNonce) — planUsagePublish's exact
|
||||
// ownership discriminator between "my own write" (clean replace) and "a foreign
|
||||
// writer" (union / re-mint). NEVER persisted: a persisted nonce would clone with
|
||||
// the state on FX copy, and two same-track copies converging on byte-identical
|
||||
// wires is exactly the ambiguity the nonce exists to break (a wire-equality
|
||||
// discriminator let sibling A clean-replace over sibling B's still-held paths —
|
||||
// the delete direction). Minted lazily on first publish; cleared on setState (a
|
||||
// restored blob is a new lifetime). Guarded by usageMutex_ (publish runs under
|
||||
// reloadMutex_ but getState/setState do not).
|
||||
// Usage-publish identity (see publishUsage). instanceGuid_ is the persisted per-instance
|
||||
// identity; usageNonce_ is this incarnation's per-lifetime owner nonce (never persisted —
|
||||
// a persisted nonce would clone with the state on FX copy, letting a sibling clean-
|
||||
// replace over another's held paths). Minted lazily; cleared on setState.
|
||||
std::mutex usageMutex_;
|
||||
std::string instanceGuid_;
|
||||
std::string usageNonce_;
|
||||
|
||||
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument);
|
||||
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
|
||||
// on the audio thread — process renders against the host's negotiated output channel count.
|
||||
// channelModeExplicit_ (GA, persisted v9): false = the mode is an un-touched default that
|
||||
// reloadInstrument may auto-default from the loaded capture's channel count; true = the user
|
||||
// deliberately toggled the mode (setChannelMode latches it) and it is never fought.
|
||||
// Per-instance channel mode, default Mono; not read on the audio thread (process
|
||||
// renders against the host's negotiated channel count). channelModeExplicit_: false =
|
||||
// reloadInstrument may auto-default the mode from the loaded capture; true = the user
|
||||
// deliberately toggled it (never fought thereafter).
|
||||
std::mutex channelModeMutex_;
|
||||
ChannelMode channelMode_ = ChannelMode::Mono;
|
||||
bool channelModeExplicit_ = false;
|
||||
|
||||
// The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in
|
||||
// component state (v5) so a re-open does not re-apply a request the user already got and
|
||||
// then changed away from. Written by pollBankSync (UI/timer thread) and getState; read by
|
||||
// pollBankSync + getState; seeded by setState. Guarded against a getState/poll race. NEVER
|
||||
// read on the audio thread. Default 0 -> a genuinely new first assign (gen >= 1) applies.
|
||||
// The last assignment-request generation consumed, persisted so a re-open does not
|
||||
// re-apply a stale request. Default 0 -> a genuinely new first assign (gen >= 1) applies.
|
||||
std::mutex assignMarkerMutex_;
|
||||
std::int64_t lastConsumedAssignGeneration_ = 0;
|
||||
|
||||
// The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync
|
||||
// is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a
|
||||
// -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the
|
||||
// FIRST poll after an editor open BASELINES the seen value without a redundant reload
|
||||
// (setState already loaded the instrument from the OWNED refs); a subsequent generation
|
||||
// CHANGE then drives the reload. Since pS there is NO reopen-heal here: playback never
|
||||
// depends on this poll — a v10 blob plays from its own refs at setState time. Besides a
|
||||
// generation change, pollBankSync reloads only for an APPLIED S8 assignment and for the
|
||||
// pre-v10 LEGACY LIFT. NOT read on the audio thread.
|
||||
// The bank generation this instance last saw. UI/timer-thread only (pollBankSync's sole
|
||||
// reader/writer), not persisted. -1 sentinel baselines the first poll without a
|
||||
// redundant reload; a later generation change then drives the reload.
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
|
||||
// The pre-v10 LEGACY LIFT's terminating latch (#A): set once legacyLiftShouldRun proves
|
||||
// the referenced ids STALE against a readable bank blob (LegacyLiftDecision::Stale) —
|
||||
// there is nothing to lift, so the lift stops re-firing (the steady state is one relaxed
|
||||
// load per tick, no bank read). Reset by setState (a new blob = new facts). NOT consulted
|
||||
// by the genChanged/applied reload paths, so a later bank change that re-introduces an id
|
||||
// (e.g. an extension-side undo) still refreshes the refs — the latch only gates the lift.
|
||||
// Atomic: written on the UI-timer thread (pollBankSync) and the host load thread (setState).
|
||||
// Legacy-lift terminating latch: set once legacyLiftShouldRun proves the referenced ids
|
||||
// stale against a readable bank blob, so the lift stops re-firing every tick. Reset by
|
||||
// setState (a new blob = new facts).
|
||||
std::atomic<bool> legacyLiftConcluded_{false};
|
||||
|
||||
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the
|
||||
// user's chosen strike velocity survives a project save/reload. Since Wave 2 the Sample-view
|
||||
// velocity knob writes it on the UI thread, so it is guarded by previewMutex_; setState and
|
||||
// getState (load/save thread) share the same guard. Default kPreviewVelocityDefault (64). NOT
|
||||
// read on the audio thread.
|
||||
// Preview-trigger velocity (MIDI 1..127, persisted). Default kPreviewVelocityDefault
|
||||
// (64). Not read on the audio thread.
|
||||
std::mutex previewMutex_;
|
||||
std::uint8_t previewVelocity_ = kPreviewVelocityDefault;
|
||||
|
||||
// Phase S voice-system parameters (per-instance, persisted in component state v7). Off-thread
|
||||
// only (UI voice deck + getState/setState + reloadInstrument); guarded against a getState/editor
|
||||
// race. Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior. NOT read on the audio
|
||||
// thread — reloadInstrument bakes them into the LoadedInstrument's engine off-thread.
|
||||
// Voice-system parameters (per-instance, persisted). Defaults {16, Poly, Retrigger}.
|
||||
// Not read on the audio thread — reloadInstrument bakes them into the engine off-thread.
|
||||
std::mutex voiceParamsMutex_;
|
||||
int voiceCount_ = kDefaultVoiceCount;
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
|
||||
// FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free
|
||||
// atomic — the target the UI thread writes; the audio thread ramps gainCurrent_ toward
|
||||
// it per-sample each block (linear interpolation, ~20 ms wall-clock at every host rate)
|
||||
// so sudden knob moves produce no zipper noise and the true-zero bottom causes no click.
|
||||
// Post-mixer master gain (linear, persisted). Lock-free atomic target; the audio thread
|
||||
// ramps gainCurrent_ toward it per-sample (~20 ms wall-clock at every host rate) so
|
||||
// knob moves produce no zipper noise.
|
||||
std::atomic<float> masterGain_{1.0f};
|
||||
// The audio-thread running gain value: tracks masterGain_ across blocks, stepping at
|
||||
// most gainRampStep_ per sample toward the target. Starts at unity (pre-FB1 default).
|
||||
// Written and read exclusively on the audio thread — no atomics needed.
|
||||
// Audio-thread running gain value, stepping at most gainRampStep_ per sample toward the
|
||||
// target. Written/read exclusively on the audio thread — no atomics needed.
|
||||
float gainCurrent_ = 1.0f;
|
||||
// T3-01: the per-sample ramp step, derived from kGainRampSeconds (20 ms wall-clock)
|
||||
// against the live host rate in setupProcessing — never a baked-in rate. The default is
|
||||
// the 48 kHz value so behavior before the first setupProcessing is unchanged. Written in
|
||||
// setupProcessing (host-serialized against process), read on the audio thread.
|
||||
// Per-sample ramp step derived from kGainRampSeconds against the live host rate in
|
||||
// setupProcessing — never a baked-in rate. Default is the 48 kHz value.
|
||||
float gainRampStep_ = 1.0f / 960.0f;
|
||||
|
||||
// --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) ---------
|
||||
// The editor's preview-trigger button posts a note-on/off request from the UI thread; process()
|
||||
// drains it at block start and drives the live instrument's MAIN VoiceEngine — the same
|
||||
// noteOn/noteOff host MIDI takes, so the preview obeys voicing. ONE slot per direction, each a packed
|
||||
// request whose high bits are a monotonically-incrementing sequence so process() detects a NEW
|
||||
// request by comparing against the last sequence it consumed (never re-firing a stale one). The
|
||||
// low 8 bits carry the note (on) / note (off); the on request also carries the velocity in the
|
||||
// next 8 bits, latched at post time so the audio thread reads no shared velocity field. A single
|
||||
// relaxed atomic load per block on the audio thread — RT-safe (no alloc, no lock).
|
||||
// packed = (seq << 16) | (velocity << 8) | note [note-on]
|
||||
// packed = (seq << 16) | note [note-off]
|
||||
// --- Preview-trigger mailbox (off-thread -> audio thread, lock-free) -----------------
|
||||
// One slot per direction, packed as (seq << 16) | (velocity << 8) | note [on] or
|
||||
// (seq << 16) | note [off]. process() detects a new request by comparing the packed
|
||||
// sequence against the last one consumed — a single relaxed atomic load per block,
|
||||
// RT-safe (no alloc, no lock).
|
||||
std::atomic<std::uint32_t> previewOnRequest_{0}; // 0 = no request posted yet
|
||||
std::atomic<std::uint32_t> previewOffRequest_{0};
|
||||
std::uint16_t previewOnSeq_ = 0; // UI-thread post counter (never 0 after first post)
|
||||
@@ -495,22 +335,17 @@ private:
|
||||
std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired
|
||||
std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired
|
||||
|
||||
// Latched from setupProcessing so setActive/reload can size against it. Read
|
||||
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
|
||||
// before any audio, and reloadInstrument guards on it before use.
|
||||
// Latched from setupProcessing; 0.0 is explicitly invalid (reloadInstrument guards on it).
|
||||
double sampleRate_ = 0.0;
|
||||
Steinberg::int32 maxBlockSize_ = 4096;
|
||||
|
||||
// --- S6 embedded TCP/MCP UI ---------------------------------------------
|
||||
// The embed shell (IReaperUIEmbedInterface), created lazily on the first queryInterface
|
||||
// and owned here for the processor's lifetime. REAPER borrows AddRef'd references from
|
||||
// queryInterface; the shell's refcount is a no-op because THIS unique_ptr governs its
|
||||
// destruction (the processor always outlives the borrowed references).
|
||||
// The embed shell, created lazily on the first queryInterface and owned here for the
|
||||
// processor's lifetime; REAPER's borrowed AddRef'd references are outlived by this
|
||||
// unique_ptr, so its own refcount is a no-op.
|
||||
std::unique_ptr<ReaSamplerEmbed> embed_;
|
||||
|
||||
// The per-block mono peak (0..1+) the audio thread stores relaxed; the embed strip's
|
||||
// level indicator reads it via embedActivityLevel(). Advisory only — a plain atomic,
|
||||
// no ordering coupling, never guarded by a lock the audio thread touches.
|
||||
// Per-block mono peak the audio thread stores relaxed; embedActivityLevel() reads it
|
||||
// for the embed strip's level indicator. Advisory only.
|
||||
std::atomic<float> embedPeak_{0.f};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
// 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).
|
||||
// reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument: the
|
||||
// plugin's class UID, vendor name/URL/email, so the processor, factory, and editor agree.
|
||||
// A class UID is FOREVER-STABLE once shipped (see this directory's CLAUDE.md) — minted
|
||||
// once, never regenerated. Filename + display name are channel-derived from app_version's
|
||||
// string accessors; this header owns only the binary UID identity.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -25,23 +12,16 @@
|
||||
|
||||
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).
|
||||
// Vendor identity, shared across channels — 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).
|
||||
// string from the same constants without pulling the VST3 SDK). The channel bit selects
|
||||
// which one this binary's factory registers — one class per binary, never both.
|
||||
|
||||
// The runtime FUID for the class this binary registers — the channel-selected UID.
|
||||
static const Steinberg::FUID kReaSamplerProcessorUID(REASAMPLER_ACTIVE_UID_1,
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
// 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.
|
||||
// vst_entry.cpp — the VST3 module class factory. Enumerates the one class this module
|
||||
// offers via the SDK's factory macros. Windows module exports — GetPluginFactory (here,
|
||||
// via BEGIN_FACTORY) and InitDll/ExitDll (SDK's dllmain.cpp) — are how REAPER discovers
|
||||
// and loads a VST3. Verified against vendor/vst3sdk/public.sdk/source/main/: the plug-in
|
||||
// must supply InitModule/DeinitModule (linked here via moduleinit.cpp). classFlags = 0 for
|
||||
// a SingleComponentEffect (non-distributable), matching the AGain example.
|
||||
|
||||
#include "public.sdk/source/main/pluginfactory.h"
|
||||
|
||||
@@ -26,23 +14,16 @@
|
||||
#include "shell/instrument/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 instrument's plugin identity (UID + filename + display) and its data identity
|
||||
// (ext_keys.h's kProjExtNamespace(), delegating to app_version::extStateNamespace()) both
|
||||
// fork from the one REASAMPLER_CHANNEL_IS_BETA 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.)
|
||||
// The guard below pins the two forks together so a refactor cannot split them: it asserts
|
||||
// the class UID this factory registers matches this binary's channel bit. If the #if in
|
||||
// reasampler_vst.h picked the wrong branch, the instrument's identity would diverge from
|
||||
// the namespace ext_keys reads (a beta-named plugin presenting the stable UID, or vice
|
||||
// versa) — this breaks the build instead of shipping that silent split.
|
||||
#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 &&
|
||||
@@ -62,13 +43,9 @@ static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_1 &&
|
||||
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).
|
||||
// Display name + version are channel-derived from app_version, not literals. DEF_CLASS2
|
||||
// expands inside GetPluginFactory(); PClassInfo2's constructor copies the char* into its
|
||||
// own buffer at that call, so .c_str() on the accessors' static-storage strings is valid.
|
||||
DEF_CLASS2(INLINE_UID(REASAMPLER_ACTIVE_UID_1, REASAMPLER_ACTIVE_UID_2,
|
||||
REASAMPLER_ACTIVE_UID_3, REASAMPLER_ACTIVE_UID_4),
|
||||
Steinberg::PClassInfo::kManyInstances, // cardinality
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
// 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.
|
||||
// SHELL: the only kit file that touches LICE + SWELL. Colors come from `theme`;
|
||||
// geometry from `component_geometry`. 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)
|
||||
#include "core/ui/bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve
|
||||
|
||||
// SWELL / LICE. On Windows use native Win32 (windows.h first); on mac/linux SWELL is
|
||||
// provided by the host. Mirrors the panel TUs' (shell/panel/) include discipline.
|
||||
// On Windows use native Win32 (windows.h first); on mac/linux SWELL is provided by the host.
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
@@ -23,7 +20,6 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
|
||||
using audio::ChannelEnvelope;
|
||||
using audio::columnMinMax;
|
||||
using audio::MinMax;
|
||||
@@ -32,24 +28,18 @@ using ui::roleColor;
|
||||
using ui::roleColorState;
|
||||
using ui::spectralColor;
|
||||
|
||||
// --- 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.
|
||||
// The one place a pure KitColor becomes a LICE_pixel (LICE_RGBA(r,g,b,a), verified against
|
||||
// lice.h). The theme owns the color; the shell owns the packing.
|
||||
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.
|
||||
// LICE_FillRect etc. take a float alpha (0..1) separate from the pixel's own alpha byte —
|
||||
// this converts a KitColor's 8-bit alpha so a disabled surface composites at the right opacity.
|
||||
float drawAlpha(const KitColor& c) { return c.a / 255.0f; }
|
||||
|
||||
// --- Font set (owned by the kit) ---------------------------------------------
|
||||
|
||||
struct KitFonts {
|
||||
LICE_CachedFont title;
|
||||
LICE_CachedFont label;
|
||||
@@ -92,8 +82,8 @@ UINT alignFlag(Align a) {
|
||||
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.
|
||||
// A 1px inner highlight on the top edge and shadow on the bottom edge gives a flat fill
|
||||
// dimension without a border.
|
||||
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);
|
||||
@@ -111,9 +101,8 @@ 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).
|
||||
// LICE_GradRect (lice.h) takes initial R/G/B/A plus per-axis deltas: ir..ia are the
|
||||
// top-left color, drdy..dady ramp DOWN the height so the bottom row reaches `bottom`.
|
||||
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;
|
||||
@@ -145,12 +134,8 @@ RECT toRect(const KitBox& b) {
|
||||
|
||||
} // 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");
|
||||
@@ -160,11 +145,8 @@ void kitFontsInit() {
|
||||
|
||||
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 is a static instance, never re-created, so free the HFONTs explicitly:
|
||||
// handing each a null font with OWNS_HFONT cleans up the prior HFONT (lice_text.h).
|
||||
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);
|
||||
@@ -172,8 +154,6 @@ void kitFontsShutdown() {
|
||||
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;
|
||||
@@ -191,8 +171,6 @@ void text(LICE_IBitmap* bmp, const KitBox& box, const char* str,
|
||||
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);
|
||||
@@ -211,9 +189,8 @@ void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label
|
||||
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);
|
||||
// Corner radius scales with height, clamped so tiny buttons stay legible.
|
||||
const int radius = b.height >= 20 ? 5 : (b.height >= 12 ? 3 : 2);
|
||||
const KitColor borderCol =
|
||||
(state == InteractionState::Active || state == InteractionState::Focus)
|
||||
@@ -224,9 +201,7 @@ void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label
|
||||
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).
|
||||
// Active fill is the accent — label goes in bg/base for contrast; else text/primary.
|
||||
const Role textRole = (state == InteractionState::Active)
|
||||
? Role::BgBase
|
||||
: Role::TextPrimary;
|
||||
@@ -237,10 +212,8 @@ void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label
|
||||
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)
|
||||
@@ -251,7 +224,6 @@ void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState
|
||||
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);
|
||||
@@ -263,18 +235,16 @@ void drawListRow(LICE_IBitmap* bmp, const ListRowBox& row, const char* label,
|
||||
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.
|
||||
// Focus ring is text/primary, 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.
|
||||
// Active rows draw the label in bg/base for contrast against the accent fill.
|
||||
if (label && *label) {
|
||||
const int inset = thumbWidth > 0 ? thumbWidth + 6 : 6;
|
||||
KitBox labelBox{b.x + inset, b.y, b.width - inset - 6, b.height};
|
||||
@@ -314,13 +284,7 @@ void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) {
|
||||
|
||||
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).
|
||||
// One filled span per pixel column (see draw_kit.h — gap-free via columnMinMax).
|
||||
for (int col = 0; col < innerW; ++col) {
|
||||
const MinMax mm = columnMinMax(bins, innerW, col);
|
||||
const int x = box.x + 2 + col;
|
||||
|
||||
+51
-93
@@ -1,37 +1,29 @@
|
||||
#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.
|
||||
// draw_kit — the LICE-facing SHELL half of the shared drawing kit: the ONE source of
|
||||
// drawing for the whole system (bank_panel, the VST editor, and the embed strip all fill,
|
||||
// button, row, slider, waveform, and draw TEXT through this same kit, so a control looks
|
||||
// identical everywhere).
|
||||
//
|
||||
// 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.
|
||||
// SHELL: touches LICE and SWELL (HFONT). Palette decisions come from the pure `theme`
|
||||
// module (role -> KitColor); layout/hit-test from the pure `component_geometry` /
|
||||
// mode_switch / etc. modules. This file only turns those pure answers into LICE calls.
|
||||
// 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.
|
||||
// Fonts: kitFontsInit() hands each LICE_CachedFont an HFONT with
|
||||
// LICE_FONT_FLAG_OWNS_HFONT, so the cached font frees the HFONT itself on shutdown/
|
||||
// reassignment. text() no-ops safely before init, 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.
|
||||
// Every function 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/component_geometry.h" // KitBox / SliderGeometry
|
||||
#include "core/audio/peaks.h" // Envelope
|
||||
#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.
|
||||
// LICE_pixel is a typedef (unsigned int), not forward-declarable, so the full lice.h is
|
||||
// included for the toLice() declaration; lice.h pulls in <windows.h> on Windows, which is
|
||||
// fine since this header is shell-only and never included by a pure module.
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
@@ -40,10 +32,7 @@ class LICE_IBitmap;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the interim core/namespaces.h shim
|
||||
// is retired; the kit's pure vocabulary names its Q-W1 homes explicitly). These are
|
||||
// deliberate re-exports: every draw_kit consumer speaks these types at the call
|
||||
// boundary, so they surface here exactly as panel_state.h surfaces the panel's.
|
||||
// Re-exports: every draw_kit consumer speaks these types at the call boundary.
|
||||
using audio::Envelope;
|
||||
using ui::InteractionState;
|
||||
using ui::KitBox;
|
||||
@@ -53,8 +42,8 @@ using ui::ListRowBox;
|
||||
using ui::Role;
|
||||
using ui::SliderGeometry;
|
||||
|
||||
// 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.
|
||||
// The kit's four cached fonts. 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
|
||||
@@ -66,90 +55,59 @@ enum class Font {
|
||||
// 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.
|
||||
// The one place a pure KitColor becomes a LICE_pixel. 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.
|
||||
// Creates the four cached fonts once; idempotent. Segoe UI for title/label/micro,
|
||||
// Consolas (tabular) for value-mono. No-op-safe: if font creation fails, text()
|
||||
// draws 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.
|
||||
// Frees the owned HFONTs. Idempotent. Call 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.
|
||||
// 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. No-op
|
||||
// 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).
|
||||
// Convenience overload: text in a palette ROLE's 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.).
|
||||
// The kit's foundational fill: a micro-gradient plus a 1px inner top-highlight/
|
||||
// bottom-shadow, so elevation reads without a border. `state` transforms the role
|
||||
// color (hover lightens, pressed darkens, disabled desaturates).
|
||||
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.
|
||||
// A rounded, gradient-filled button with a centered label. `warn == true` swaps the
|
||||
// surface to the warn role — the only place warn is drawn. 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.
|
||||
// A horizontal slider: track groove, accent-filled portion up to the handle, and the
|
||||
// handle itself. `geom` is the pure SliderGeometry the caller computed.
|
||||
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.
|
||||
// A selectable list row: row surface, an optional leading thumbnail inset
|
||||
// (`thumbWidth`, 0 for none), and a left-aligned label. Focus draws a 1px ring
|
||||
// distinct from the accent selection fill.
|
||||
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.
|
||||
// Callers pass waveformColumnCount(box) as computeEnvelope's `binCount` — overbinning
|
||||
// costs memory/CPU without changing a rendered pixel, since columnMinMax's exact
|
||||
// partition already makes the draw gap-free at any bins-to-pixels ratio. Kept at 1 (no
|
||||
// oversampling); present so existing call sites `kWaveformOversample *
|
||||
// waveformColumnCount(box)` compile unchanged.
|
||||
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.
|
||||
// A waveform drawn as a min/max plot: a midline per channel and one accent vertical
|
||||
// span per pixel column (peaks::columnMinMax — gap-free at any bins-to-pixels ratio).
|
||||
// The ONE waveform shape in the system: dock-panel thumbnail, browser cards, and
|
||||
// editor hero all render through this. `env` is sized to waveformColumnCount(box)
|
||||
// bins (clamped to frame count); an empty env draws just the midline.
|
||||
void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
// panel_audition.cpp — the audition/preview engine seam of the docked bank panel
|
||||
// (Q-W2 split of bank_panel.cpp; M5 Wave B). HOT PATH GUARDRAIL (T4-28 / Q-W2): the
|
||||
// preview path stays a DIRECT free-function call-through — no interface, no virtual
|
||||
// dispatch, no added header->TU indirection; the idle path is unchanged in shape.
|
||||
// panel_audition.cpp — the audition/preview engine seam of the docked bank panel.
|
||||
// Hot-path guardrail: the preview path stays a direct free-function call-through —
|
||||
// no interface, no virtual dispatch, no added header->TU indirection.
|
||||
//
|
||||
// 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). DAW-verified, not unit tested.
|
||||
// DAW-verified, not unit tested. main.cpp owns the API pointers; here they are extern.
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "shell/panel/panel_state.h"
|
||||
|
||||
// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h):
|
||||
// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the
|
||||
// STOCK symbols (not SWS-only) — see the audition section below.
|
||||
// PlayPreview/StopPreview (stock, not SWS-only) drive a caller-owned preview_register_t.
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_PlayPreview
|
||||
#define REAPERAPI_WANT_StopPreview
|
||||
@@ -23,26 +18,18 @@
|
||||
|
||||
namespace reasampler::panel {
|
||||
|
||||
// --- Audition preview ---------------------------------------------------------
|
||||
// Audition is preview playback only — never inserts into the arrange or mutates
|
||||
// the project/bank. PlayPreview streams a caller-owned PCM_source through
|
||||
// REAPER's preview bus.
|
||||
//
|
||||
// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW
|
||||
// playback only. It NEVER inserts into the arrange, creates items/tracks, or
|
||||
// mutates the project or bank. PlayPreview streams a caller-owned PCM_source
|
||||
// through REAPER's preview bus and touches nothing in the project.
|
||||
//
|
||||
// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by
|
||||
// signature/struct, not semantics — DAW-verify):
|
||||
// 1. REAPER's audio thread reads the preview_register_t by POINTER while the
|
||||
// preview is active (the struct's own comment mandates a cs/mutex we init),
|
||||
// so the register must outlive playback — we hold it in g_panel (static),
|
||||
// never on the stack.
|
||||
// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it
|
||||
// returns, making it safe to PCM_Source_Destroy the source immediately after.
|
||||
// This is the conventional contract (SWS' preview helpers rely on it) but is
|
||||
// NOT documented in the header — flagged. If a rare race surfaced, the fix is
|
||||
// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence).
|
||||
// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not
|
||||
// set mono (&1024). volume 1.0, loop false, curpos 0.
|
||||
// Runtime assumptions not documented in the SDK header (DAW-verify, not asserted):
|
||||
// 1. The audio thread reads preview_register_t by pointer while active, so it
|
||||
// must outlive playback — held in g_panel (static), never on the stack.
|
||||
// 2. StopPreview is assumed to detach the source before returning, so
|
||||
// PCM_Source_Destroy immediately after is safe (SWS' preview helpers rely on
|
||||
// the same contract). If a race ever surfaces, the fix is a StartPreviewFade
|
||||
// + deferred free.
|
||||
// 3. m_out_chan == 0 routes to the first hardware output pair (stereo, not mono).
|
||||
|
||||
void initPreview() {
|
||||
if (g_panel.previewInited) return;
|
||||
@@ -76,8 +63,7 @@ void deinitPreview() {
|
||||
g_panel.previewInited = false;
|
||||
}
|
||||
|
||||
// Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank.
|
||||
// L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw
|
||||
// `idx` is a display-order (slot) ordinal, resolved through orderedIds, not a raw
|
||||
// BankModel position.
|
||||
void startAudition(int idx) {
|
||||
stopAudition();
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// panel_bank_ops.cpp — the bank-CRUD-UX + menus seam of the docked bank panel
|
||||
// (Q-W2 split of the former bank_panel god-module; Phase B4/B5). Since Q-W6 the
|
||||
// promptless bank verbs live in shell/bank_ops (model op + persistBankOp, taking
|
||||
// ReaSamplerSession&); this TU is the panel's THIN UX SKIN over them — the menu
|
||||
// handlers (prompts / confirms / message boxes / panel-state nudges / repaint),
|
||||
// the book/bank accessors, the popup menus that drive them, and the selection-id /
|
||||
// OS-drag path resolvers. The bindable bank_actions family is the sibling skin.
|
||||
// panel_bank_ops.cpp — the bank-CRUD-UX + menus seam of the docked bank panel. The
|
||||
// promptless bank verbs live in shell/bank_ops (bankOp* + persistBankOp); this TU is
|
||||
// the panel's THIN UX SKIN over them — menu handlers, book/bank accessors, popup
|
||||
// menus, and the selection-id / OS-drag path resolvers. `bank_actions` is the
|
||||
// sibling bindable-action skin.
|
||||
//
|
||||
// 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). DAW-verified, not unit tested.
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers. DAW-verified, not
|
||||
// unit-tested.
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
@@ -18,7 +16,7 @@
|
||||
#include "shell/panel/panel_state.h"
|
||||
#include "shell/panel/panel_bank_ops.h"
|
||||
|
||||
#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs (the Q-W6 non-UI seam)
|
||||
#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs
|
||||
#include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
@@ -32,7 +30,7 @@ namespace reasampler::panel {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// --- Current-project directory (mirrors the persist shell's derivation, ext_state_io.cpp)
|
||||
// Mirrors the persist shell's derivation (ext_state_io.cpp).
|
||||
std::string currentProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
@@ -41,8 +39,6 @@ std::string currentProjectDir() {
|
||||
return normalizeSlashes(fs::path(rpp).parent_path().string());
|
||||
}
|
||||
|
||||
// --- Book / bank accessors ----------------------------------------------------
|
||||
|
||||
BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; }
|
||||
|
||||
// The BankModel a region currently displays. Pool region -> the pool; banks region ->
|
||||
@@ -72,17 +68,9 @@ std::vector<const Bank*> namedBanks() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Bank management ops (id-keyed; THIN UX SKINS over the bankOp* verbs) ------
|
||||
//
|
||||
// Q-W4/Q-W6: each handler here owns only the panel's UX (prompts / confirms /
|
||||
// message boxes / panel-state nudges / repaint); the model op + persist is the
|
||||
// shared bankOp* inner verb (shell/bank_ops), which takes the live session by
|
||||
// reference — the book() check answers the one session-liveness question per
|
||||
// handler. After a STRUCTURAL mutation (create/delete/evacuate) any
|
||||
// Bank*/BankModel& is invalid — we resolve fresh, pass ids, and let the next
|
||||
// refreshFingerprint repaint. On an unsaved project the empty-close discard in
|
||||
// persistBankOp ensures no stale state survives (matches the capture/B3
|
||||
// quiet-persist idiom).
|
||||
// Bank management ops (id-keyed; THIN UX SKINS over the bankOp* verbs). After a
|
||||
// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we
|
||||
// resolve fresh, pass ids, and let the next refreshFingerprint repaint.
|
||||
|
||||
void doCreateBank() {
|
||||
if (!book()) return;
|
||||
@@ -116,9 +104,9 @@ void doRenameBank(const std::string& bankId) {
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
// Delete with the RICHER confirm-on-non-empty affordance (B4): the confirm names the
|
||||
// member count AND offers evacuate as the one-click alternative (Yes=delete anyway,
|
||||
// No=evacuate-then-keep, Cancel=abort) — richer than B3's basic YESNO.
|
||||
// Delete with a confirm-on-non-empty affordance: the confirm names the member count
|
||||
// and offers evacuate as the one-click alternative (Yes=delete anyway,
|
||||
// No=evacuate-then-keep, Cancel=abort).
|
||||
void doDeleteBank(const std::string& bankId) {
|
||||
if (!book()) return;
|
||||
const Bank* bk = book()->bank(bankId);
|
||||
@@ -144,13 +132,10 @@ void doDeleteBank(const std::string& bankId) {
|
||||
}
|
||||
// r == 6 (Yes) falls through to a plain delete (drops members).
|
||||
}
|
||||
// S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then-
|
||||
// delete path moved/dropped members) — both change what a live instance could play. An
|
||||
// empty-bank delete is purely organizational, no bump. The ORIGINAL member count decides
|
||||
// (the No-path evacuated them moments ago, but the membership still changed).
|
||||
// Bump generation when the bank held samples — an empty-bank delete is purely
|
||||
// organizational. The ORIGINAL member count decides (the No-path already evacuated them).
|
||||
if (!bankOpDelete(*g_panel.session, bankId, /*bumpGeneration=*/members > 0)) return;
|
||||
// shownBankId is reconciled by the next fingerprint pass. If no named banks remain,
|
||||
// nudge focus to the pool so the selection has a valid home.
|
||||
// If no named banks remain, nudge focus to the pool so the selection has a valid home.
|
||||
if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool;
|
||||
invalidatePanel();
|
||||
}
|
||||
@@ -172,40 +157,33 @@ void doActivateBank(const std::string& bankId) {
|
||||
} // namespace
|
||||
|
||||
// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Thin panel
|
||||
// skin over bankOpTransfer (the one-home verb owns the loop, the verb-aware no-op
|
||||
// guardrail, and the undo-batched persist); this layer clears the stale selection
|
||||
// and repaints on an actual mutation.
|
||||
// skin over bankOpTransfer; clears the stale selection and repaints on an actual mutation.
|
||||
void transferSamples(const std::vector<std::string>& sampleIds,
|
||||
const std::string& srcBankId, const std::string& destBankId,
|
||||
bool copy) {
|
||||
if (!book()) return; // no live session — nothing to transfer within
|
||||
if (!bankOpTransfer(*g_panel.session, sampleIds, srcBankId, destBankId, copy))
|
||||
return; // nothing changed — no persist, no undo point
|
||||
// The selection indexed into the source; after a move those indices are stale, so
|
||||
// clear it (the fingerprint pass will also clear, but do it now for immediacy).
|
||||
// Selection indexed into the source; after a move those indices are stale.
|
||||
g_panel.selection = Selection{};
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Thin panel skin
|
||||
// over bankOpRemove — see the verb for the never-deletes-bytes / silent-remove /
|
||||
// one-Ctrl-Z contract. Clears the stale selection and repaints on an actual removal.
|
||||
// over bankOpRemove. Clears the stale selection and repaints on an actual removal.
|
||||
void removeSamples(const std::vector<std::string>& sampleIds,
|
||||
const std::string& srcBankId) {
|
||||
if (!book()) return; // no live session — nothing to remove from
|
||||
if (!bankOpRemove(*g_panel.session, sampleIds, srcBankId))
|
||||
return; // nothing changed — no persist, no undo point
|
||||
// The selection indexed into the source; after a remove those indices are stale, so
|
||||
// clear it (the fingerprint pass will also clear, but do it now for immediacy).
|
||||
g_panel.selection = Selection{};
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
// The selection's sample ids resolved against the FOCUSED region's bank (source of a
|
||||
// move/copy). Returns ids in bank order; empty when nothing selected.
|
||||
// move/copy). Selection ordinals index the DISPLAY (slot) order, not BankModel
|
||||
// insertion order. Returns ids in bank order; empty when nothing selected.
|
||||
std::vector<std::string> focusedSelectionIds() {
|
||||
// L7: selection ordinals index the DISPLAY (slot) order, not BankModel insertion order.
|
||||
// orderedIds[i] is the id at selection ordinal i.
|
||||
std::vector<std::string> ids;
|
||||
const RegionDisplay disp = focusedDisplay();
|
||||
const int count = disp.occupiedCount();
|
||||
@@ -214,14 +192,11 @@ std::vector<std::string> focusedSelectionIds() {
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Resolves the ARMED drag payload (g_panel.dragSampleIds, from g_panel.dragSourceBankId) to
|
||||
// the absolute, existing-file path list for a native OS drag-out (M11). Reuses the SAME M4
|
||||
// path machinery the panel uses for audition/insert (resolveBankFile over the current
|
||||
// project dir) — no temp copies; the drag points straight at the on-disk bank files. Each
|
||||
// id is looked up in its SOURCE bank's index (the payload's origin, not the focused region,
|
||||
// which can differ once the pointer roams), resolved, stat'd, then handed to the pure
|
||||
// drag_out::assemblePathList for dedupe + skip-missing/unresolved policy. Read-only: no
|
||||
// mutation of sample / index / selection (invariant #2).
|
||||
// Resolves the ARMED drag payload to the absolute, existing-file path list for a native
|
||||
// OS drag-out. Reuses resolveBankFile (audition/insert's path machinery) — no temp
|
||||
// copies. Each id is looked up in its SOURCE bank's index (not the focused region, which
|
||||
// can differ once the pointer roams), then handed to drag_out::assemblePathList for
|
||||
// dedupe + skip-missing/unresolved policy. Read-only.
|
||||
std::vector<std::string> resolveDragPathsForOs() {
|
||||
std::vector<ResolvedSample> resolved;
|
||||
BankBook* b = book();
|
||||
@@ -242,19 +217,13 @@ std::vector<std::string> resolveDragPathsForOs() {
|
||||
return assemblePathList(resolved).paths;
|
||||
}
|
||||
|
||||
// --- Popup menus --------------------------------------------------------------
|
||||
//
|
||||
// SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu
|
||||
// -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of
|
||||
// (label -> small int command), track it at screen coords, and switch on the return.
|
||||
// Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD
|
||||
// hands the chosen id straight back, so no hookcommand routing is involved.
|
||||
// SWELL/Win32 both expose CreatePopupMenu / InsertMenu / TrackPopupMenu(TPM_RETURNCMD) /
|
||||
// DestroyMenu. Menu command ids below are LOCAL to the popup (not REAPER action ids) —
|
||||
// TPM_RETURNCMD hands the chosen id straight back, so no hookcommand routing is involved.
|
||||
|
||||
namespace {
|
||||
|
||||
// Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both
|
||||
// accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative
|
||||
// position appending. Win32 and SWELL both treat pos < 0 as an append.
|
||||
// Win32 and SWELL both treat pos < 0 as append.
|
||||
void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) {
|
||||
UINT flags = MF_BYPOSITION | MF_STRING;
|
||||
if (grayed) flags |= MF_GRAYED;
|
||||
@@ -272,7 +241,7 @@ enum : unsigned int {
|
||||
kMenuDelete,
|
||||
kMenuEvacuate,
|
||||
kMenuCreate,
|
||||
kMenuRemove, // remove selected sample(s) from the source bank (B5)
|
||||
kMenuRemove, // remove selected sample(s) from the source bank
|
||||
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
|
||||
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
|
||||
};
|
||||
@@ -313,11 +282,10 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Opens the top-toolbar overflow ("⋯" More) popup at the button's screen position and fires the
|
||||
// chosen rare-capture variant's command (L5 refinement 1). Menu ids are LOCAL to the popup
|
||||
// (1-based ordinal into overflowMenuRows); TPM_RETURNCMD hands the chosen id back, then we
|
||||
// resolve + fire the corresponding registered command id via the SAME contract the visible
|
||||
// buttons use. Defined here (after menuAppend/menuSeparator); forward-declared above.
|
||||
// Opens the top-toolbar overflow ("⋯" More) popup and fires the chosen rare-capture
|
||||
// variant's command. Menu ids are LOCAL to the popup (1-based ordinal into
|
||||
// overflowMenuRows); we resolve + fire the corresponding registered command id via
|
||||
// the same contract the visible buttons use.
|
||||
void showMoreMenu() {
|
||||
if (!g_panel.hwnd) return;
|
||||
const std::vector<ActionBarRow> rows = overflowMenuRows();
|
||||
@@ -349,9 +317,8 @@ void showMoreMenu() {
|
||||
}
|
||||
|
||||
// Shows the move/copy menu for the current selection (the SOURCE is the focused
|
||||
// region's bank). Lists every OTHER bank (pool + named) as a move destination, then a
|
||||
// copy submenu-free flat list (copy entries follow the move block). Move is the
|
||||
// default (listed first); copy is the deliberate secondary act.
|
||||
// region's bank). Lists every OTHER bank as a move destination, then the same list
|
||||
// as a copy destination. Move is the default (listed first); copy is secondary.
|
||||
void showSelectionMenu(int screenX, int screenY) {
|
||||
const std::vector<std::string> sel = focusedSelectionIds();
|
||||
if (sel.empty()) return;
|
||||
@@ -401,18 +368,14 @@ void showSelectionMenu(int screenX, int screenY) {
|
||||
|
||||
} // namespace reasampler::panel
|
||||
|
||||
// --- Public API (panel_bank_ops.h) ---------------------------------------------
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// One home (Q-W4) for the former actions/panel byte-identical twins.
|
||||
// COMMA GUARD: GetUserInputs splits returned values on a separator defaulting to ',',
|
||||
// so the return separator is overridden to \x1f (un-typeable) via the documented
|
||||
// `separator=X` trailing pseudo-caption (SDK ~3806) — any printable name round-trips.
|
||||
// GetUserInputs splits returned values on a separator defaulting to ',', so the
|
||||
// separator is overridden to \x1f (un-typeable) via the documented `separator=X`
|
||||
// trailing pseudo-caption — any printable name round-trips.
|
||||
bool promptBankName(const char* title, const char* caption, const std::string& initial,
|
||||
std::string& out) {
|
||||
std::vector<char> buf(512, '\0');
|
||||
// Pre-fill: GetUserInputs seeds the field from the retvals buffer's initial value.
|
||||
std::snprintf(buf.data(), buf.size(), "%s", initial.c_str());
|
||||
const std::string captions = std::string(caption) + ",separator=\x1f";
|
||||
if (!GetUserInputs(title, 1, captions.c_str(), buf.data(),
|
||||
@@ -424,8 +387,6 @@ bool promptBankName(const char* title, const char* caption, const std::string& i
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Selection read seam --------------------------------------------------------
|
||||
|
||||
std::vector<std::string> bankPanelSelectedSampleIds() {
|
||||
return panel::focusedSelectionIds();
|
||||
}
|
||||
|
||||
@@ -1,55 +1,25 @@
|
||||
#pragma once
|
||||
// panel_bank_ops — the bank-CRUD-UX + selection-read seam of the bank panel (Q-W2
|
||||
// split of the former bank_panel god-module; Phase B4/B5). Since Q-W6 the
|
||||
// promptless bank verbs themselves live in the NON-UI shell/bank_ops seam
|
||||
// (bankOp* + persistBankOp, taking ReaSamplerSession&); this TU is the panel's
|
||||
// thin UX skin over them — prompts / confirms / message boxes / panel-state
|
||||
// nudges / repaints — plus the popup menus that drive them. The bank_actions
|
||||
// bindable family is the sibling skin over the same verbs. This header carries
|
||||
// the shared prompt helper and the panel's public selection-read surface.
|
||||
//
|
||||
// The selection reads are REAPER-free; the prompt helper is REAPER-facing (stock
|
||||
// dialogs) but SDK-free in this header.
|
||||
// panel_bank_ops — bank-CRUD-UX + selection-read seam of the bank panel. The
|
||||
// promptless verbs themselves live in shell/bank_ops (bankOp* + persistBankOp);
|
||||
// this TU is the panel's thin prompt/confirm/repaint skin over them, plus the
|
||||
// popup menus that drive them. `bank_actions` is the sibling skin.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Prompts the user for a single line of text via REAPER's stock input dialog
|
||||
// (GetUserInputs). `initial` pre-fills the field. Returns false (leaving `out`
|
||||
// untouched) on cancel or an empty entry. COMMA GUARD: the return separator is
|
||||
// overridden to \x1f (un-typeable) via the documented `separator=X` pseudo-caption,
|
||||
// so any printable name — commas included — round-trips whole (SDK ~3806/3808).
|
||||
// One home (Q-W4) for the former actions/panel byte-identical twins; shared by the
|
||||
// panel menus and the bank_actions bindable family.
|
||||
// Via GetUserInputs. Returns false (leaving `out` untouched) on cancel or empty
|
||||
// entry. Return separator is overridden to \x1f so any printable name round-trips.
|
||||
bool promptBankName(const char* title, const char* caption, const std::string& initial,
|
||||
std::string& out);
|
||||
|
||||
// The stable ids of the currently-selected samples, in bank (insertion) order.
|
||||
// Empty when nothing is selected or the panel has never opened. This is the clean
|
||||
// seam the `insert` action reads to know WHAT to place — it returns ids (not grid
|
||||
// indices) so the caller resolves against the live bank and is unaffected by the
|
||||
// panel's internal index bookkeeping. READ of panel state only; no mutation.
|
||||
//
|
||||
// Note: the panel's selection is cleared on a bank change (capture / project
|
||||
// load), so a returned id always names a sample present in the current bank at
|
||||
// the moment of the call; the caller still tolerates an absent id gracefully.
|
||||
//
|
||||
// Phase B4 (vertical split): the selection lives in whichever REGION the user last
|
||||
// interacted with (the pool grid on top or a named-bank grid below), which is NOT
|
||||
// necessarily the active/capture-target bank. The returned ids therefore name
|
||||
// samples in the FOCUSED region's displayed bank — the bank the user visibly
|
||||
// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those
|
||||
// ids belong to (the move/copy source).
|
||||
// Ids in bank (insertion) order, not grid indices. Empty when nothing is selected.
|
||||
// Pair with bankPanelSelectedSourceBankId() to know which bank these belong to.
|
||||
std::vector<std::string> bankPanelSelectedSampleIds();
|
||||
|
||||
// The bank id the current selection belongs to — the displayed bank of the region
|
||||
// the user last interacted with (pool region -> the pool id; named-banks region ->
|
||||
// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current
|
||||
// selection, and it is distinct from the active/capture-target bank (active ≠ shown).
|
||||
// Returns the pool id when nothing is selected or the panel has never opened (a safe
|
||||
// default source). READ of panel state only; no mutation.
|
||||
// The displayed bank of the region the user last interacted with — the SOURCE
|
||||
// bank for a move/copy. Returns the pool id as a safe default.
|
||||
std::string bankPanelSelectedSourceBankId();
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
+71
-112
@@ -1,18 +1,13 @@
|
||||
// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel
|
||||
// (Q-W2 split of bank_panel.cpp; the T4-01 NEW seam; M11/L7/S17). Owns WM_MOUSEMOVE
|
||||
// (hover resolution + tooltip timing + the live drag), the drop-target/gesture
|
||||
// classification, the cursor cues, button-up drop dispatch (reorder / replace /
|
||||
// move / copy / instrument-drop / OS drag-out), and right-click menu routing. Its
|
||||
// PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test) with
|
||||
// core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only
|
||||
// the live rects, modifier state, and side effects.
|
||||
//
|
||||
// PER-MOUSE-MOVE GUARDRAIL (T4-28): everything on the move path stays plain
|
||||
// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel:
|
||||
// WM_MOUSEMOVE (hover + tooltip timing + the live drag), drop-target/gesture
|
||||
// classification, cursor cues, button-up drop dispatch, and right-click menu routing.
|
||||
// Its PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test), with
|
||||
// core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only the
|
||||
// live rects, modifier state, and side effects. Per-mouse-move work stays plain
|
||||
// free-function calls — no interface, no virtual dispatch.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called
|
||||
// directly here (the FX-hotspot / OS-drag / instrument-drop shells own theirs);
|
||||
// REAPER SDK types arrive via panel_state.h.
|
||||
// directly here; REAPER SDK types arrive via panel_state.h.
|
||||
|
||||
#include <cstdlib> // std::abs (drag threshold)
|
||||
#include <string>
|
||||
@@ -20,14 +15,12 @@
|
||||
|
||||
#include "shell/panel/panel_state.h"
|
||||
|
||||
#include "shell/bank_ops/bank_ops.h" // persistBankOp — shared undo-block wrapper (R-B)
|
||||
#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11)
|
||||
#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop (S17)
|
||||
#include "shell/bank_ops/bank_ops.h" // persistBankOp — shared undo-block wrapper
|
||||
#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam
|
||||
#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop
|
||||
|
||||
namespace reasampler::panel {
|
||||
|
||||
// --- Drag (move between regions/onto a tab) -----------------------------------
|
||||
|
||||
constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag
|
||||
|
||||
namespace {
|
||||
@@ -55,9 +48,7 @@ void updateDropTarget(int x, int y) {
|
||||
g_panel.dropBankId = tabs[static_cast<std::size_t>(hit.index)]->id;
|
||||
return;
|
||||
}
|
||||
// Tab takes precedence over the region; if the point is in the banks region but
|
||||
// not on a specific tab, treat the whole grid as a drop zone for the shown bank.
|
||||
// No valid target when there are no named banks or no shown bank.
|
||||
// Tab takes precedence; otherwise the whole grid is a drop zone for the shown bank.
|
||||
if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) {
|
||||
if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) {
|
||||
g_panel.dropKind = DropKind::BanksRegion;
|
||||
@@ -76,9 +67,8 @@ void updateDropTarget(int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// The destination bank id under the current drop target (pool id for PoolRegion; the tab/
|
||||
// shown-bank id for Tab/BanksRegion; "" for no target). Derived from updateDropTarget's
|
||||
// dropKind/dropBankId — the single source of "what bank is under the pointer".
|
||||
// The destination bank id under the current drop target (pool id for PoolRegion; the
|
||||
// tab/shown-bank id for Tab/BanksRegion; "" for no target).
|
||||
std::string dropTargetBankId() {
|
||||
switch (g_panel.dropKind) {
|
||||
case DropKind::PoolRegion: return std::string(kPoolBankId);
|
||||
@@ -89,13 +79,12 @@ std::string dropTargetBankId() {
|
||||
return {};
|
||||
}
|
||||
|
||||
// L7: classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop)
|
||||
// the target slot, updating g_panel.cardGesture / dragTargetSlot. Call AFTER updateDropTarget
|
||||
// so dropKind/dropBankId are current. The pure card_drag::decideCardGesture owns the
|
||||
// precedence (leave-client -> OS; other-bank -> move/copy; same-bank grid -> reorder/replace);
|
||||
// the shell only supplies the region verdict, the same-bank target slot + occupancy, and the
|
||||
// live modifier state. The OS-drag-out boundary is handled by the existing decideGesture path
|
||||
// in onMouseMove BEFORE this runs, so here the pointer is always inside the client.
|
||||
// Classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop)
|
||||
// the target slot. Call AFTER updateDropTarget so dropKind/dropBankId are current.
|
||||
// card_drag::decideCardGesture owns the precedence (other-bank -> move/copy; same-bank
|
||||
// grid -> reorder/replace); this only supplies the region verdict, target slot +
|
||||
// occupancy, and modifier state (the OS-drag-out boundary is handled earlier, in
|
||||
// onMouseMove, so here the pointer is always inside).
|
||||
void classifyCardDrag(int x, int y) {
|
||||
g_panel.cardGesture = CardGesture::None;
|
||||
g_panel.dragTargetSlot = -1;
|
||||
@@ -111,10 +100,9 @@ void classifyCardDrag(int x, int y) {
|
||||
mods.alt = altDown();
|
||||
|
||||
if (!destBank.empty() && destBank == g_panel.dragSourceBankId) {
|
||||
// Same-bank grid: a reorder/replace target. Resolve the slot the pointer sits over
|
||||
// in the SOURCE bank's own region display + whether it is occupied.
|
||||
// Uses computeSlotRectsForDrop (one trailing row past maxSlot) so a drop beyond
|
||||
// the last occupied card resolves to a valid trailing slot, not a -1 miss.
|
||||
// Same-bank grid: a reorder/replace target. Resolve the slot + occupancy in the
|
||||
// SOURCE bank's display. computeSlotRectsForDrop adds one trailing row past
|
||||
// maxSlot so a drop beyond the last card resolves to a valid slot, not a -1 miss.
|
||||
mods.region = DropRegion::SameBankGrid;
|
||||
const bool isBanks = g_panel.dragSourceRegion == Region::Banks;
|
||||
const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
|
||||
@@ -141,16 +129,11 @@ void classifyCardDrag(int x, int y) {
|
||||
g_panel.cardGesture = decideCardGesture(x, y, client, st, mods);
|
||||
}
|
||||
|
||||
// Maps the pure L7 cursor cue to a SWELL stock cursor and sets it. The cue DECISION is pure
|
||||
// (card_drag::cursorForGesture); the shell owns only this SetCursor call + the resource choice.
|
||||
// Stock SWELL cursors (vendor/WDL/WDL/swell/swell-types.h:1320-1329, mirroring the Win32 OCR_*
|
||||
// set): Reorder -> IDC_SIZEALL (four-way move, the file-manager reorder idiom); Move ->
|
||||
// IDC_HAND (grab-and-place to another bank/tab); Copy -> IDC_UPARROW (no stock copy cursor
|
||||
// exists cross-platform — this is the closest distinct stock cue; a bespoke copy cursor would
|
||||
// need a resource file, deliberately NOT added); Replace -> IDC_SIZEWE (a distinct "swap
|
||||
// occupant" cue, shown ONLY when the pure result is Replace, i.e. Alt over an occupied slot);
|
||||
// OsDragOut -> the OS drag loop owns the cursor once handed off, so leave it (arrow here is
|
||||
// never seen — the handoff happens before this runs); Default/None -> IDC_ARROW.
|
||||
// Maps the pure cursor cue to a SWELL stock cursor (vendor/WDL/WDL/swell/swell-types.h,
|
||||
// mirroring Win32 OCR_*) and sets it; the cue decision is pure (card_drag::cursorForGesture).
|
||||
// Copy has no stock cross-platform cursor, so IDC_UPARROW is the closest distinct stock cue
|
||||
// (a bespoke resource was deliberately not added). OsDragOut leaves the cursor alone — the
|
||||
// OS drag loop owns it once handed off, and this branch is never actually seen.
|
||||
void applyDragCursor(CardGesture g) {
|
||||
const char* idc = IDC_ARROW;
|
||||
switch (cursorForGesture(g)) {
|
||||
@@ -164,26 +147,23 @@ void applyDragCursor(CardGesture g) {
|
||||
SetCursor(LoadCursor(nullptr, idc));
|
||||
}
|
||||
|
||||
// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring
|
||||
// handleClick's precedence exactly (so the element that lights on hover is the one a click
|
||||
// would hit). Returns HoverKind::None for the grid / dead space / a point outside the client
|
||||
// (the grid cells carry their own selection/focus chrome, not a kit hover surface). Pure
|
||||
// resolution over the same pure geometry the click path uses.
|
||||
// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback,
|
||||
// mirroring handleClick's precedence exactly (so the element that lights on hover is
|
||||
// the one a click would hit). Returns HoverKind::None for the grid / dead space (the
|
||||
// grid cells carry their own selection/focus chrome, not a kit hover surface).
|
||||
Hover resolveHover(int x, int y) {
|
||||
if (!g_panel.hwnd) return Hover{};
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
|
||||
|
||||
// TOP toolbar: the far-right More button, then the frequent buttons (matching the click
|
||||
// order — first zone top-to-bottom).
|
||||
// TOP toolbar, then footer, then BOTTOM toolbar, matching handleClick's precedence.
|
||||
{
|
||||
const MenuButtonRect mb = topMenuButtonRect(w);
|
||||
if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1};
|
||||
const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows());
|
||||
if (hit >= 0) return Hover{HoverKind::TopBarButton, hit};
|
||||
}
|
||||
// Footer: mode-toggle segments, Tail button, then Prune (matching the click order).
|
||||
{
|
||||
const int seg = footerToggleSegmentHit(x, y, w, h);
|
||||
if (seg >= 0) return Hover{HoverKind::ModeSegment, seg};
|
||||
@@ -192,12 +172,10 @@ Hover resolveHover(int x, int y) {
|
||||
const ButtonRect pb = pruneButtonRectFor(w, h);
|
||||
if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1};
|
||||
}
|
||||
// BOTTOM toolbar buttons.
|
||||
{
|
||||
const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows());
|
||||
if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit};
|
||||
}
|
||||
// Region chrome: full-height toggles, create button, tabs.
|
||||
if (poolShown()) {
|
||||
const RECT pr = poolRegionRect(w, h);
|
||||
const RECT ftb = fullHtBtnRect(pr);
|
||||
@@ -222,10 +200,10 @@ Hover resolveHover(int x, int y) {
|
||||
}
|
||||
|
||||
// Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no
|
||||
// per-move jank — the "speed is the selling point" repaint discipline). L5: a hover CHANGE also
|
||||
// resets the tooltip timer (hoverSinceTick) and hides any shown tooltip, so the tooltip only
|
||||
// appears after the pointer rests kTooltipDelayMs on ONE element (the delay is applied by the
|
||||
// poll tick in maybeShowTooltip). A move within the SAME element leaves the timer running.
|
||||
// per-move jank). A hover CHANGE also resets the tooltip timer (hoverSinceTick) and hides
|
||||
// any shown tooltip, so the tooltip only appears after the pointer rests kTooltipDelayMs
|
||||
// on ONE element (applied by the poll tick in maybeShowTooltip); a move within the SAME
|
||||
// element leaves the timer running.
|
||||
void updateHover(int x, int y) {
|
||||
const Hover next = resolveHover(x, y);
|
||||
if (next != g_panel.hovered) {
|
||||
@@ -239,10 +217,9 @@ void updateHover(int x, int y) {
|
||||
} // namespace
|
||||
|
||||
// Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past
|
||||
// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the
|
||||
// OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer;
|
||||
// WM_MOUSEMOVE's updateHover resets the timer, so a moving pointer never trips it. No-op when the
|
||||
// current hover has no tooltip (grid / chrome / the More button).
|
||||
// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from
|
||||
// the OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated
|
||||
// timer; updateHover resets the timer on every move, so a moving pointer never trips it.
|
||||
void maybeShowTooltip() {
|
||||
if (g_panel.tooltipShown) return;
|
||||
const HoverKind k = g_panel.hovered.kind;
|
||||
@@ -255,9 +232,8 @@ void maybeShowTooltip() {
|
||||
}
|
||||
|
||||
void onMouseMove(int x, int y) {
|
||||
// Hover feedback (L2): resolve + repaint-on-change, but NOT during a drag (the drag owns
|
||||
// the visual feedback then — a drop-target highlight, not a hover). Cleared to None when
|
||||
// the pointer is over the grid / dead space.
|
||||
// Hover feedback: resolve + repaint-on-change, but NOT during a drag (the drag owns
|
||||
// the visual feedback then — a drop-target highlight, not a hover).
|
||||
if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y);
|
||||
|
||||
if (g_panel.dragArmed && !g_panel.dragging) {
|
||||
@@ -267,9 +243,9 @@ void onMouseMove(int x, int y) {
|
||||
g_panel.dragging = true;
|
||||
g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion);
|
||||
g_panel.dragSampleIds = focusedSelectionIds();
|
||||
// The single card actually grabbed = the focus ordinal's id. This is the L7
|
||||
// in-grid reorder/replace subject (see onLBtnUp) — "drag a card" is a single-card
|
||||
// gesture, distinct from the multi-select move/copy payload in dragSampleIds.
|
||||
// The single card actually grabbed = the focus ordinal's id — the in-grid
|
||||
// reorder/replace subject (see onLBtnUp), distinct from the multi-select
|
||||
// move/copy payload in dragSampleIds.
|
||||
{
|
||||
const RegionDisplay disp = focusedDisplay();
|
||||
const int f = g_panel.selection.focus;
|
||||
@@ -283,13 +259,9 @@ void onMouseMove(int x, int y) {
|
||||
}
|
||||
}
|
||||
if (g_panel.dragging) {
|
||||
// M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the
|
||||
// pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant
|
||||
// #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture
|
||||
// splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an
|
||||
// InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a
|
||||
// pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays
|
||||
// Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint.
|
||||
// Inside the client rect it stays the internal bank-to-bank drag. Once it LEAVES,
|
||||
// drag_out::decideGesture splits three ways: single-capture over REAPER's OWN UI ->
|
||||
// InstrumentDrop; multi-capture or fully outside REAPER -> OsDrag; inside -> Internal.
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
|
||||
@@ -298,10 +270,8 @@ void onMouseMove(int x, int y) {
|
||||
DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
|
||||
st.singleCapture = (g_panel.dragSampleIds.size() == 1);
|
||||
|
||||
// Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can
|
||||
// only arise there) and only for a single-capture payload — the SDK hit-test is skipped
|
||||
// on the common internal-drag path so it costs nothing there. The screen conversion is
|
||||
// Windows-only (D5); resolveFxDropTarget owns the REAPER hit query.
|
||||
// Only resolved OUTSIDE the client rect and for a single-capture payload, so the SDK
|
||||
// hit-test costs nothing on the common internal-drag path.
|
||||
FxDropTarget fx;
|
||||
if (!inside && st.singleCapture) {
|
||||
POINT sp{x, y};
|
||||
@@ -313,11 +283,9 @@ void onMouseMove(int x, int y) {
|
||||
const DragGesture gesture = decideGesture(x, y, client, st);
|
||||
|
||||
if (gesture == DragGesture::InstrumentDrop) {
|
||||
// Track the FX hotspot for the release; the highlight is REAPER's own FX-button
|
||||
// hover feedback under the pointer (the drop is driven on button-up). We keep the
|
||||
// internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does
|
||||
// NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the
|
||||
// panel does not also paint a bank-drop cue while the drag is out over a track.
|
||||
// Track the FX hotspot for the release. Unlike OsDrag this does NOT hand off to
|
||||
// a modal OS loop, so the internal-drag capture stays alive; clear any bank
|
||||
// drop-target highlight so the panel doesn't paint that cue too.
|
||||
g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr;
|
||||
g_panel.dropKind = DropKind::None;
|
||||
g_panel.dropBankId.clear();
|
||||
@@ -333,11 +301,8 @@ void onMouseMove(int x, int y) {
|
||||
// drag state (the resolver reads dragSourceBankId / dragSampleIds).
|
||||
const std::vector<std::string> paths = resolveDragPathsForOs();
|
||||
|
||||
// Reset internal drag state and release capture NOW: DoDragDrop runs its own
|
||||
// modal loop and takes over mouse capture, so the internal drag must be fully
|
||||
// wound down first (no stale dragging/dropKind, no lingering SetCapture). A
|
||||
// cancelled/empty OS drag therefore leaves the panel in a clean, no-op state
|
||||
// (invariant #2 — nothing mutated).
|
||||
// DoDragDrop runs its own modal loop and takes over mouse capture, so the internal
|
||||
// drag must be fully wound down first.
|
||||
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
|
||||
g_panel.dragArmed = false;
|
||||
g_panel.dragging = false;
|
||||
@@ -353,9 +318,9 @@ void onMouseMove(int x, int y) {
|
||||
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
|
||||
return;
|
||||
}
|
||||
// Inside the client: classify the in-grid gesture (L7 reorder/replace vs the existing
|
||||
// move/copy) and reflect it as a cursor cue. updateDropTarget first so dropKind/
|
||||
// dropBankId are current for classifyCardDrag's same-vs-other-bank decision.
|
||||
// Inside the client: classify the in-grid gesture (reorder/replace vs move/copy) and
|
||||
// reflect it as a cursor cue. updateDropTarget first so dropKind/dropBankId are
|
||||
// current for classifyCardDrag's same-vs-other-bank decision.
|
||||
updateDropTarget(x, y);
|
||||
classifyCardDrag(x, y);
|
||||
applyDragCursor(g_panel.cardGesture);
|
||||
@@ -363,12 +328,10 @@ void onMouseMove(int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// L7 in-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap-
|
||||
// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the pure
|
||||
// BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z (persistBankOp opens the
|
||||
// batched undo point + saves). A no-op reorder (already at the target, model returns false)
|
||||
// opens no undo point. Selection reasons over slot order, so it is cleared after — the
|
||||
// fingerprint pass rebuilds it against the new order.
|
||||
// In-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap-
|
||||
// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the
|
||||
// pure BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z. A no-op reorder
|
||||
// opens no undo point. Selection reasons over slot order, so it is cleared after.
|
||||
namespace {
|
||||
|
||||
void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) {
|
||||
@@ -379,10 +342,9 @@ void doReorderDrop(const std::string& id, const std::string& bankId, int targetS
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
// L7 Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is
|
||||
// removed from the bank's index (index-only, file untouched — pool guard enforced in the pure
|
||||
// BankBook::replaceSample). Rejected (pool guard / absent) = a true NO-OP: no fallback insert,
|
||||
// no undo point (per spec). One drop = one Ctrl-Z on success.
|
||||
// Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is
|
||||
// removed from the bank's index (index-only, file untouched — pool guard enforced in the
|
||||
// pure BankBook::replaceSample). Rejected = a true NO-OP: no fallback insert, no undo point.
|
||||
void doReplaceDrop(const std::string& newId, const std::string& oldId,
|
||||
const std::string& bankId) {
|
||||
if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return;
|
||||
@@ -409,24 +371,22 @@ void resetDragState() {
|
||||
}
|
||||
|
||||
// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides:
|
||||
// * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each.
|
||||
// * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy).
|
||||
// * Reorder / Replace -> in-grid, within the source bank; one Ctrl-Z each.
|
||||
// * Move / Copy -> the cross-bank transfer (Ctrl = copy).
|
||||
// * None -> a drop over dead space / the source-bank gap = no-op.
|
||||
// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove.
|
||||
void onLBtnUp(int x, int y) {
|
||||
if (g_panel.dragging) {
|
||||
// S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a
|
||||
// Drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a
|
||||
// ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move,
|
||||
// NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank
|
||||
// drop (the pointer is out over a track, not over a bank region). Single-capture only (the
|
||||
// gesture never armed for a multi payload), so dragSampleIds.front() is the capture.
|
||||
// NOT an OS drag, NEVER a timeline insert. Takes priority over the in-grid / cross-bank
|
||||
// drop. Single-capture only, so dragSampleIds.front() is the capture.
|
||||
if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) {
|
||||
const std::string sampleId = g_panel.dragSampleIds.front();
|
||||
performInstrumentDrop(g_panel.instrumentDropTrack,
|
||||
buildInstrumentDropPreset(sampleId));
|
||||
// Read-only over the bank + arrange: the ONLY mutations are the new FX instance +
|
||||
// its state (both undoable in performInstrumentDrop). No book change, no ext-state,
|
||||
// no dirty-mark here.
|
||||
// Read-only over the bank + arrange: the only mutations are the new FX instance +
|
||||
// its state (both undoable in performInstrumentDrop).
|
||||
} else {
|
||||
updateDropTarget(x, y);
|
||||
classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed)
|
||||
@@ -481,7 +441,6 @@ void handleRightClick(int x, int y) {
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
|
||||
|
||||
// Tab management menu.
|
||||
if (banksShown()) {
|
||||
const RECT br = banksRegionRect(w, h);
|
||||
const TabStripRect strip = banksTabStripRect(br);
|
||||
|
||||
+90
-173
@@ -1,13 +1,9 @@
|
||||
// panel_input.cpp — the input + detection seam of the docked bank panel (Q-W2 split
|
||||
// of bank_panel.cpp). Owns left-click / wheel / keyboard routing (plain free-function
|
||||
// calls on the per-event path — T4-28), the accelerator registration, the tail-setting
|
||||
// read/mutate helpers, and the timer-driven new-content auto-tag detection (D2 Wave 2).
|
||||
// Mouse-MOVE (hover + the card-drag state machine) lives in panel_drag; the bank-change
|
||||
// panel_input.cpp — the input + detection seam of the docked bank panel: left-click /
|
||||
// wheel / keyboard routing, accelerator registration, tail-setting read/mutate helpers,
|
||||
// and timer-driven new-content auto-tag detection. Mouse-MOVE lives in panel_drag; the
|
||||
// fingerprint pass lives in panel_thumbnails (it owns the cache it invalidates).
|
||||
//
|
||||
// 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). DAW-verified, not unit tested.
|
||||
// Compiled into the reaper_reasampler MODULE, without REAPERAPI_IMPLEMENT (main.cpp
|
||||
// owns the API pointers). DAW-verified, not unit-tested.
|
||||
|
||||
#include <map>
|
||||
#include <set>
|
||||
@@ -17,15 +13,15 @@
|
||||
#include "shell/panel/panel_state.h"
|
||||
#include "shell/panel/panel_input.h"
|
||||
|
||||
#include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch (R3)
|
||||
#include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch
|
||||
#include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation
|
||||
#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2)
|
||||
#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
|
||||
#include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2)
|
||||
#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation (D2/D4)
|
||||
#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag
|
||||
#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam
|
||||
#include "shell/capture/track_guid.h" // guidString — canonical track GUID key
|
||||
#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation
|
||||
|
||||
// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane
|
||||
// state to classify an item's lane as managed vs manual.
|
||||
// New-content detection: enumerate live tracks + items and read fixed-lane state to
|
||||
// classify an item's lane as managed vs manual.
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
@@ -50,23 +46,17 @@ TailSetting currentTail() {
|
||||
|
||||
namespace {
|
||||
|
||||
// Commits the current tail setting to ext state and marks the active project dirty
|
||||
// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only
|
||||
// path that calls SetProjExtState for the tail key — calling it here closes the gap
|
||||
// where toggle/scroll would dirty the project but the new value was never written.
|
||||
// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h).
|
||||
// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way.
|
||||
// NON-DESTRUCTIVE: touches nothing in the bank/arrange.
|
||||
// Commits the current tail setting to ext state and marks the active project dirty so the
|
||||
// change travels inside the .rpp on Ctrl+S — closes the gap where toggle/scroll would dirty
|
||||
// the project but never write the new value. No-ops cleanly on an unsaved project.
|
||||
void markTailDirty() {
|
||||
if (g_panel.session) g_panel.session->saveToActiveProject();
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (proj) MarkProjectDirty(proj);
|
||||
}
|
||||
|
||||
// Routes a click in a toolbar to the hit button's action, fired through the command-id contract
|
||||
// (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click
|
||||
// was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the
|
||||
// caller stops before grid handling. `rows` is the toolbar's inventory.
|
||||
// Routes a click in a toolbar to the hit button's action via Main_OnCommand. Returns true
|
||||
// iff the click was inside the bar band, so the caller stops before grid handling.
|
||||
bool handleToolbarClick(int x, int y, const ActionBarRect& bar,
|
||||
const std::vector<ActionBarRow>& rows) {
|
||||
if (bar.height <= 0) return false;
|
||||
@@ -78,46 +68,26 @@ bool handleToolbarClick(int x, int y, const ActionBarRect& bar,
|
||||
x >= bar.x && x < bar.x + bar.width;
|
||||
}
|
||||
const ActionBarRow& row = rows[static_cast<std::size_t>(hit)];
|
||||
// A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the
|
||||
// action and never falls through to the grid (a dead button reads as inert, not absent).
|
||||
// A disabled button (opposite-mode gate) is claimed but no-ops — the click never fires
|
||||
// the action and never falls through to the grid (a dead button reads as inert, not absent).
|
||||
if (!row.enabled) return true;
|
||||
const int cmd = resolveBarCommandId(row);
|
||||
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- New-content detection (D2 Wave 2) ----------------------------------------
|
||||
//
|
||||
// REAPER exposes no "item/track added" callback, so we diff live project state on the
|
||||
// existing timer. Each tick: enumerate every track GUID and every item GUID, diff
|
||||
// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new
|
||||
// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane
|
||||
// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All
|
||||
// enumeration is READ-ONLY on the project; the only mutation is to the in-memory
|
||||
// membership index (persisted by persist on the next save, same as an action-driven tag).
|
||||
|
||||
// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified
|
||||
// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so
|
||||
// panel_input.cpp stays self-contained without pulling in view.cpp's private namespace.
|
||||
// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). Value verified in view.cpp;
|
||||
// reproduced locally so this file stays self-contained.
|
||||
constexpr int kFreeModeFixedLanes = 2;
|
||||
|
||||
bool isFixedLaneTrack(MediaTrack* tr) {
|
||||
return static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
|
||||
}
|
||||
|
||||
// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h):
|
||||
// itemGuid(it) and itemLaneName(tr, it). panel_input.cpp no longer carries its own copies.
|
||||
|
||||
// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set,
|
||||
// baseline input) and, for each item, records whether it sits on a manual lane so a
|
||||
// newly-detected item can be exempted from auto-tag without a second project walk.
|
||||
// `trackItemGuids` additionally maps each track GUID to the item GUIDs it carries, so a
|
||||
// newly-detected item's PRE-EXISTING siblings can be resolved (the adoption / strand
|
||||
// guard) without a second project walk.
|
||||
//
|
||||
// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack,
|
||||
// laneName) from lane_keys — the same predicate the apply path consults — so the exemption
|
||||
// rule is defined in exactly one place and is unit-tested there.
|
||||
// Enumerates the live project's track + item GUIDs. Fills `allGuids` and, per item,
|
||||
// whether it sits on a manual lane (exempt from auto-tag). `trackItemGuids` maps each
|
||||
// track to its item GUIDs so a newly-detected item's PRE-EXISTING siblings resolve in
|
||||
// one lookup. Manual-lane classification uses the single pure predicate isOnManualLane.
|
||||
void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
|
||||
std::map<std::string, bool>& itemOnManualLane,
|
||||
std::map<std::string, std::vector<std::string>>& trackItemGuids) {
|
||||
@@ -140,9 +110,8 @@ void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
|
||||
std::string ig = itemGuid(it);
|
||||
if (ig.empty()) continue;
|
||||
allGuids.insert(ig);
|
||||
// Classify via the single shared predicate. For a fixed-lane track we read
|
||||
// the item's lane name; for a normal track we pass "" (isOnManualLane returns
|
||||
// false immediately for non-fixed-lane tracks regardless of name).
|
||||
// "" for a normal track — isOnManualLane returns false immediately for a
|
||||
// non-fixed-lane track regardless of name.
|
||||
const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
|
||||
itemOnManualLane[ig] = isOnManualLane(fixedLane, ln);
|
||||
itemsOnTrack.push_back(ig);
|
||||
@@ -150,35 +119,26 @@ void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
|
||||
}
|
||||
}
|
||||
|
||||
// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones
|
||||
// into the active mode. Runs every timer tick regardless of panel open/close (content
|
||||
// is created in the arrange). READ-ONLY on the project; mutates only the in-memory
|
||||
// membership index.
|
||||
// One detection tick: REAPER exposes no "item/track added" callback, so this diffs live
|
||||
// GUIDs against the baseline and auto-tags the new ones into the active mode. Runs every
|
||||
// timer tick regardless of panel open/close. READ-ONLY on the project; mutates only the
|
||||
// in-memory membership index — deliberately OUTSIDE any Undo block (auto-tag is a
|
||||
// background metadata update, not a destructive edit; an Undo block here would flood
|
||||
// REAPER's history with an entry per tick that sees new content).
|
||||
//
|
||||
// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a
|
||||
// background metadata update (like setting a label), not a destructive project edit. The
|
||||
// persist shell (ext_state_io.cpp) writes it on the next project save alongside the bank
|
||||
// and view state, the same way an action-driven tag is persisted. Wrapping this in an Undo
|
||||
// block would flood the REAPER undo history with a new entry for every timer tick that sees
|
||||
// new content.
|
||||
// Returns true iff this tick tagged at least one new GUID into a mode — the signal the
|
||||
// caller uses to decide whether to run the lane-minting pass (a track can only newly
|
||||
// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint.
|
||||
// Returns true iff this tick tagged at least one new GUID — the signal the caller uses to
|
||||
// decide whether to run the lane-minting pass.
|
||||
bool detectNewContent() {
|
||||
if (!g_panel.session) return false;
|
||||
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
|
||||
// A project (re)load re-arms the first-poll guard so we never diff across two
|
||||
// projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded()
|
||||
// on the tick persist restores the project's membership + active mode, which sets
|
||||
// reloadPending. Draining it here re-baselines against the fully-loaded set (that
|
||||
// same tick's reapply-active-mode enumerated those tracks, so they are present),
|
||||
// and the observe() below returns nothing new — pre-existing untagged tracks stay
|
||||
// Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so
|
||||
// no separate first-tick handling is needed here. Using persist's GUID-primary load
|
||||
// signal (not a local pointer compare) is what fixes the reload-mis-tag: the two
|
||||
// identity checks can no longer diverge on a recycled ReaProject* address.
|
||||
// projects. bankPanelNotifyProjectLoaded() sets reloadPending on the tick persist
|
||||
// restores membership + active mode; draining it here re-baselines against the
|
||||
// fully-loaded set, so pre-existing untagged tracks stay Arrange rather than getting
|
||||
// mass-tagged. Using persist's load signal (not a local ReaProject* compare) is what
|
||||
// fixes the reload-mis-tag bug: pointer identity can recycle across projects.
|
||||
if (g_panel.reloadPending) {
|
||||
g_panel.contentBaseline.reset();
|
||||
g_panel.reloadPending = false;
|
||||
@@ -210,12 +170,9 @@ bool detectNewContent() {
|
||||
for (const auto& [trackGuid, items] : trackItemGuids)
|
||||
for (const std::string& ig : items) trackOfItem[ig] = trackGuid;
|
||||
|
||||
// The distinct modes the PRE-EXISTING (not-new-this-tick) MANAGED-ELIGIBLE items on
|
||||
// `trackGuid` resolve to. Untagged siblings resolve to Arrange (leafBelongsToMode's
|
||||
// default); new siblings are excluded; manual-lane siblings are EXEMPT — exactly as
|
||||
// planLaneMinting ignores them when computing a track's own-item mode span, so the
|
||||
// adoption guard's view of the track matches the split decision's. Drives the adoption
|
||||
// / strand guard in autoTagNewContent.
|
||||
// The distinct modes the PRE-EXISTING, managed-eligible items on `trackGuid` resolve
|
||||
// to. Untagged siblings default to Arrange; new siblings excluded; manual-lane
|
||||
// siblings EXEMPT — matching planLaneMinting's own-item mode span computation.
|
||||
const auto preExistingTrackModes =
|
||||
[&](const std::string& trackGuid) -> std::set<std::string> {
|
||||
std::set<std::string> modes;
|
||||
@@ -233,8 +190,8 @@ bool detectNewContent() {
|
||||
};
|
||||
|
||||
// Split the new GUIDs into tracks vs items so the pure decision can apply the
|
||||
// manual-lane exemption to items only. A GUID present in the item-lane map is an
|
||||
// item; otherwise it is a track (track GUIDs never appear in that map).
|
||||
// manual-lane exemption to items only. A GUID in the item-lane map is an item;
|
||||
// otherwise it's a track.
|
||||
std::vector<std::string> newTracks;
|
||||
std::vector<NewItem> newItems;
|
||||
for (const std::string& g : added) {
|
||||
@@ -256,28 +213,21 @@ bool detectNewContent() {
|
||||
return !tags.empty();
|
||||
}
|
||||
|
||||
// The item count the SELECTION reasons over — the focused region's occupied-cell count.
|
||||
// L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not
|
||||
// selectable). Occupied count == index size by construction: every index member maps to
|
||||
// exactly one occupied slot (gaps are empty slots, which the index never backs), so the
|
||||
// raw index size IS the dense selection-space extent.
|
||||
// The item count the SELECTION reasons over. Occupied count == index size by
|
||||
// construction, so the raw index size IS the dense selection-space extent.
|
||||
int focusedItemCount() {
|
||||
const BankModel* idx = indexForRegion(g_panel.focusedRegion);
|
||||
return idx ? static_cast<int>(idx->size()) : 0;
|
||||
}
|
||||
|
||||
// --- Click routing ------------------------------------------------------------
|
||||
|
||||
// Handles a header/tab-strip/button click for the banks region. Returns true if the
|
||||
// click was consumed (a region-chrome hit), false to fall through to grid selection.
|
||||
bool handleBanksChromeClick(int x, int y, const RECT& region) {
|
||||
// Full-height toggle button.
|
||||
const RECT ftb = fullHtBtnRect(region);
|
||||
if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) {
|
||||
bankPanelToggledBanksFullHeight();
|
||||
return true;
|
||||
}
|
||||
// "+" create button.
|
||||
const RECT cb = createBtnRect(region);
|
||||
if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) {
|
||||
doCreateBank();
|
||||
@@ -326,16 +276,15 @@ bool handlePoolChromeClick(int x, int y, const RECT& region) {
|
||||
|
||||
// Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) /
|
||||
// bottom toolbar / region chrome / grid selection, and arm a potential drag when the click
|
||||
// lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom.
|
||||
// lands on a selected cell. Order mirrors the three-zone layout top-to-bottom.
|
||||
void handleClick(int x, int y) {
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
|
||||
|
||||
// TOP toolbar: the far-right More button first (its rect sits in the band's reserved right
|
||||
// strip, outside the action rect), then the frequent capture/placement buttons. A button
|
||||
// fires its registered action via the command-id contract; the band is claimed whole (a
|
||||
// gap/overflow miss is a harmless no-op, never a fall-through). Capture never auto-inserts.
|
||||
// TOP toolbar: the far-right More button first, then the frequent capture/placement
|
||||
// buttons. A button fires its registered action via the command-id contract; the band
|
||||
// is claimed whole (a gap/overflow miss is a harmless no-op, never a fall-through).
|
||||
{
|
||||
const MenuButtonRect mb = topMenuButtonRect(w);
|
||||
if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; }
|
||||
@@ -345,10 +294,8 @@ void handleClick(int x, int y) {
|
||||
// the More button) so a click there is inert chrome, never a fall-through to the grid.
|
||||
if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return;
|
||||
|
||||
// Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design]
|
||||
// toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a
|
||||
// click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar /
|
||||
// grid so a footer click never selects a cell.
|
||||
// Footer: mode toggle (left) -> Tail button -> Prune (right). Checked before the bottom
|
||||
// toolbar / grid so a footer click never selects a cell.
|
||||
{
|
||||
const int seg = footerToggleSegmentHit(x, y, w, h);
|
||||
if (seg >= 0) {
|
||||
@@ -363,9 +310,7 @@ void handleClick(int x, int y) {
|
||||
|
||||
const FooterBarLayout fb = footerBarLayoutFor(w, h);
|
||||
if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) {
|
||||
// Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates
|
||||
// the SESSION's tail setting (capture reads it; persist saves it with the project)
|
||||
// and marks the project dirty — touches NOTHING in the bank/arrange.
|
||||
// Cycles None -> Auto -> Manual -> None; touches NOTHING in the bank/arrange.
|
||||
TailSetting& tail = g_panel.session->tail();
|
||||
tail.mode = cycleTailMode(tail.mode);
|
||||
markTailDirty();
|
||||
@@ -373,10 +318,9 @@ void handleClick(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prune button (R3): fires the "Prune bank folder" action THROUGH its registered
|
||||
// command id (fork R-E: dispatch the command, not the session directly) so the panel
|
||||
// affordance and the bindable action share the one guarded dry-run/confirm/delete path
|
||||
// in doBankPruneFolder. A 0 id (pre-registration) no-ops.
|
||||
// Fires through its registered command id (not the session directly) so the panel
|
||||
// affordance and the bindable action share the one guarded dry-run/confirm/delete
|
||||
// path in doBankPruneFolder.
|
||||
const ButtonRect pb = pruneButtonRectFor(w, h);
|
||||
if (hitTestPruneButton(x, y, pb)) {
|
||||
const int cmd = bankPruneCommandId();
|
||||
@@ -385,11 +329,8 @@ void handleClick(int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// BOTTOM toolbar (Design-View verbs): a button fires its registered action via the
|
||||
// command-id contract. Claimed whole like the top toolbar.
|
||||
if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return;
|
||||
|
||||
// Region chrome (headers, tab strip, buttons).
|
||||
if (poolShown()) {
|
||||
const RECT pr = poolRegionRect(w, h);
|
||||
if (y >= pr.top && y < regionGridRect(pr, false).top) {
|
||||
@@ -403,15 +344,13 @@ void handleClick(int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// Grid selection. Resolve which region's grid the point is in.
|
||||
Region reg = Region::Pool;
|
||||
if (!regionAt(x, y, reg)) return;
|
||||
const bool isBanks = reg == Region::Banks;
|
||||
const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
|
||||
// L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An
|
||||
// empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a
|
||||
// click on a gap clears selection, exactly like a click in the margin) — empty slots
|
||||
// are decorative, not selectable.
|
||||
// Hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An empty
|
||||
// (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a click on
|
||||
// a gap clears selection, exactly like a click in the margin).
|
||||
const RegionDisplay disp = regionDisplay(region, isBanks, reg);
|
||||
const int hitSlot = hitTestSlot(x, y, disp.slotRects);
|
||||
const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot);
|
||||
@@ -434,19 +373,12 @@ void handleClick(int x, int y) {
|
||||
}
|
||||
|
||||
// Drag-arm disambiguation for plain (no ctrl, no shift) presses on a grid cell:
|
||||
//
|
||||
// • Already-selected cell: defer the selection change to LBUTTONUP so a plain
|
||||
// press on a multi-selection doesn't collapse it before we know whether a drag
|
||||
// will happen. Arm the drag with the current (multi-)selection as the payload
|
||||
// candidate; only the caret moves immediately.
|
||||
//
|
||||
// • Unselected cell: apply the plain-click selection immediately (collapses to
|
||||
// the single pressed cell) THEN arm a drag from it — so the user can press-and-
|
||||
// drag in one gesture without a prior selecting click. The selection is set
|
||||
// before arming so that focusedSelectionIds() resolves the right payload when
|
||||
// the threshold is crossed in onMouseMove.
|
||||
//
|
||||
// ctrl / shift presses are selection-only gestures — no drag arm in either case.
|
||||
// already-selected cell defers the selection change to LBUTTONUP (so a plain press on
|
||||
// a multi-selection doesn't collapse it before we know whether a drag will happen; only
|
||||
// the caret moves immediately); unselected cell applies the plain-click selection now
|
||||
// (collapses to the single pressed cell) so a press-and-drag works without a prior
|
||||
// selecting click and focusedSelectionIds() resolves the right payload once the
|
||||
// threshold is crossed. ctrl/shift presses are selection-only — no drag arm.
|
||||
const bool onSelected = g_panel.selection.contains(hit);
|
||||
if (!ctrlDown() && !shiftDown()) {
|
||||
if (!onSelected) {
|
||||
@@ -461,12 +393,10 @@ void handleClick(int x, int y) {
|
||||
g_panel.dragStartX = x;
|
||||
g_panel.dragStartY = y;
|
||||
g_panel.dragSourceRegion = reg;
|
||||
// Capture the mouse NOW so WM_MOUSEMOVE is delivered even when the pointer leaves the
|
||||
// panel client rect before the drag threshold is crossed. Without capture, outside moves
|
||||
// are not delivered, so a fast straight-out drag never transitions dragArmed → dragging
|
||||
// and the OS drag-out never fires on the first pass. The capture is released on button-up
|
||||
// (no drag: onLBtnUp dragArmed branch; drag: OsDrag path or onLBtnUp dragging branch)
|
||||
// and on WM_CAPTURECHANGED (stolen or external release — already calls resetDragState).
|
||||
// Capture the mouse NOW so WM_MOUSEMOVE is still delivered once the pointer leaves the
|
||||
// client rect before the drag threshold is crossed — without capture, a fast
|
||||
// straight-out drag never transitions dragArmed -> dragging. Released on button-up or
|
||||
// WM_CAPTURECHANGED (which already calls resetDragState).
|
||||
SetCapture(g_panel.hwnd);
|
||||
invalidatePanel();
|
||||
return;
|
||||
@@ -477,13 +407,10 @@ void handleClick(int x, int y) {
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`.
|
||||
// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is
|
||||
// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens,
|
||||
// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns
|
||||
// false so the caller can let REAPER/the docker handle the wheel normally). On a real
|
||||
// change it mutates the SESSION's tail setting, marks the project dirty (so it saves),
|
||||
// and repaints the live length. Returns true iff the wheel was consumed.
|
||||
// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is over
|
||||
// the footer strip AND the mode is Manual — wheel up lengthens, down shortens, clamped to
|
||||
// [0, kMaxTailMs]. Otherwise does nothing (returns false so the caller can let REAPER/the
|
||||
// docker handle the wheel normally). Returns true iff the wheel was consumed.
|
||||
bool handleWheel(int x, int y, int delta) {
|
||||
if (!g_panel.session) return false;
|
||||
if (!pointInFooter(x, y)) return false;
|
||||
@@ -542,8 +469,7 @@ bool handleKey(int vk) {
|
||||
stopAudition();
|
||||
return true;
|
||||
case VK_DELETE: {
|
||||
// Remove the focused-region selection (B5). Silent; a no-op when nothing
|
||||
// is selected.
|
||||
// Remove the focused-region selection. Silent; a no-op when nothing is selected.
|
||||
const std::vector<std::string> sel = focusedSelectionIds();
|
||||
if (sel.empty()) return false; // nothing selected — let the key fall through
|
||||
removeSamples(sel, bankIdForRegion(g_panel.focusedRegion));
|
||||
@@ -580,35 +506,27 @@ void unregisterAccel() {
|
||||
|
||||
} // namespace reasampler::panel
|
||||
|
||||
// --- Public API (the timer + tail read seam — panel_input.h) -------------------
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
void bankPanelNotifyProjectLoaded() {
|
||||
// Persist restored a project's membership + active mode this tick (main.cpp calls
|
||||
// this from the same consumeLoadSignal() branch that reapplies the active mode).
|
||||
// Arm the new-content detector to re-baseline on its next tick so the just-loaded
|
||||
// project's pre-existing content is treated as the baseline (nothing new) rather
|
||||
// than diffed against the previous project and mass-tagged into the active mode.
|
||||
// A flag (not an inline reset) because detectNewContent owns the baseline and runs
|
||||
// later in the SAME OnTimer tick — it drains this and re-baselines against the live
|
||||
// set in one place, keeping the reset and the observe() adjacent and ordered.
|
||||
// Arms the new-content detector to re-baseline on its next tick so the just-loaded
|
||||
// project's pre-existing content is the baseline (nothing new) rather than diffed
|
||||
// against the previous project and mass-tagged. A flag, not an inline reset, because
|
||||
// detectNewContent owns the baseline and runs later in the SAME OnTimer tick.
|
||||
panel::g_panel.reloadPending = true;
|
||||
}
|
||||
|
||||
void bankPanelRefresh() {
|
||||
// New-content auto-tag detection runs EVERY tick regardless of panel open/close:
|
||||
// tracks/items are created in the arrange view, not the panel, so detection must
|
||||
// not be gated on the dock being visible. READ-ONLY on the project; only mutates
|
||||
// the in-memory membership index (persist saves it like any action-driven tag).
|
||||
// tracks/items are created in the arrange view, not the panel. READ-ONLY on the
|
||||
// project; only mutates the in-memory membership index.
|
||||
const bool tagged = panel::detectNewContent();
|
||||
|
||||
// Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a
|
||||
// track can only newly become multi-mode when auto-tag placed content on it. Unlike
|
||||
// the invisible membership tag above, minting is a visible structural mutation
|
||||
// Lane minting runs ONLY when detection just tagged new content — a track can only
|
||||
// newly become multi-mode when auto-tag placed content on it. Unlike the invisible
|
||||
// membership tag above, minting is a visible structural mutation
|
||||
// (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo
|
||||
// block and only mints for tracks that hold >1 mode's content — a single-mode track
|
||||
// is left to D1 whole-track parking. Managed lanes only; manual lanes untouched.
|
||||
// block and only mints for tracks that hold >1 mode's content. Managed lanes only.
|
||||
if (tagged && panel::g_panel.session) {
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
mintManagedLanes(panel::g_panel.session->view(), proj);
|
||||
@@ -616,8 +534,8 @@ void bankPanelRefresh() {
|
||||
|
||||
if (!panel::g_panel.open || !panel::g_panel.hwnd) return;
|
||||
|
||||
// L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a
|
||||
// toolbar button has rested under the pointer past the delay, latch + repaint the tooltip.
|
||||
// The custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if
|
||||
// a toolbar button has rested under the pointer past the delay, latch + repaint it.
|
||||
panel::maybeShowTooltip();
|
||||
|
||||
if (panel::refreshFingerprint())
|
||||
@@ -625,10 +543,9 @@ void bankPanelRefresh() {
|
||||
}
|
||||
|
||||
capture::TailSetting bankPanelTailSetting() {
|
||||
// The authoritative setting lives in the session (session->tail()) so it travels
|
||||
// inside the .rpp: it loads per project and saves with the project. This stays the
|
||||
// read seam for the capture actions. manualMs is clamped here so a caller always
|
||||
// receives a within-cap length regardless of what was stored/scrolled.
|
||||
// The authoritative setting lives in the session so it travels inside the .rpp; this
|
||||
// is the read seam for the capture actions. manualMs is clamped here so a caller
|
||||
// always receives a within-cap length regardless of what was stored/scrolled.
|
||||
capture::TailSetting s = panel::currentTail();
|
||||
s.manualMs = capture::clampManualMs(s.manualMs);
|
||||
return s;
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
#pragma once
|
||||
// panel_input — the input + detection seam of the bank panel (Q-W2 split of
|
||||
// bank_panel.h). The .cpp owns mouse-click / wheel / keyboard routing (plain
|
||||
// free-function calls per T4-28 — no interface on the per-event path) plus the
|
||||
// timer-driven detection passes: new-content auto-tag (D2 Wave 2) and the
|
||||
// hover-delay tooltip latch. This header carries the timer/lifecycle surface
|
||||
// main.cpp drives and the tail-setting read seam the capture actions consume.
|
||||
//
|
||||
// REAPER-free as practical: TailSetting is the pure capture-side type.
|
||||
// panel_input — input + detection seam of the bank panel: mouse/wheel/keyboard
|
||||
// routing plus timer-driven passes (new-content auto-tag, tooltip hover-delay
|
||||
// latch). REAPER-free as practical: TailSetting is the pure capture-side type.
|
||||
|
||||
#include "core/capture/tail_control.h" // capture::TailSetting — the panel's tail-mode toggle state
|
||||
#include "core/capture/tail_control.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Requests a repaint if the bank changed since the last paint (generation bump).
|
||||
// Cheap when nothing changed. Driven by the timer so a capture / project load is
|
||||
// reflected without the panel diffing the bank itself. Also hosts the every-tick
|
||||
// new-content auto-tag detection (runs whether or not the dock is visible) and the
|
||||
// tooltip hover-delay latch.
|
||||
// Repaints if the bank changed since last paint (generation bump); cheap no-op
|
||||
// otherwise. Also drives the auto-tag detector and tooltip hover latch each tick.
|
||||
void bankPanelRefresh();
|
||||
|
||||
// Notifies the panel that persist just (re)loaded a project's view model (membership +
|
||||
// active mode). main.cpp calls this on the exact tick it drains persist's load signal
|
||||
// and reapplies the active mode. It re-arms the new-content detector so the just-loaded
|
||||
// project's PRE-EXISTING content is taken as the baseline (reported as nothing new),
|
||||
// never diffed against the previously-open project and mass-tagged into the active mode.
|
||||
// This coordinates the detector's project-identity signal with persist's authoritative
|
||||
// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address,
|
||||
// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/
|
||||
// arm of panel state only; no project or bank mutation.
|
||||
// Call on the exact tick persist's project-load signal drains, before reapplying the
|
||||
// active mode. Re-arms the new-content detector so the just-loaded project's existing
|
||||
// content is the baseline, not diffed against the prior project and mass-tagged.
|
||||
void bankPanelNotifyProjectLoaded();
|
||||
|
||||
// The panel's current tail-mode setting (mode + Manual length), read by the plain
|
||||
// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture
|
||||
// applies whatever the panel toggle is set to. Default None (exact bounds) — a
|
||||
// capture with no explicit choice stays byte-identical to today. The authoritative
|
||||
// setting lives in ReaSamplerSession (it travels inside the .rpp); the panel mutates
|
||||
// it via the footer Tail button (cycle) and scroll-wheel (Manual fine-adjust), both
|
||||
// owned by this input seam. Safe to call before the panel has ever opened (returns
|
||||
// the default). READ of panel state only.
|
||||
// Read by CAPTURE_ITEM/CAPTURE_TRACK to apply the panel's tail toggle to a
|
||||
// CaptureRequest. Default None (exact bounds, byte-identical to no tail). Safe
|
||||
// before the panel has ever opened.
|
||||
capture::TailSetting bankPanelTailSetting();
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
// panel_layout.cpp — the geometry-glue seam of the docked bank panel (Q-W2 split of
|
||||
// bank_panel.cpp; the T4-01 NEW seam). Owns the toolbar/footer/menu rects, the
|
||||
// toolbar row/cluster builders, the vertical-split geometry + region rects, and the
|
||||
// L7 slot-order display bridge (regionDisplay/focusedDisplay). Every rect is derived
|
||||
// from the client size + fullHeight state, and BOTH paint (panel_render) and
|
||||
// hit-testing (panel_input / panel_drag) call these so they never drift.
|
||||
// panel_layout.cpp — geometry-glue seam of the docked bank panel: toolbar/footer/menu
|
||||
// rects, toolbar row/cluster builders, vertical-split geometry + region rects, and
|
||||
// the slot-order display bridge. Every rect is derived from client size + fullHeight
|
||||
// state, and both paint and hit-testing call these so they never drift. Also home of
|
||||
// the public split-state seam (panel_layout.h).
|
||||
//
|
||||
// Also home of the public split-state seam (panel_layout.h): the B3-owned
|
||||
// BankPanelFullHeight toggles the render derives the region rects from.
|
||||
//
|
||||
// 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). DAW-verified, not unit tested (the PURE tiling /
|
||||
// hit-test math lives in action_bar / footer_bar / prune_button / overflow_menu /
|
||||
// tab_strip / mode_switch / bank_grid / card_drag, unit-tested outside the DAW).
|
||||
// main.cpp owns the API pointers; here they are extern. DAW-verified, not unit tested
|
||||
// (the pure tiling/hit-test math lives in action_bar / footer_bar / etc.).
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -20,12 +13,11 @@
|
||||
#include "shell/panel/panel_state.h"
|
||||
#include "shell/panel/panel_layout.h"
|
||||
|
||||
#include "shell/persist/session.h" // ReaSamplerSession — mode/view reads
|
||||
#include "core/view/view_mode_model.h" // ViewModeModel — modes()/activeModeId()
|
||||
#include "shell/persist/session.h"
|
||||
#include "core/view/view_mode_model.h"
|
||||
|
||||
// Action-trigger buttons (M11): resolve each button's command id at runtime from the
|
||||
// composed named-command string and read its current key binding for the tooltip.
|
||||
// All main-section (SectionFromUniqueID(0)).
|
||||
// Resolves each button's command id at runtime from the composed named-command
|
||||
// string, and reads its current key binding for the tooltip. Main-section only.
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_NamedCommandLookup
|
||||
#define REAPERAPI_WANT_kbd_getTextFromCmd
|
||||
@@ -34,20 +26,14 @@
|
||||
|
||||
namespace reasampler::panel {
|
||||
|
||||
// --- Mode toggle (D5; relocated to the footer at L4) --------------------------
|
||||
|
||||
int modeCount() {
|
||||
if (!g_panel.session) return 0;
|
||||
return static_cast<int>(g_panel.session->view().modes().size());
|
||||
}
|
||||
|
||||
// --- Top toolbar band (L4; L5 overflow-menu reserve) --------------------------
|
||||
//
|
||||
// The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate
|
||||
// (height 0) when the client is too short to host it above the split body. The WHOLE band
|
||||
// (topToolbarRect) is what the far-right More button anchors into; the action_bar's frequent
|
||||
// buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run
|
||||
// under the menu button (L5 refinement 1).
|
||||
// topToolbarRect is what the far-right More button anchors into; the action_bar
|
||||
// buttons tile into the band minus the menu reserve (topToolbarActionRect) so
|
||||
// they never run under it.
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -60,25 +46,21 @@ ActionBarRect topToolbarRect(int w) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// The band the More button occupies (the whole top toolbar band as a MenuBarRect).
|
||||
MenuBarRect topMenuBarRect(int w) {
|
||||
const ActionBarRect bar = topToolbarRect(w);
|
||||
return MenuBarRect{bar.x, bar.y, bar.width, bar.height};
|
||||
}
|
||||
|
||||
// The More button's rect (right-anchored in the top band). Empty when the band is too narrow
|
||||
// to place it clear of its left inset — the three variants stay reachable via their bindable
|
||||
// commands (graceful suppression).
|
||||
} // namespace
|
||||
|
||||
// Empty when the band is too narrow to place the button clear of its left
|
||||
// inset — suppressed gracefully; still reachable via its bindable command.
|
||||
MenuButtonRect topMenuButtonRect(int w) {
|
||||
return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec);
|
||||
}
|
||||
|
||||
// The rect the TOP toolbar's action_bar tiles into: the whole band MINUS the reserve for the
|
||||
// far-right More button, so the frequent buttons never overlap it. When the More button is
|
||||
// suppressed (band too narrow) the reserve is still subtracted (the reserve is 0 only for a
|
||||
// degenerate band), which keeps draw and hit-test consistent whether or not the button shows.
|
||||
// Reserve is still subtracted even when the button is suppressed (0 only for a
|
||||
// degenerate band), keeping draw and hit-test consistent either way.
|
||||
ActionBarRect topToolbarActionRect(int w) {
|
||||
ActionBarRect bar = topToolbarRect(w);
|
||||
const int reserve = menuButtonReserve(topMenuBarRect(w), kMenuBtnSpec);
|
||||
@@ -87,8 +69,6 @@ ActionBarRect topToolbarActionRect(int w) {
|
||||
return bar;
|
||||
}
|
||||
|
||||
// --- Footer (L4) --------------------------------------------------------------
|
||||
|
||||
RECT panelFooter(int w, int h) {
|
||||
RECT rc{};
|
||||
rc.left = 0;
|
||||
@@ -100,8 +80,8 @@ RECT panelFooter(int w, int h) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
// The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client
|
||||
// size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate.
|
||||
// Left-group layout (mode toggle + count + Tail button). Single source of truth
|
||||
// for draw and hit-test.
|
||||
FooterBarLayout footerBarLayoutFor(int w, int h) {
|
||||
const RECT f = panelFooter(w, h);
|
||||
if (f.top >= f.bottom) return FooterBarLayout{};
|
||||
@@ -109,22 +89,18 @@ FooterBarLayout footerBarLayoutFor(int w, int h) {
|
||||
return computeFooterBar(footer, FooterBarSpec{});
|
||||
}
|
||||
|
||||
// The prune button's rect within the footer, derived from the client size. SINGLE source
|
||||
// of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate
|
||||
// or too narrow to place the button clear of the footer-left group / version readout — the
|
||||
// action stays reachable via its bindable command, so a suppressed button is graceful. Kept
|
||||
// set apart at the RIGHT (footer_bar reserves the matching space at its right so the two
|
||||
// groups never overlap). See prune_button.h §Placement contract.
|
||||
// Empty when the footer is degenerate or too narrow to clear the left group /
|
||||
// version readout — reachable via its bindable command regardless. Set apart at
|
||||
// the right (footer_bar reserves matching space so the groups never overlap).
|
||||
ButtonRect pruneButtonRectFor(int w, int h) {
|
||||
const RECT f = panelFooter(w, h);
|
||||
if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button
|
||||
if (f.top >= f.bottom) return ButtonRect{};
|
||||
const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top};
|
||||
return computePruneButton(footer, PruneButtonSpec{});
|
||||
}
|
||||
|
||||
// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the
|
||||
// scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The
|
||||
// Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar).
|
||||
// Used by the scroll-wheel (Manual tail fine-adjust); the Tail-cycle click hits
|
||||
// the Tail button rect (footer_bar) instead.
|
||||
bool pointInFooter(int x, int y) {
|
||||
if (!g_panel.hwnd) return false;
|
||||
RECT cr{};
|
||||
@@ -133,37 +109,11 @@ bool pointInFooter(int x, int y) {
|
||||
return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom;
|
||||
}
|
||||
|
||||
// === Task-grouped toolbars (Phase L, L2 + L4) =================================
|
||||
//
|
||||
// L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin)
|
||||
// across TWO toolbars, BOTH drawn through the pure action_bar module:
|
||||
// * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the
|
||||
// two acts the tool exists for (L4 §1);
|
||||
// * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the
|
||||
// footer, in the space capture/placement vacated (L4 §2).
|
||||
// Each button is drawn with its action name (Font::Label) and live key binding on a Micro
|
||||
// sub-row (the L2 contract). action_bar owns the cluster tiling, the label/binding sub-rects,
|
||||
// the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK binding query
|
||||
// + the NamedCommandLookup/Main_OnCommand dispatch live here.
|
||||
//
|
||||
// Each button resolves its command id at RUNTIME from the composed named-command string
|
||||
// (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable
|
||||
// and beta and adds NO second registration. A cmd of 0 (action not registered on this channel)
|
||||
// draws Disabled and no-ops on click. L4 is layout-only: the SAME existing actions fire via the
|
||||
// SAME contract — no re-wiring, no command-id changes, and capture never auto-inserts.
|
||||
|
||||
// The TOP toolbar inventory (L6 refinement): the FREQUENT acts only — Capture (item / track)
|
||||
// then Re-capture (Maintenance, set between the two capture verbs and the placement verbs) then
|
||||
// Placement (insert / insert-conform). The FOUR RARE variants (Batch Items / Batch Razor /
|
||||
// Capture RT / Cancel RT) are ALL in the far-right "⋯" overflow menu (overflowMenuRows) —
|
||||
// same registered actions, same command-id contract, just a different home. Capture scopes come
|
||||
// from captureActionTable() (render_settings, pure); the rest are the registered M11/M10/M8
|
||||
// commands. Built once per draw/click. Each row carries its full (prefix-stripped) action name
|
||||
// for the hover tooltip.
|
||||
// Frequent acts only: Capture (item/track), Re-capture (between capture and
|
||||
// placement), Placement (insert/insert-conform). The four rare variants live in
|
||||
// the overflow menu (overflowMenuRows) — same actions, different home.
|
||||
std::vector<ActionBarRow> topBarRows() {
|
||||
std::vector<ActionBarRow> rows;
|
||||
// Capture cluster — the primary gesture, leftmost. Face is a terse "Capture Item/Track";
|
||||
// the tooltip carries the full descriptionPhrase the action was registered with.
|
||||
for (const CaptureActionDef& def : captureActionTable()) {
|
||||
std::string label = def.commandSuffix;
|
||||
if (label == "CAPTURE_ITEM") label = "Capture Item";
|
||||
@@ -171,12 +121,8 @@ std::vector<ActionBarRow> topBarRows() {
|
||||
rows.push_back({def.commandSuffix, label, def.descriptionPhrase,
|
||||
ActionCluster::Capture, true});
|
||||
}
|
||||
// Maintenance cluster — Re-capture from source (M10), placed BETWEEN the capture group and
|
||||
// the placement group so its position reads "refine the last capture before placing it".
|
||||
// Cancel RT lives in the overflow menu (both realtime verbs share that home — L6).
|
||||
rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture",
|
||||
"re-capture from source", ActionCluster::Maintenance, true});
|
||||
// Placement cluster — the second act (still a distinct on-demand act; no auto-insert).
|
||||
rows.push_back({"INSERT_SELECTED", "Insert",
|
||||
"insert selected sample at edit cursor", ActionCluster::Placement, true});
|
||||
rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform",
|
||||
@@ -185,12 +131,8 @@ std::vector<ActionBarRow> topBarRows() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
// The TOP-toolbar OVERFLOW menu inventory (L6): four items pulled off the visible bar into the
|
||||
// far-right "⋯" menu button's popup — the three rare batch/realtime capture variants plus
|
||||
// Cancel RT (both realtime verbs share the menu home). Each fires the SAME existing registered
|
||||
// command id via the SAME NamedCommandLookup/Main_OnCommand contract — no action changes. The
|
||||
// fullName is the popup entry text (the terse shortLabel is unused for menu items; the popup has
|
||||
// room for the full name). Batch entries first, then the two realtime verbs.
|
||||
// The four rare batch/realtime capture variants pulled off the visible bar, plus
|
||||
// Cancel RT. fullName is the popup entry text (shortLabel is unused for menu items).
|
||||
std::vector<ActionBarRow> overflowMenuRows() {
|
||||
return {
|
||||
{"CAPTURE_BATCH_ITEMS", "Batch Items",
|
||||
@@ -206,8 +148,6 @@ std::vector<ActionBarRow> overflowMenuRows() {
|
||||
|
||||
namespace {
|
||||
|
||||
// The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for
|
||||
// "which mode is active"). Empty when no session (every button then falls to fail-open live).
|
||||
std::string activeModeIdOrEmpty() {
|
||||
if (!g_panel.session) return {};
|
||||
return g_panel.session->view().activeModeId();
|
||||
@@ -215,26 +155,16 @@ std::string activeModeIdOrEmpty() {
|
||||
|
||||
} // namespace
|
||||
|
||||
// The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons
|
||||
// then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from
|
||||
// design_view_actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves;
|
||||
// VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not
|
||||
// paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag).
|
||||
// The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the
|
||||
// footer [Arrange|Design] toggle owns mode switching.
|
||||
//
|
||||
// OPPOSITE-MODE ENABLEMENT (L5): a tag button is LIVE only for the OPPOSITE of the active mode
|
||||
// (you tag into the mode you are not in). The pure mode_enable::tagButtonEnabled decides it from
|
||||
// the active mode id; Show Both is unconditional (not a tag target). enabled=false rows draw
|
||||
// Disabled and no-op on click. The Item/Track axis is display-only here — both the Item and the
|
||||
// Track button for a target share the target's enablement.
|
||||
// Four Item/Track x Arrange/Design tag buttons then a set-apart Show Both. A tag
|
||||
// button is live only for the OPPOSITE of the active mode (tag into the mode
|
||||
// you're not in) — decided by the pure mode_enable::tagButtonEnabled; disabled
|
||||
// rows draw Disabled and no-op on click. Show Both is unconditional.
|
||||
std::vector<ActionBarRow> bottomBarRows() {
|
||||
const std::string active = activeModeIdOrEmpty();
|
||||
const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange);
|
||||
const bool designLive = tagButtonEnabled(active, TagTarget::Design);
|
||||
|
||||
std::vector<ActionBarRow> rows;
|
||||
// Tagging cluster — the four Item/Track x Arrange/Design tag buttons.
|
||||
rows.push_back({"VIEW_MOVE_ITEMS_ARRANGE", "Item: Arrange",
|
||||
"move selected items -> Arrange", ActionCluster::Tagging, arrangeLive});
|
||||
rows.push_back({"VIEW_MOVE_ITEMS_DESIGN", "Item: Design",
|
||||
@@ -243,18 +173,14 @@ std::vector<ActionBarRow> bottomBarRows() {
|
||||
"tag selected tracks -> Arrange", ActionCluster::Tagging, arrangeLive});
|
||||
rows.push_back({"VIEW_TAG_DESIGN", "Track: Design",
|
||||
"tag selected tracks -> Design", ActionCluster::Tagging, designLive});
|
||||
// Switching cluster — Show Both, set apart (the only survivor of the old switching group).
|
||||
rows.push_back({"VIEW_SHOW_BOTH", "Show Both",
|
||||
"show both for selected tracks", ActionCluster::Switching, true});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// The cluster button-count specs for a given row set, in the row list's cluster order (so the
|
||||
// pure action_bar's flat index lines up with the row list). Handles all five cluster kinds;
|
||||
// empty clusters contribute a 0-count spec (action_bar skips them, emitting no gap). The spec
|
||||
// order follows each toolbar's fixed layout order (top: Capture, Maintenance, Placement —
|
||||
// Re-capture sits between the two capture verbs and the placement verbs; bottom: Tagging,
|
||||
// Switching). The bottom bar's Maintenance count is 0, so the order change is transparent there.
|
||||
// Cluster button-count specs in the row list's cluster order, so action_bar's
|
||||
// flat index lines up with the row list. Empty clusters contribute a 0-count
|
||||
// spec (action_bar skips them, no gap).
|
||||
std::vector<ClusterSpec> actionBarClusters(const std::vector<ActionBarRow>& rows) {
|
||||
int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0;
|
||||
for (const ActionBarRow& r : rows) {
|
||||
@@ -275,8 +201,8 @@ std::vector<ClusterSpec> actionBarClusters(const std::vector<ActionBarRow>& rows
|
||||
};
|
||||
}
|
||||
|
||||
// The BOTTOM toolbar band: a fixed-height band directly above the footer (below the split
|
||||
// body). Degenerate (height 0) when the client is too short to host it above the footer.
|
||||
// Fixed-height band directly above the footer; degenerate (height 0) when too
|
||||
// short to host it above the footer.
|
||||
ActionBarRect bottomToolbarRect(int w, int h) {
|
||||
ActionBarRect s;
|
||||
const RECT footer = panelFooter(w, h);
|
||||
@@ -285,12 +211,10 @@ ActionBarRect bottomToolbarRect(int w, int h) {
|
||||
s.width = w;
|
||||
s.height = kBottomToolbarHeight;
|
||||
s.y = footerTop - kBottomToolbarHeight;
|
||||
// Keep the bar below the top toolbar; if the client is too short, collapse it.
|
||||
if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; }
|
||||
return s;
|
||||
}
|
||||
|
||||
// Resolves a row's composed named command to its runtime command id (0 if not registered).
|
||||
// The named-command lookup string is "_" + the channel-qualified id (REAPER's convention).
|
||||
int resolveBarCommandId(const ActionBarRow& row) {
|
||||
if (!NamedCommandLookup) return 0;
|
||||
@@ -300,8 +224,7 @@ int resolveBarCommandId(const ActionBarRow& row) {
|
||||
|
||||
namespace {
|
||||
|
||||
// The current key binding string for a command in the MAIN section, or "" (unbound / not
|
||||
// registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)).
|
||||
// "" when unbound or not registered.
|
||||
std::string barBindingText(int cmd) {
|
||||
if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) {
|
||||
const char* t = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0));
|
||||
@@ -318,16 +241,9 @@ int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector<ActionB
|
||||
return hitTestActionBar(x, y, bar, actionBarClusters(rows), kBarSpec);
|
||||
}
|
||||
|
||||
// --- Tooltip (L5 refinement 2) ------------------------------------------------
|
||||
//
|
||||
// A custom hover-delay tooltip: the full, prefix-stripped action name of the hovered toolbar
|
||||
// button. Resolves the hovered element to its (anchor rect, text); returns false when the current
|
||||
// hover has no tooltip (grid / chrome / the More button — the More button's own popup is its
|
||||
// affordance). The tooltip DRAW lives in panel_render (drawTooltip); timing
|
||||
// (kTooltipDelayMs) is applied by the caller.
|
||||
|
||||
// The full (prefix-stripped) tooltip text for the currently hovered toolbar button, plus its
|
||||
// anchor rect. Returns false when the hover is not a tooltip-bearing toolbar button.
|
||||
// Resolves the hovered element to (anchor rect, text); false when the hover has
|
||||
// no tooltip (grid / chrome / More button — its own popup is its affordance).
|
||||
// Draw lives in panel_render; timing is applied by the caller.
|
||||
bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah) {
|
||||
const Hover& hv = g_panel.hovered;
|
||||
std::vector<ActionBarRow> rows;
|
||||
@@ -343,8 +259,8 @@ bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& a
|
||||
}
|
||||
if (hv.index < 0 || hv.index >= static_cast<int>(rows.size())) return false;
|
||||
|
||||
// The hovered button's slot rect (the anchor). computeBarSlots is the same layout the draw +
|
||||
// hit-test use, so the anchor matches the drawn button exactly.
|
||||
// computeBarSlots is the same layout draw + hit-test use, so the anchor
|
||||
// matches the drawn button exactly.
|
||||
const std::vector<ClusterSpec> clusters = actionBarClusters(rows);
|
||||
const std::vector<ActionBarSlot> slots = computeBarSlots(bar, clusters, kBarSpec);
|
||||
const ActionBarSlot* slot = nullptr;
|
||||
@@ -352,11 +268,9 @@ bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& a
|
||||
if (s.index == hv.index) { slot = &s; break; }
|
||||
if (!slot) return false;
|
||||
|
||||
// The full name is stored already prefix-free, but strip defensively in case a source ever
|
||||
// carries the "ReaSampler:" display prefix (the tooltip must never show it — L5 refinement 2).
|
||||
// L6: the keybinding sub-row was removed from the button face, so the tooltip now carries
|
||||
// both the name AND the binding (when bound) — e.g. "capture selected item — F5". When the
|
||||
// action is unbound the tooltip shows only the name (no "(unbound)" noise in the tooltip).
|
||||
// fullName is stored prefix-free; strip defensively in case a source ever
|
||||
// carries the display prefix. Tooltip carries name + binding when bound
|
||||
// (e.g. "capture selected item — F5"), name only when unbound.
|
||||
const std::string phrase = stripActionPrefix(rows[static_cast<std::size_t>(hv.index)].fullName,
|
||||
actionDisplayPrefix());
|
||||
const int cmd = resolveBarCommandId(rows[static_cast<std::size_t>(hv.index)]);
|
||||
@@ -366,14 +280,9 @@ bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& a
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Split geometry -----------------------------------------------------------
|
||||
//
|
||||
// Every rect below is derived from the client size + fullHeight state, and BOTH paint
|
||||
// and hit-testing call these so they never drift. All are top-left origin.
|
||||
|
||||
// The body band between the TOP toolbar and the BOTTOM toolbar (L4). Its top edge is below the
|
||||
// top toolbar; its bottom edge is the bottom toolbar's top. When the bottom bar collapses on a
|
||||
// short client, bottomToolbarRect returns its y at the footer top, so the body still ends there.
|
||||
// Between the top and bottom toolbars. When the bottom bar collapses on a short
|
||||
// client, bottomToolbarRect returns its y at the footer top, so the body still
|
||||
// ends there.
|
||||
RECT splitBody(int w, int h) {
|
||||
RECT rc{};
|
||||
rc.left = 0;
|
||||
@@ -385,24 +294,20 @@ RECT splitBody(int w, int h) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
// True when both regions are shown (the split is live). Otherwise one region fills
|
||||
// the body.
|
||||
bool poolShown() { return g_panel.fullHeight != BankPanelFullHeight::BanksOnly; }
|
||||
bool banksShown() { return g_panel.fullHeight != BankPanelFullHeight::PoolOnly; }
|
||||
|
||||
// The pool region's rect (whole-region: header band + grid). Empty when hidden.
|
||||
// Empty when hidden.
|
||||
RECT poolRegionRect(int w, int h) {
|
||||
const RECT body = splitBody(w, h);
|
||||
if (!poolShown()) return RECT{0, 0, 0, 0};
|
||||
if (!banksShown()) return body; // pool full-height: the whole body
|
||||
// Split: pool gets the top half (minus the divider).
|
||||
if (!banksShown()) return body;
|
||||
RECT rc = body;
|
||||
rc.bottom = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2;
|
||||
if (rc.bottom < rc.top) rc.bottom = rc.top;
|
||||
return rc;
|
||||
}
|
||||
|
||||
// The named-banks region's rect (whole-region: header band + tab strip + grid).
|
||||
RECT banksRegionRect(int w, int h) {
|
||||
const RECT body = splitBody(w, h);
|
||||
if (!banksShown()) return RECT{0, 0, 0, 0};
|
||||
@@ -414,7 +319,6 @@ RECT banksRegionRect(int w, int h) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
// A region's header band (the top kRegionHeaderHeight of the region).
|
||||
RECT regionHeaderRect(const RECT& region) {
|
||||
RECT rc = region;
|
||||
rc.bottom = region.top + kRegionHeaderHeight;
|
||||
@@ -422,7 +326,6 @@ RECT regionHeaderRect(const RECT& region) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
// The named-banks region's tab strip (below its header band).
|
||||
TabStripRect banksTabStripRect(const RECT& region) {
|
||||
const RECT hdr = regionHeaderRect(region);
|
||||
TabStripRect s;
|
||||
@@ -445,7 +348,6 @@ RECT regionGridRect(const RECT& region, bool isBanks) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
// The full-height toggle button rect inside a region header (right-aligned).
|
||||
RECT fullHtBtnRect(const RECT& region) {
|
||||
const RECT hdr = regionHeaderRect(region);
|
||||
RECT rc = hdr;
|
||||
@@ -456,8 +358,6 @@ RECT fullHtBtnRect(const RECT& region) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
// The "+" create-bank button rect inside the named-banks region header (left of the
|
||||
// full-height button).
|
||||
RECT createBtnRect(const RECT& region) {
|
||||
RECT ft = fullHtBtnRect(region);
|
||||
RECT rc = ft;
|
||||
@@ -466,10 +366,8 @@ RECT createBtnRect(const RECT& region) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
// Resolves a region's display for the currently-shown bank. Empty (no bank / no width)
|
||||
// yields an empty display. orderedSampleIds reconciles the bank's SlotMap against live
|
||||
// membership, so a freshly-migrated or out-of-band-mutated bank always yields a complete
|
||||
// order (trailing empties are trimmed by the model — maxSlot walks only live occupants).
|
||||
// orderedSampleIds reconciles the bank's SlotMap against live membership, so a
|
||||
// freshly-migrated or out-of-band-mutated bank always yields a complete order.
|
||||
RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) {
|
||||
RegionDisplay d;
|
||||
BankBook* b = book();
|
||||
@@ -490,8 +388,6 @@ RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) {
|
||||
return d;
|
||||
}
|
||||
|
||||
// The FOCUSED region's display (the slot-order bridge for the region holding the live
|
||||
// selection). Mirrors columnsForRegion's client read.
|
||||
RegionDisplay focusedDisplay() {
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
@@ -501,8 +397,6 @@ RegionDisplay focusedDisplay() {
|
||||
return regionDisplay(region, isBanks, g_panel.focusedRegion);
|
||||
}
|
||||
|
||||
// Which region (if any) contains client point (x, y); returns false via `out` set to
|
||||
// Pool by default when the point is in neither region body.
|
||||
bool regionAt(int x, int y, Region& out) {
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
@@ -522,8 +416,6 @@ bool regionAt(int x, int y, Region& out) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by
|
||||
// mode_switch inside footer_bar's toggle box, so both draw and hit-test use the same box.
|
||||
int footerToggleSegmentHit(int x, int y, int w, int h) {
|
||||
if (!g_panel.session) return -1;
|
||||
const FooterBarLayout fb = footerBarLayoutFor(w, h);
|
||||
@@ -532,7 +424,6 @@ int footerToggleSegmentHit(int x, int y, int w, int h) {
|
||||
return hitTestSegment(x, y, th, modeCount());
|
||||
}
|
||||
|
||||
// The column count for a region's current grid width (nav needs the layout's wrap).
|
||||
int columnsForRegion(Region reg) {
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
@@ -545,8 +436,6 @@ int columnsForRegion(Region reg) {
|
||||
|
||||
} // namespace reasampler::panel
|
||||
|
||||
// --- Public API (the split-state seam — panel_layout.h) ------------------------
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
BankPanelFullHeight bankPanelFullHeight() {
|
||||
|
||||
@@ -1,43 +1,25 @@
|
||||
#pragma once
|
||||
// panel_layout — the vertical-split layout STATE seam of the bank panel (Q-W2 split of
|
||||
// bank_panel.h; Phase B3/B4). The panel window splits vertically — pool on top,
|
||||
// named-banks region below — and two toggles collapse the split. This header carries
|
||||
// that public state surface; the geometry derivation itself (toolbar/footer/menu rects,
|
||||
// row/cluster builders, region rects, the L7 slot-order display bridge) is internal to
|
||||
// panel_layout.cpp (see panel_state.h for the intra-panel seam).
|
||||
//
|
||||
// REAPER-free: main.cpp / bank_actions.cpp drive these through plain free functions.
|
||||
// panel_layout — vertical-split layout state seam of the bank panel (pool on top,
|
||||
// named-banks region below, two toggles collapse the split). Geometry derivation
|
||||
// itself is internal to panel_layout.cpp. REAPER-free.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The vertical-split full-height layout state (Phase B). The bank window splits
|
||||
// vertically — pool on top, named-banks region below — and two toggles collapse the
|
||||
// split: pool full-height (hide the named-banks region) and banks full-height (hide
|
||||
// the pool). The two are mutually exclusive with the default (both regions shown),
|
||||
// so one enum captures the whole state.
|
||||
//
|
||||
// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives
|
||||
// beside the tail setting — the other session-level view-layout bit the panel
|
||||
// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not
|
||||
// project state, so it must not travel with the .rpp. In-memory for the extension's
|
||||
// lifetime; resets to Split on unload.
|
||||
// UI-layout preference, not project state — deliberately not persisted in
|
||||
// ReaSamplerSession (must not travel with the .rpp). Resets to Split on unload.
|
||||
enum class BankPanelFullHeight {
|
||||
Split, // default: pool region on top, named-banks region below
|
||||
PoolOnly, // pool full-height — named-banks region hidden
|
||||
BanksOnly, // banks full-height — pool region hidden
|
||||
Split,
|
||||
PoolOnly,
|
||||
BanksOnly,
|
||||
};
|
||||
|
||||
// The current full-height layout state (default Split). READ by B4's panel to decide
|
||||
// which region(s) to draw. Safe before the panel has ever opened.
|
||||
// Safe before the panel has ever opened.
|
||||
BankPanelFullHeight bankPanelFullHeight();
|
||||
|
||||
// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from
|
||||
// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool
|
||||
// full-height" action. Requests a repaint so an open panel reflects the change.
|
||||
// Split <-> PoolOnly; from BanksOnly also enters PoolOnly. Requests a repaint.
|
||||
void bankPanelToggledPoolFullHeight();
|
||||
|
||||
// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle.
|
||||
// Bound to the "banks full-height" action. Requests a repaint.
|
||||
// Split <-> BanksOnly, symmetric to the pool toggle.
|
||||
void bankPanelToggledBanksFullHeight();
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,49 +1,35 @@
|
||||
// panel_render.cpp — the LICE draw seam of the docked bank panel (Q-W2 split of
|
||||
// bank_panel.cpp; M5 Wave A/B + Phase B4 + Phase L). Owns WM_PAINT's full paint:
|
||||
// the VERTICAL SPLIT (pool grid region on top, named-banks tab-page region below),
|
||||
// the region headers + tab strip, the two task-grouped toolbars + More button, the
|
||||
// footer (mode toggle + count + Tail + Prune), the hover-delay tooltip overlay, and
|
||||
// the per-card thumbnail/metadata draw — everything through the L1 kit by palette
|
||||
// role (draw_kit), double-buffered, BitBlt'd once.
|
||||
//
|
||||
// READ-ONLY: reads panel + session state; the input/drag seams mutate it. All rect
|
||||
// derivation comes from panel_layout (the single source both draw and hit-test use).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called
|
||||
// here (LICE/Win32 only); REAPER SDK types arrive via panel_state.h.
|
||||
// panel_render.cpp — LICE draw seam of the docked bank panel. Owns WM_PAINT's full
|
||||
// paint: the vertical split, region headers + tab strip, the two toolbars + More
|
||||
// button, the footer, the tooltip overlay, and per-card thumbnail/metadata draw —
|
||||
// everything through the kit by palette role, double-buffered, BitBlt'd once.
|
||||
// Read-only: reads panel + session state; input/drag seams mutate it. All rect
|
||||
// derivation comes from panel_layout (the single source both draw and hit-test
|
||||
// use). No REAPER API functions are called here.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "shell/panel/panel_state.h"
|
||||
|
||||
#include "shell/panel/draw_kit.h" // kit text()/fillSurface/drawButton/drawWaveform (L1)
|
||||
#include "shell/persist/session.h" // ReaSamplerSession — mode/view/tail reads
|
||||
#include "core/view/view_mode_model.h" // ViewModeModel / Mode — the footer toggle's model
|
||||
#include "shell/panel/draw_kit.h"
|
||||
#include "shell/persist/session.h"
|
||||
#include "core/view/view_mode_model.h"
|
||||
|
||||
namespace reasampler::panel {
|
||||
|
||||
namespace {
|
||||
|
||||
// --- Drawing: thumbnails (via the kit's shared drawWaveform since FA3) ---------
|
||||
|
||||
// Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT
|
||||
// (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT
|
||||
// (wall-clock). Decorative + non-interactive (no hit-test, no hover). Drawn in the kit's
|
||||
// Micro / ValueMono classes in text/dim, subordinate to the waveform. A blank musical
|
||||
// read-out (unstamped meter / unknown tempo) simply omits the bottom-left string.
|
||||
// Bars.beats.subdivisions bottom-left (musical), seconds.milliseconds bottom-right.
|
||||
// Decorative, non-interactive; a blank musical readout omits the left string.
|
||||
void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) {
|
||||
MusicalLength ml;
|
||||
ml.lengthSeconds = s.lengthSeconds;
|
||||
ml.tempoBpm = s.captureTempo;
|
||||
ml.timeSigNum = s.captureTimeSigNum;
|
||||
ml.timeSigDenom = s.captureTimeSigDenom;
|
||||
const std::string bars = formatBarsBeats(ml); // "" when unstamped/no-tempo
|
||||
const std::string bars = formatBarsBeats(ml);
|
||||
const std::string secs = formatSecondsMs(s.lengthSeconds);
|
||||
|
||||
// A short strip along the card's bottom edge. Left/right halves; text/dim so the
|
||||
// waveform stays the centerpiece. Micro on the left (musical), ValueMono on the right
|
||||
// (tabular numbers that must not jitter).
|
||||
const int stripH = 12;
|
||||
const int pad = 3;
|
||||
const int y = rect.y + rect.height - stripH;
|
||||
@@ -57,17 +43,13 @@ void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) {
|
||||
|
||||
void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
||||
bool selected, bool focused, bool hovered, const Sample* sample) {
|
||||
// Cell surface through the kit (L7 selection restyle): a selected card draws the NORMAL
|
||||
// cell surface (Rest, or Hover when hovered) — NOT the inverted accent-fill. Selection is
|
||||
// marked purely by an accent/tertiary (pastel purple) border below; hover stays a fill-
|
||||
// state change orthogonal to that border, so a hovered selected card still reads selected.
|
||||
// Selected cards draw the normal cell surface, not an inverted fill — selection
|
||||
// is marked purely by the border below, kept orthogonal to hover state.
|
||||
const KitBox cell{rect.x, rect.y, rect.width, rect.height};
|
||||
const InteractionState state = hovered ? InteractionState::Hover : InteractionState::Rest;
|
||||
fillSurface(bmp, cell, Role::BgCell, state);
|
||||
|
||||
// Border (L7): accent/TERTIARY purple when selected (the sole selection signal), else
|
||||
// hairline. Focus is a distinct text/primary inner ring so a focused-AND-selected card
|
||||
// reads BOTH — the purple outer border + the inner focus ring — kept visually separate.
|
||||
// Focus is a distinct inner ring so a focused-AND-selected card reads both.
|
||||
const KitColor border = selected ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0);
|
||||
if (focused) {
|
||||
@@ -75,44 +57,22 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
||||
LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0);
|
||||
}
|
||||
|
||||
// Waveform plot through the kit's shared primitive (FA3): the SAME per-pixel-column
|
||||
// min/max envelope draw the VST editor hero + browser cards use — one algorithm, one
|
||||
// look, everywhere. The oversampled env (see drawRegionGrid's binWidth) collapses per
|
||||
// column via peaks::columnMinMax inside the kit; an empty env draws just the midline.
|
||||
drawWaveform(bmp, cell, env);
|
||||
|
||||
// L7 decorative metadata overlay, drawn last so it sits over the waveform.
|
||||
if (sample) drawCardMeta(bmp, rect, *sample);
|
||||
}
|
||||
|
||||
// --- Kit draw adapters (Phase L) ----------------------------------------------
|
||||
//
|
||||
// All panel text draws through the kit's cached AA font (draw_kit::text), NOT raw GDI DrawText
|
||||
// (retired at L1). L2 re-roles every color through the pure `theme` module and draws surfaces
|
||||
// via the kit (fillSurface / drawButton). These thin adapters bridge the panel's RECT-based
|
||||
// geometry helpers to the kit's KitBox and give the panel a KitColor->LICE_pixel boundary for
|
||||
// the few raw borders it still draws over kit surfaces. The kit owns the font lifecycle
|
||||
// (kitFontsInit/Shutdown, wired at panel open/close below).
|
||||
|
||||
KitBox toKitBox(const RECT& r) {
|
||||
return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top};
|
||||
}
|
||||
|
||||
// KitColor -> LICE_pixel: all sites use the kit's toLice() from draw_kit.h — the single
|
||||
// conversion boundary the kit enforces. No local alias needed.
|
||||
|
||||
// L2 role/font-aware text: draws through the kit in a palette ROLE color and a chosen kit
|
||||
// Font (the action bar uses Micro for the keybinding sub-label, Label for the name, Title for
|
||||
// region headings). Takes a KitBox directly (the pure geometry the L2 modules return).
|
||||
void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt,
|
||||
Font font, Role role, Align align) {
|
||||
text(bmp, box, txt, font, role, align);
|
||||
}
|
||||
|
||||
// The per-mode membership count that travels with the toggle (L4 §3): the number of leaves
|
||||
// tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no
|
||||
// session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.)
|
||||
// A display-only tally over the model's public membership map — no model semantics duplicated.
|
||||
// Number of leaves tagged into the currently active mode; 0 when no session. The
|
||||
// Arrange default (untagged) is not counted — membership tracks tagged leaves only.
|
||||
int activeModeMemberCount() {
|
||||
if (!g_panel.session) return 0;
|
||||
const ViewModeModel& view = g_panel.session->view();
|
||||
@@ -124,10 +84,9 @@ int activeModeMemberCount() {
|
||||
return n;
|
||||
}
|
||||
|
||||
// Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design]
|
||||
// toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and
|
||||
// the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button
|
||||
// set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it.
|
||||
// Draws the footer: band + top divider, LEFT group ([Arrange|Design] toggle, per-mode
|
||||
// count, Tail BUTTON), the version readout, and the Prune button at the far right.
|
||||
// READ-ONLY: reads session state; input handlers mutate it.
|
||||
void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
const RECT f = panelFooter(w, h);
|
||||
if (f.top >= f.bottom) return;
|
||||
@@ -140,8 +99,7 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
|
||||
const FooterBarLayout fb = footerBarLayoutFor(w, h);
|
||||
|
||||
// [Arrange|Design] toggle — drawn as N mode_switch segments inside footer_bar's toggle box
|
||||
// (the segment geometry stays owned by the pure mode_switch; footer_bar owns the box). The
|
||||
// [Arrange|Design] toggle — N mode_switch segments inside footer_bar's toggle box. The
|
||||
// active mode's segment carries the accent; others hover-or-rest bg/cell.
|
||||
if (!fb.toggle.empty() && g_panel.session) {
|
||||
const ViewModeModel& view = g_panel.session->view();
|
||||
@@ -166,8 +124,7 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-mode member count, a compact dim readout beside the toggle (L4 §3 — "the count
|
||||
// travels with the toggle"). Passive text, not a control.
|
||||
// Per-mode member count, a compact dim readout beside the toggle. Passive text, not a control.
|
||||
if (!fb.count.empty()) {
|
||||
const int members = activeModeMemberCount();
|
||||
const std::string countLabel =
|
||||
@@ -176,8 +133,8 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center);
|
||||
}
|
||||
|
||||
// Tail BUTTON (L4 §4) — a real kit button with rest/hover states; its click cycles the
|
||||
// tail mode exactly as the old click-zone did. Label is the pure tailToggleLabel.
|
||||
// Tail BUTTON — a real kit button with rest/hover states; its click cycles the tail
|
||||
// mode. Label is the pure tailToggleLabel.
|
||||
if (!fb.tail.empty()) {
|
||||
const InteractionState state = hoverState(g_panel.hovered, HoverKind::TailButton, -1);
|
||||
const std::string label = tailToggleLabel(currentTail());
|
||||
@@ -185,17 +142,13 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
drawButton(bmp, box, label.c_str(), state, /*warn=*/false);
|
||||
}
|
||||
|
||||
// Version/channel readout (Phase V, V3/V4), right-aligned, unobtrusive. appVersion()
|
||||
// renders the configured version string on stable and that string plus "-beta" on beta,
|
||||
// so a beta panel self-identifies. It sits inside the space footer_bar reserves at the
|
||||
// right (rightReserve) and clears the prune button (prune_button::rightInset). Dim,
|
||||
// passive identification (V3).
|
||||
// Version/channel readout. appVersion() renders the configured version string on
|
||||
// stable and that string plus "-beta" on beta, so a beta panel self-identifies.
|
||||
kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top},
|
||||
appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right);
|
||||
|
||||
// Prune button — set apart at the far RIGHT (the ONLY warn-colored, byte-deleting control),
|
||||
// honoring hover. No-op when suppressed (footer too narrow). Order reads left (benign,
|
||||
// frequent) -> right (destructive, rare) per the L4 footer contract.
|
||||
// Prune button — the ONLY warn-colored, byte-deleting control. No-op when suppressed
|
||||
// (footer too narrow).
|
||||
const ButtonRect pb = pruneButtonRectFor(w, h);
|
||||
if (!pb.empty()) {
|
||||
const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1);
|
||||
@@ -204,14 +157,12 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
}
|
||||
}
|
||||
|
||||
// Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button
|
||||
// as a kit drawButton (rest/hover/disabled) with the action short label on the single-row face.
|
||||
// Overflow drops WHOLE trailing buttons (the pure layout returns only the buttons that fit), so
|
||||
// nothing is drawn clipped. `hoverKind` selects which HoverKind this bar's buttons use
|
||||
// (TopBarButton / BottomBarButton) so the two toolbars' hover states never cross. `topDivider`
|
||||
// draws a hairline at the band's top edge (the bottom toolbar's elevation over the split body);
|
||||
// the top toolbar draws it at its bottom edge instead. Key binding help is in the hover tooltip
|
||||
// (L6), not on the button face — the face shows only shortLabel.
|
||||
// Draws one task-grouped toolbar through the kit. Overflow drops WHOLE trailing buttons
|
||||
// (the pure layout returns only the buttons that fit), so nothing is drawn clipped.
|
||||
// `hoverKind` selects which HoverKind this bar's buttons use so the two toolbars' hover
|
||||
// states never cross. `topDivider` draws the hairline at the band's top edge (bottom
|
||||
// toolbar) vs. bottom edge (top toolbar). Key binding help is in the hover tooltip, not
|
||||
// on the button face.
|
||||
void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar,
|
||||
const std::vector<ActionBarRow>& rows, HoverKind hoverKind, bool topDivider) {
|
||||
if (bar.height <= 0 || bar.width <= 0) return;
|
||||
@@ -230,17 +181,14 @@ void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar,
|
||||
const ActionBarRow& row = rows[static_cast<std::size_t>(s.index)];
|
||||
const int cmd = resolveBarCommandId(row);
|
||||
|
||||
// State: Disabled when the action is not registered on this channel OR the row is gated
|
||||
// off (L5 opposite-mode enablement — the tag buttons for the ACTIVE mode); else Hover
|
||||
// when hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.)
|
||||
// Disabled when unregistered on this channel or gated off (opposite-mode
|
||||
// enablement); else Hover when hovered, else Rest — these are stateless triggers.
|
||||
InteractionState state = InteractionState::Rest;
|
||||
if (cmd == 0 || !row.enabled) state = InteractionState::Disabled;
|
||||
else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index)
|
||||
state = InteractionState::Hover;
|
||||
|
||||
// The button surface (drawButton draws the micro-gradient + rounded border + honors
|
||||
// the state). The label is drawn separately so the text role tracks the state correctly;
|
||||
// pass no label to drawButton.
|
||||
// Label drawn separately (not passed to drawButton) so its text role tracks state.
|
||||
const KitButtonBox box{KitBox{s.x, s.y, s.width, s.height}};
|
||||
drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false);
|
||||
|
||||
@@ -251,31 +199,22 @@ void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Top-toolbar overflow ("⋯" More) menu (L5 refinement 1) -------------------
|
||||
//
|
||||
// The three rare capture variants live only in this popup. The button is drawn kit-style (rest/
|
||||
// hover) at the far right of the top band; a click opens a REAPER/host TrackPopupMenu listing the
|
||||
// variants, each firing its existing registered command id via NamedCommandLookup/Main_OnCommand
|
||||
// (the SAME contract the visible buttons use — no action changes). A transient OS menu is fine
|
||||
// for panel-external chrome (brief §1); only the button geometry (overflow_menu) is pure.
|
||||
|
||||
// Draws the far-right More button (rest/hover). No-op when suppressed (band too narrow).
|
||||
// Draws the far-right More button (rest/hover) — the entry to the top-toolbar overflow
|
||||
// popup listing the rare capture variants. No-op when suppressed (band too narrow).
|
||||
void drawMoreButton(LICE_IBitmap* bmp, int w) {
|
||||
const MenuButtonRect mb = topMenuButtonRect(w);
|
||||
if (mb.empty()) return;
|
||||
const InteractionState state = hoverState(g_panel.hovered, HoverKind::MoreButton, -1);
|
||||
const KitButtonBox box{KitBox{mb.x, mb.y, mb.width, mb.height}};
|
||||
drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false);
|
||||
// The glyph: three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text
|
||||
// path). Drawn as text so it picks up the kit font + AA. Reads as the conventional "More".
|
||||
// Three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text path).
|
||||
kitText(bmp, KitBox{mb.x, mb.y, mb.width, mb.height}, "...",
|
||||
Font::Label, Role::TextPrimary, Align::Center);
|
||||
}
|
||||
|
||||
// Draws the hover-delay tooltip over the given anchor button, if a tooltip is due (the current
|
||||
// hover is a toolbar button AND it has been hovered past kTooltipDelayMs). Drawn LAST in the
|
||||
// paint so it overlays the toolbars. The box is placed by the pure tooltip module (below the
|
||||
// anchor, flipping above near the bottom edge, clamped to the client).
|
||||
// Draws the hover-delay tooltip over the given anchor button, if due. Drawn LAST so it
|
||||
// overlays the toolbars. Box placement (below anchor, flip above near the bottom edge,
|
||||
// clamp to client) is the pure tooltip module's.
|
||||
void drawTooltip(LICE_IBitmap* bmp, int w, int h) {
|
||||
if (!g_panel.tooltipShown) return;
|
||||
std::string txt;
|
||||
@@ -295,8 +234,6 @@ void drawTooltip(LICE_IBitmap* bmp, int w, int h) {
|
||||
kitText(bmp, box, txt.c_str(), Font::Label, Role::TextPrimary, Align::Center);
|
||||
}
|
||||
|
||||
// --- Drawing: a grid region ---------------------------------------------------
|
||||
|
||||
// Draws one region's grid of thumbnails (or an empty-state line) clipped to its
|
||||
// viewport. `selectionOwner` is true when this region holds the live selection, so
|
||||
// its cells show selection/focus chrome; the other region draws plain.
|
||||
@@ -311,14 +248,12 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks,
|
||||
return;
|
||||
}
|
||||
|
||||
// L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense
|
||||
// Iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense
|
||||
// BankModel insertion order. Selection/focus are keyed by the occupied-ordinal (selection
|
||||
// space); a slot maps back to its ordinal via selectionForSlot.
|
||||
const RegionDisplay disp = regionDisplay(region, isBanks, reg);
|
||||
// FA3 gap-free: request one bin per drawn pixel column; drawWaveform's
|
||||
// peaks::columnMinMax exact partition makes every column gap-free — overbinning
|
||||
// produces byte-identical pixels at higher memory/CPU cost. computeThumbnail clamps
|
||||
// the request to the frame count.
|
||||
// Request one bin per drawn pixel column; drawWaveform's peaks::columnMinMax exact
|
||||
// partition makes every column gap-free regardless. computeThumbnail clamps to frame count.
|
||||
const int binWidth = kWaveformOversample *
|
||||
waveformColumnCount(KitBox{0, 0, kGrid.cellWidth, kGrid.cellHeight});
|
||||
for (const SlotCellRect& r : disp.slotRects) {
|
||||
@@ -327,9 +262,8 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks,
|
||||
const std::string id = disp.idAtSlot(r.slot);
|
||||
if (id.empty()) {
|
||||
// Interior gap slot: a subtle empty-slot treatment through the kit — a hairline
|
||||
// outline on bg/cell, clearly NOT a card (decorative, per the L7 spec). No
|
||||
// selection/focus/waveform, and not a hover or hit target (the grid never tracks
|
||||
// cell hover; a click on an empty slot clears selection like any grid miss).
|
||||
// outline on bg/cell, clearly NOT a card. No selection/focus/waveform, and not a
|
||||
// hover or hit target (a click on an empty slot clears selection like any grid miss).
|
||||
fillSurface(bmp, KitBox{rect.x, rect.y, rect.width, rect.height},
|
||||
Role::BgCell, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height,
|
||||
@@ -349,12 +283,11 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks,
|
||||
}
|
||||
}
|
||||
|
||||
// L7: draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but
|
||||
// Draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but
|
||||
// ONLY when a same-bank in-grid drag (Reorder or Replace) is live over THIS region (the drag
|
||||
// source region). An accent/HOT outline (distinct from the accent/tertiary purple selection
|
||||
// border, per the spec's "must not be confusable" constraint); Replace draws a doubled outline
|
||||
// so an Alt-over-occupied replace reads as a stronger "swap" cue than a plain reorder. No-op
|
||||
// for a move/copy/OS drag or when the pointer is off any slot (dragTargetSlot < 0).
|
||||
// source region). An accent/HOT outline, distinct from the accent/tertiary purple selection
|
||||
// border so it is never confusable; Replace draws a doubled outline so an Alt-over-occupied
|
||||
// replace reads as a stronger "swap" cue than a plain reorder.
|
||||
void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Region reg) {
|
||||
if (!g_panel.dragging) return;
|
||||
if (g_panel.cardGesture != CardGesture::Reorder &&
|
||||
@@ -365,8 +298,8 @@ void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Reg
|
||||
|
||||
const RECT grid = regionGridRect(region, isBanks);
|
||||
const RegionDisplay disp = regionDisplay(region, isBanks, reg);
|
||||
// Use the drop rects (includes the trailing row past maxSlot) so a beyond-extent
|
||||
// target slot gets a visible highlight cue, not silence.
|
||||
// The drop rects include a trailing row past maxSlot so a beyond-extent target
|
||||
// slot gets a visible highlight cue, not silence.
|
||||
const int gridW = grid.right - grid.left;
|
||||
const int maxSlot = disp.bank ? disp.bank->slots.maxSlot() : -1;
|
||||
std::vector<SlotCellRect> dropRects = computeSlotRectsForDrop(maxSlot, gridW, kGrid);
|
||||
@@ -386,26 +319,24 @@ void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Reg
|
||||
void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title,
|
||||
const std::string& activeName, bool poolBtnIsPool) {
|
||||
const RECT hdr = regionHeaderRect(region);
|
||||
// Region header band (kit bg/panel — a raised region title bar). A hairline underline.
|
||||
fillSurface(bmp, KitBox{hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top},
|
||||
Role::BgPanel, InteractionState::Rest);
|
||||
LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0, false);
|
||||
|
||||
// Title, left (Font::Title — a region heading). The two regions are distinct KINDS of
|
||||
// container, so the title carries a CATEGORICAL accent (DS-2 revised: secondary/tertiary
|
||||
// mark kinds, never intensity) — Pool = secondary teal, Banks = tertiary purple. This is
|
||||
// a category mark, NOT the "what's live" signal (that stays the primary-lime "Active:"
|
||||
// readout beside it), keeping primary reserved for the live/active layer.
|
||||
// Title, left. The two regions are distinct KINDS of container, so the title carries a
|
||||
// CATEGORICAL accent (secondary/tertiary mark kinds, never intensity) — Pool = secondary
|
||||
// teal, Banks = tertiary purple. This is a category mark, NOT the "what's live" signal
|
||||
// (that stays the primary-lime "Active:" readout beside it).
|
||||
RECT titleRc = hdr;
|
||||
titleRc.left += 8;
|
||||
titleRc.right = titleRc.left + 120;
|
||||
const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary;
|
||||
kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left);
|
||||
|
||||
// Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the PRIMARY
|
||||
// accent role in BOTH region headers so the active/capture-target bank is legible even when
|
||||
// it is not the shown tab and even when it is the pool. Primary = "what's live" (DS-2).
|
||||
// Active-bank readout — the UNMISTAKABLE indicator, in the PRIMARY accent role in BOTH
|
||||
// region headers so the active/capture-target bank is legible even when it is not the
|
||||
// shown tab and even when it is the pool. Primary = "what's live".
|
||||
const std::string readout = "Active: " + activeName;
|
||||
RECT actRc = hdr;
|
||||
actRc.left = titleRc.right + 6;
|
||||
@@ -413,8 +344,8 @@ void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title,
|
||||
if (actRc.right > actRc.left)
|
||||
kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::AccentPrimary, Align::Left);
|
||||
|
||||
// Full-height toggle button: an arrow glyph. In split it means "maximize this region";
|
||||
// when this region is already full it means "restore the split". Kit drawButton + hover.
|
||||
// Arrow glyph: in split it means "maximize this region"; when already full it means
|
||||
// "restore the split".
|
||||
const RECT btn = fullHtBtnRect(region);
|
||||
const bool thisFull =
|
||||
poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly)
|
||||
@@ -434,7 +365,6 @@ void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title,
|
||||
void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) {
|
||||
const TabStripRect strip = banksTabStripRect(region);
|
||||
if (strip.height <= 0) return;
|
||||
// Tab strip band (kit bg/base — recessed relative to the region header above it).
|
||||
fillSurface(bmp, KitBox{strip.x, strip.y, strip.width, strip.height},
|
||||
Role::BgBase, InteractionState::Rest);
|
||||
|
||||
@@ -474,9 +404,8 @@ void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) {
|
||||
const bool hovered = g_panel.hovered.kind == HoverKind::Tab &&
|
||||
g_panel.hovered.index == tr.index;
|
||||
|
||||
// Surface state: the ACTIVE bank (capture target) carries the accent (Active); a drag
|
||||
// drop-target reads Dragging; the SHOWN (browsed) tab reads Pressed (recessed-lit);
|
||||
// else hover-or-rest bg/cell.
|
||||
// The ACTIVE bank (capture target) carries the accent; a drag drop-target reads
|
||||
// Dragging; the SHOWN (browsed) tab reads Pressed; else hover-or-rest bg/cell.
|
||||
const KitBox tb{tr.x, tr.y, tr.width, tr.height};
|
||||
InteractionState state = InteractionState::Rest;
|
||||
if (active) state = InteractionState::Active;
|
||||
@@ -510,8 +439,6 @@ std::string activeBankName() {
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- Full paint ---------------------------------------------------------------
|
||||
|
||||
void paintPanel(HWND hwnd, HDC hdc) {
|
||||
RECT cr{};
|
||||
GetClientRect(hwnd, &cr);
|
||||
@@ -525,16 +452,14 @@ void paintPanel(HWND hwnd, HDC hdc) {
|
||||
const std::string projectDir = currentProjectDir();
|
||||
const std::string activeName = activeBankName();
|
||||
|
||||
// Pool region (top).
|
||||
if (poolShown()) {
|
||||
const RECT region = poolRegionRect(w, h);
|
||||
drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true);
|
||||
drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool),
|
||||
"No samples in the pool yet. Capture one to see it here.",
|
||||
g_panel.focusedRegion == Region::Pool, projectDir, Region::Pool);
|
||||
// Drop-target highlight for the pool region during a MOVE/COPY drag (a whole-grid
|
||||
// outline signalling "drop here to move/copy into this bank"). Suppressed for a
|
||||
// same-bank reorder (that shows a per-SLOT highlight below, not the whole grid).
|
||||
// Whole-grid drop-target outline for a MOVE/COPY drag; a same-bank reorder shows a
|
||||
// per-SLOT highlight instead (drawCardDropTarget below).
|
||||
if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion &&
|
||||
(g_panel.cardGesture == CardGesture::Move ||
|
||||
g_panel.cardGesture == CardGesture::Copy)) {
|
||||
@@ -543,13 +468,9 @@ void paintPanel(HWND hwnd, HDC hdc) {
|
||||
grid.right - grid.left - 2, grid.bottom - grid.top - 2,
|
||||
toLice(roleColor(Role::AccentHot)), 1.0f, 0);
|
||||
}
|
||||
// L7 per-slot reorder/replace target highlight (source = pool). An accent/hot outline
|
||||
// on the target slot's cell — distinct from the accent/tertiary purple selection
|
||||
// border, so it is never confusable with a selected card.
|
||||
drawCardDropTarget(&bmp, region, /*isBanks=*/false, Region::Pool);
|
||||
}
|
||||
|
||||
// Split divider.
|
||||
if (poolShown() && banksShown()) {
|
||||
const RECT body = splitBody(w, h);
|
||||
const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2;
|
||||
@@ -557,7 +478,6 @@ void paintPanel(HWND hwnd, HDC hdc) {
|
||||
toLice(roleColor(Role::BgBase)), 1.0f, 0);
|
||||
}
|
||||
|
||||
// Named-banks region (bottom).
|
||||
if (banksShown()) {
|
||||
const RECT region = banksRegionRect(w, h);
|
||||
drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false);
|
||||
@@ -575,9 +495,8 @@ void paintPanel(HWND hwnd, HDC hdc) {
|
||||
? "Select or create a named bank."
|
||||
: "This bank is empty. Move samples here from the pool.",
|
||||
g_panel.focusedRegion == Region::Banks, projectDir, Region::Banks);
|
||||
// Drop-target highlight for the banks region during a drag. BanksRegion fires
|
||||
// when the pointer is in the grid but not on a specific tab; Tab draws its own
|
||||
// highlight on the individual tab (drawTabStrip above handles that case).
|
||||
// BanksRegion fires when the pointer is in the grid but not on a specific tab;
|
||||
// Tab draws its own highlight on the individual tab (drawTabStrip above).
|
||||
if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion &&
|
||||
(g_panel.cardGesture == CardGesture::Move ||
|
||||
g_panel.cardGesture == CardGesture::Copy)) {
|
||||
@@ -586,16 +505,12 @@ void paintPanel(HWND hwnd, HDC hdc) {
|
||||
grid.right - grid.left - 2, grid.bottom - grid.top - 2,
|
||||
toLice(roleColor(Role::AccentHot)), 1.0f, 0);
|
||||
}
|
||||
// L7 per-slot reorder/replace target highlight (source = banks region).
|
||||
drawCardDropTarget(&bmp, region, /*isBanks=*/true, Region::Banks);
|
||||
}
|
||||
|
||||
// L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles
|
||||
// into the band MINUS the far-right More-button reserve; the More button is drawn over the
|
||||
// band's reserved right strip; the BOTTOM toolbar (four opposite-mode tag buttons + Show
|
||||
// Both); then the footer (mode toggle + count + Tail button + Prune). Drawn last so they sit
|
||||
// over the split body's edges. drawToolbar fills only its passed (action) rect, so fill the
|
||||
// WHOLE top band first — otherwise the reserved right strip behind the More button is bare.
|
||||
// Toolbars + footer drawn last so they sit over the split body's edges. drawToolbar
|
||||
// fills only its passed (action) rect, so fill the WHOLE top band first — otherwise
|
||||
// the reserved right strip behind the More button is bare.
|
||||
fillSurface(&bmp, KitBox{0, 0, w, kTopToolbarHeight}, Role::BgPanel, InteractionState::Rest);
|
||||
drawToolbar(&bmp, topToolbarActionRect(w), topBarRows(), HoverKind::TopBarButton,
|
||||
/*topDivider=*/false);
|
||||
@@ -604,7 +519,6 @@ void paintPanel(HWND hwnd, HDC hdc) {
|
||||
/*topDivider=*/true);
|
||||
drawFooter(&bmp, w, h);
|
||||
|
||||
// The custom hover-delay tooltip overlays everything (L5 refinement 2).
|
||||
drawTooltip(&bmp, w, h);
|
||||
|
||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||
|
||||
+124
-241
@@ -1,36 +1,22 @@
|
||||
#pragma once
|
||||
// panel_state — INTERNAL shared state + cross-seam contract of the docked bank panel
|
||||
// (Q-W2: bank_panel.cpp split into eight TUs under shell/panel/). Included ONLY by the
|
||||
// panel's own translation units (panel_render / panel_thumbnails / panel_audition /
|
||||
// panel_input / panel_layout / panel_drag / panel_bank_ops / panel_window) — consumers
|
||||
// outside the panel use the per-seam public headers (panel_window.h / panel_input.h /
|
||||
// panel_bank_ops.h / panel_layout.h).
|
||||
// panel_state — INTERNAL shared state + cross-seam contract of the docked bank panel.
|
||||
// Included ONLY by the panel's own TUs; outside consumers use the per-seam public
|
||||
// headers (panel_window.h / panel_input.h / panel_bank_ops.h / panel_layout.h).
|
||||
// Cross-seam calls are plain free functions — direct call-through, no virtual dispatch
|
||||
// (audition and per-mouse-move paths must stay direct calls).
|
||||
//
|
||||
// What lives here:
|
||||
// * PanelState (the one shared state blob, defined in panel_window.cpp) + the small
|
||||
// enums/structs the seams speak (Region / DropKind / Hover / RegionDisplay /
|
||||
// ActionBarRow) and the shared layout constants.
|
||||
// * The cross-seam free-function declarations, grouped by OWNING TU. Everything is a
|
||||
// plain free function — direct call-through, no interface, no virtual dispatch
|
||||
// (T4-28: the audition path and the per-mouse-move path must stay direct calls).
|
||||
// * Explicit using-declarations pulling the pure modules' symbols into
|
||||
// reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces). The
|
||||
// interim core/namespaces.h shim is GONE (deleted in Q-W6 with the last split);
|
||||
// every symbol below names its true home.
|
||||
//
|
||||
// REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural
|
||||
// mutation (create/delete/evacuate/activate/move) can reallocate the book's vector,
|
||||
// so a BankModel& / Bank* must NEVER be cached across one. Every seam resolves fresh
|
||||
// AFTER any mutation and passes bank IDS (not references) into the model ops.
|
||||
// REFERENCE-INVALIDATION GUARDRAIL: a bank-structural mutation (create/delete/
|
||||
// evacuate/activate/move) can reallocate the book's vector, so a BankModel& / Bank*
|
||||
// must NEVER be cached across one. Every seam resolves fresh after any mutation and
|
||||
// passes bank IDS (not references) into the model ops.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
// SWELL / platform types (HWND, RECT, HMENU). On macOS/Linux SWELL is provided by the
|
||||
// host (SWELL_PROVIDED_BY_APP); on Windows we use native Win32 (windows.h first, then
|
||||
// swell.h no-ops on _WIN32).
|
||||
// On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP); on Windows
|
||||
// we use native Win32 (windows.h first, then swell.h no-ops on _WIN32).
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
@@ -39,37 +25,35 @@
|
||||
#include "wdltypes.h"
|
||||
#include "swell/swell.h"
|
||||
|
||||
// REAPER SDK types only (preview_register_t, MediaTrack, ReaProject). The API function
|
||||
// POINTERS are declared per-TU (REAPERAPI_MINIMAL + per-TU WANT list) — main.cpp owns
|
||||
// the definitions (CLAUDE.md §contract).
|
||||
// REAPER API function pointers are declared per-TU; main.cpp owns the definitions.
|
||||
#include "reaper_plugin.h"
|
||||
|
||||
#include "core/audio/peaks.h" // audio::Envelope — thumbnail cache payload
|
||||
#include "core/capture/capture_paths.h" // capture::resolveBankFile / normalizeSlashes
|
||||
#include "core/capture/render_settings.h" // capture::CaptureActionDef / captureActionTable
|
||||
#include "core/capture/tail_control.h" // capture::TailSetting — the tail toggle state
|
||||
#include "core/model/bank_book.h" // BankBook / Bank / SlotMap (flat reasampler until its split wave)
|
||||
#include "core/model/bank_model.h" // model::BankModel / model::Sample
|
||||
#include "core/ui/action_bar.h" // ui::ActionBarRect / slots / clusters
|
||||
#include "core/ui/bank_grid.h" // ui::GridSpec / Selection / ThumbnailKey / CellRect
|
||||
#include "core/ui/card_drag.h" // ui::CardGesture / SlotCellRect / gesture decisions
|
||||
#include "core/ui/card_meta.h" // ui::MusicalLength / formatters
|
||||
#include "core/ui/component_geometry.h" // ui::KitBox / KitButtonBox / waveformColumnCount
|
||||
#include "core/ui/drag_out.h" // ui::DragState / PanelClientRect / decideGesture
|
||||
#include "core/ui/footer_bar.h" // ui::FooterBarLayout / computeFooterBar
|
||||
#include "core/ui/mode_enable.h" // ui::tagButtonEnabled / TagTarget
|
||||
#include "core/ui/overflow_menu.h" // ui::MenuButtonSpec / computeMenuButton
|
||||
#include "core/ui/prune_button.h" // ui::ButtonRect / computePruneButton
|
||||
#include "core/ui/tab_strip.h" // ui::TabStripSpec / layout / hit-test
|
||||
#include "core/ui/theme.h" // ui::Role / InteractionState / KitColor
|
||||
#include "core/ui/tooltip.h" // ui::TooltipBox / computeTooltip / stripActionPrefix
|
||||
#include "core/version/app_version.h" // version::channelCommandId / appVersion / dock identity
|
||||
#include "core/view/guid_diff.h" // view::GuidBaseline — new-content detection
|
||||
#include "core/view/lane_keys.h" // view::isOnManualLane
|
||||
#include "core/view/mode_switch.h" // view::SegmentRect / computeSegmentRects
|
||||
#include "core/wire/instrument_drop.h" // wire::buildInstrumentDropPreset (S17)
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "core/capture/render_settings.h"
|
||||
#include "core/capture/tail_control.h"
|
||||
#include "core/model/bank_book.h"
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/ui/action_bar.h"
|
||||
#include "core/ui/bank_grid.h"
|
||||
#include "core/ui/card_drag.h"
|
||||
#include "core/ui/card_meta.h"
|
||||
#include "core/ui/component_geometry.h"
|
||||
#include "core/ui/drag_out.h"
|
||||
#include "core/ui/footer_bar.h"
|
||||
#include "core/ui/mode_enable.h"
|
||||
#include "core/ui/overflow_menu.h"
|
||||
#include "core/ui/prune_button.h"
|
||||
#include "core/ui/tab_strip.h"
|
||||
#include "core/ui/theme.h"
|
||||
#include "core/ui/tooltip.h"
|
||||
#include "core/version/app_version.h"
|
||||
#include "core/view/guid_diff.h"
|
||||
#include "core/view/lane_keys.h"
|
||||
#include "core/view/mode_switch.h"
|
||||
#include "core/wire/instrument_drop.h"
|
||||
|
||||
#include "shell/panel/panel_layout.h" // BankPanelFullHeight — the split-state enum
|
||||
#include "shell/panel/panel_layout.h"
|
||||
|
||||
namespace reasampler {
|
||||
class ReaSamplerSession;
|
||||
@@ -77,14 +61,6 @@ class ReaSamplerSession;
|
||||
|
||||
namespace reasampler::panel {
|
||||
|
||||
// --- Real-namespace-home using-declarations -----------------------------------
|
||||
//
|
||||
// The panel's pre-split internals reference the pure modules' symbols unqualified;
|
||||
// these explicit per-symbol usings keep those references valid while documenting
|
||||
// each symbol's Q-W1 home. Flat-`reasampler` symbols (BankBook / ViewModeModel /
|
||||
// the draw_kit shell / the shell/bank_ops verbs / ...) resolve via the enclosing
|
||||
// namespace and need no using.
|
||||
|
||||
// core/ui
|
||||
using ui::ActionBarRect;
|
||||
using ui::ActionBarSlot;
|
||||
@@ -200,73 +176,46 @@ using version::dockTitle;
|
||||
// core/wire
|
||||
using wire::buildInstrumentDropPreset;
|
||||
|
||||
// --- Layout constants ---------------------------------------------------------
|
||||
//
|
||||
// L2: every panel COLOR comes from the pure `theme` module by ROLE (drawn through the
|
||||
// L1 kit — fillSurface / drawButton / kit text). Only the pixel LAYOUT metrics (band
|
||||
// heights, grid/tab specs, insets) live here, shared by the layout/render/input/drag
|
||||
// seams so draw and hit-test can never drift.
|
||||
// All color comes from `theme` by role, drawn through the kit. Only pixel layout
|
||||
// metrics live here, shared by layout/render/input/drag so draw and hit-test can
|
||||
// never drift.
|
||||
|
||||
inline const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10};
|
||||
|
||||
// --- Footer (Phase L, L4) -----------------------------------------------------
|
||||
// The footer carries a task-cluster of small persistent controls: the narrowed
|
||||
// [Arrange|Design] mode toggle, a compact per-mode count, the Tail BUTTON (L4 §4 —
|
||||
// a real kit button, no longer a click-zone), and the set-apart Prune button at the
|
||||
// right. Taller than the L2 footer to host the toggle segments + button chrome cleanly.
|
||||
// Layout is the pure footer_bar (left group) + prune_button (right); this is the band height.
|
||||
// Footer: [Arrange|Design] toggle, per-mode count, Tail button, Prune button
|
||||
// (footer_bar left group + prune_button right).
|
||||
inline constexpr int kFooterHeight = 30;
|
||||
|
||||
// --- Toolbars (Phase L, L4) ---------------------------------------------------
|
||||
// TWO task-grouped toolbars, both drawn through the pure action_bar module:
|
||||
// * kTopToolbarHeight — the TOP toolbar (capture + placement clusters) at the very top of
|
||||
// the client, where the eye lands (L4 §1). Replaces the L2 mode-switch header there.
|
||||
// * kBottomToolbarHeight — the BOTTOM toolbar (Design-View tag/switch verbs) directly above
|
||||
// the footer (L4 §2). This is the L2 action-bar band, repurposed.
|
||||
inline constexpr int kTopToolbarHeight = 28; // single-row label face (L6: keybinding sub-row removed)
|
||||
inline constexpr int kBottomToolbarHeight = 28; // same shape — both bars consistent
|
||||
// Two task-grouped toolbars drawn through action_bar: top = capture + placement,
|
||||
// bottom = Design-View tag/switch verbs, directly above the footer.
|
||||
inline constexpr int kTopToolbarHeight = 28;
|
||||
inline constexpr int kBottomToolbarHeight = 28;
|
||||
|
||||
// --- Tooltip (Phase L, L5) ----------------------------------------------------
|
||||
// The custom hover-delay tooltip's timing + approximate text metrics. The delay matches the
|
||||
// platform convention (~0.5 s) so the tooltip is deliberate, not twitchy; it is driven off the
|
||||
// OnTimer poll (bankPanelRefresh) + WM_MOUSEMOVE, so no dedicated timer is added. The kit font
|
||||
// is AA and proportional, so the width is estimated from a per-char average (the tooltip box is
|
||||
// generous — a slight over/under-estimate only pads the box, never clips the text).
|
||||
// Hover-delay tooltip timing, driven off the OnTimer poll (no dedicated timer). Kit
|
||||
// font is proportional, so char width is a generous estimate (pads, never clips).
|
||||
inline constexpr unsigned int kTooltipDelayMs = 500;
|
||||
inline constexpr int kTooltipCharPx = 7; // approx px per char at Font::Label (generous)
|
||||
inline constexpr int kTooltipTextH = 14; // approx line height at Font::Label
|
||||
inline constexpr int kTooltipCharPx = 7;
|
||||
inline constexpr int kTooltipTextH = 14;
|
||||
|
||||
// --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) -------
|
||||
//
|
||||
// The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) |
|
||||
// split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer
|
||||
// (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool
|
||||
// region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER
|
||||
// band: a title, the active-bank readout, and a full-height toggle button. The named-banks
|
||||
// region's header ALSO hosts the LICE tab strip and a "+" create button.
|
||||
inline constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band
|
||||
inline constexpr int kTabStripHeight = 26; // the named-banks tab strip band
|
||||
inline constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions
|
||||
inline constexpr int kFullHtBtnWidth = 22; // the square full-height toggle button
|
||||
inline constexpr int kCreateBtnWidth = 22; // the "+" create-bank button
|
||||
// Client area top to bottom: top toolbar | split body | bottom toolbar | footer.
|
||||
inline constexpr int kRegionHeaderHeight = 24;
|
||||
inline constexpr int kTabStripHeight = 26;
|
||||
inline constexpr int kSplitDividerHeight = 3;
|
||||
inline constexpr int kFullHtBtnWidth = 22;
|
||||
inline constexpr int kCreateBtnWidth = 22;
|
||||
|
||||
// Tab strip metrics (the pure tab_strip owns the math; these are its inputs).
|
||||
inline const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20};
|
||||
|
||||
// The spec for the far-right More ("⋯") overflow-menu button. One source of truth for its
|
||||
// geometry + the reserve the action_bar leaves for it (L5).
|
||||
// Far-right More ("...") overflow-menu button: one source of truth for its
|
||||
// geometry + the reserve action_bar leaves for it.
|
||||
inline const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6,
|
||||
/*verticalInset=*/3, /*minLeftInset=*/40};
|
||||
|
||||
// The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared
|
||||
// by both toolbars' draw and hit-test (identical button shape top and bottom). L5 refinement 5:
|
||||
// clusterGap widened 16 -> 24 (a 6:1 inter/intra ratio) so semantic groups read AS groups. L6:
|
||||
// bindingHeight / minSplitHeight removed — buttons are single-row label-only faces now.
|
||||
// Shared by both toolbars' draw and hit-test (identical button shape top and
|
||||
// bottom). clusterGap is wider than buttonGap so semantic groups read as groups.
|
||||
inline const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24,
|
||||
/*sidePad=*/8, /*verticalInset=*/3};
|
||||
|
||||
// --- Panel state --------------------------------------------------------------
|
||||
|
||||
struct CachedThumbnail {
|
||||
Envelope envelope;
|
||||
int width = 0;
|
||||
@@ -276,32 +225,28 @@ struct CachedThumbnail {
|
||||
// input. The move/copy source is the focused region's displayed bank.
|
||||
enum class Region { Pool, Banks };
|
||||
|
||||
// What a drag is dropping onto, resolved live under the pointer during a drag.
|
||||
// BanksRegion fires when the pointer is anywhere in the named-banks grid that is NOT
|
||||
// on a specific tab (tab takes precedence — more specific wins). The resolved bank is
|
||||
// always shownBankId.
|
||||
// What a drag is dropping onto, resolved live under the pointer. BanksRegion fires
|
||||
// when the pointer is anywhere in the named-banks grid that is NOT on a specific tab
|
||||
// (tab takes precedence); the resolved bank is always shownBankId.
|
||||
enum class DropKind { None, PoolRegion, Tab, BanksRegion };
|
||||
|
||||
// --- Hover model (Phase L, L2) ------------------------------------------------
|
||||
//
|
||||
// The hovered interactive element, resolved live in WM_MOUSEMOVE so the kit draws its
|
||||
// hover state on that element only (the "hover on every interactive element" + "sub-frame
|
||||
// feedback = the perception of speed" L2 constraint). SWELL exposes no WM_MOUSELEAVE (grep
|
||||
// of vendor/WDL/WDL/swell — none), so hover is cleared by a move that resolves to None
|
||||
// rather than a leave message; the panel is Windows-only (D5) but this stays portable-safe.
|
||||
// hover state on that element only. SWELL exposes no WM_MOUSELEAVE (confirmed: no hit in
|
||||
// vendor/WDL/WDL/swell), so hover is cleared by a move that resolves to None rather than a
|
||||
// leave message; the panel is Windows-only but this stays portable-safe.
|
||||
// `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A.
|
||||
enum class HoverKind {
|
||||
None,
|
||||
TopBarButton, // a button in the TOP toolbar (index = flat action index into topBarRows)
|
||||
BottomBarButton, // a button in the BOTTOM toolbar (index = flat action index into bottomBarRows)
|
||||
MoreButton, // the TOP toolbar's far-right "⋯" overflow-menu button (L5)
|
||||
TopBarButton, // index = flat action index into topBarRows
|
||||
BottomBarButton, // index = flat action index into bottomBarRows
|
||||
MoreButton,
|
||||
PruneButton,
|
||||
FullHtPool, // pool region full-height toggle
|
||||
FullHtBanks, // banks region full-height toggle
|
||||
CreateBank, // the "+" create-bank button
|
||||
Tab, // a named-bank tab (index = tab ordinal)
|
||||
TailButton, // the footer Tail button (L4 §4 — a real button, was a click-zone)
|
||||
ModeSegment, // a footer mode-toggle segment (index = segment ordinal)
|
||||
FullHtPool,
|
||||
FullHtBanks,
|
||||
CreateBank,
|
||||
Tab, // index = tab ordinal
|
||||
TailButton,
|
||||
ModeSegment, // index = segment ordinal
|
||||
};
|
||||
|
||||
struct Hover {
|
||||
@@ -312,9 +257,8 @@ struct Hover {
|
||||
bool operator!=(const Hover& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
// The kit interaction state for an interactive element: Hover when this (kind,index) is the
|
||||
// live hovered element, else Rest. Active/Pressed are decided per-element by the caller (e.g.
|
||||
// an active tab draws Active regardless of hover); this is the base rest/hover resolver.
|
||||
// Base rest/hover resolver; Active/Pressed are decided per-element by the caller
|
||||
// (e.g. an active tab draws Active regardless of hover).
|
||||
inline InteractionState hoverState(const Hover& hovered, HoverKind kind, int index) {
|
||||
return (hovered.kind == kind && hovered.index == index) ? InteractionState::Hover
|
||||
: InteractionState::Rest;
|
||||
@@ -331,135 +275,83 @@ struct PanelState {
|
||||
|
||||
std::unordered_map<std::string, CachedThumbnail> cache;
|
||||
|
||||
// --- Selection (per focused region) ---------------------------------------
|
||||
// One live selection, scoped to `focusedRegion`. Switching regions moves the
|
||||
// selection with the focus (a click in the other region reseeds it there).
|
||||
// selection with the focus.
|
||||
Selection selection;
|
||||
int selItemCount = 0;
|
||||
Region focusedRegion = Region::Pool;
|
||||
|
||||
// --- Hover (Phase L, L2) --------------------------------------------------
|
||||
// The live hovered interactive element (WM_MOUSEMOVE resolves it; the kit draws its
|
||||
// hover state). Repaint fires only when this changes (sub-frame, no per-move jank).
|
||||
// Resolved on WM_MOUSEMOVE; repaint fires only when this changes.
|
||||
Hover hovered;
|
||||
|
||||
// --- Tooltip (Phase L, L5) ------------------------------------------------
|
||||
// A custom LICE-kit hover-delay tooltip (NOT the native Win32/SWELL tooltip control): when a
|
||||
// TOOLTIP-capable element (a toolbar button) stays hovered past kTooltipDelayMs, the panel
|
||||
// draws a small overlay carrying the full, prefix-stripped action name. hoverSinceTick is the
|
||||
// GetTickCount() at which the CURRENT hovered element was first entered (reset on every hover
|
||||
// change); tooltipShown latches once the delay elapses so the OnTimer poll repaints exactly
|
||||
// once when the tooltip appears. The last-seen pointer pos anchors nothing (the anchor is the
|
||||
// hovered button's rect), but is kept so the OnTimer path can re-resolve without a live event.
|
||||
// Custom hover-delay tooltip. hoverSinceTick resets on every hover change;
|
||||
// tooltipShown latches once the delay elapses so the OnTimer poll repaints once.
|
||||
unsigned int hoverSinceTick = 0;
|
||||
bool tooltipShown = false;
|
||||
|
||||
// --- Vertical-split state -------------------------------------------------
|
||||
BankPanelFullHeight fullHeight = BankPanelFullHeight::Split;
|
||||
|
||||
// The named bank whose grid the banks region shows (the SHOWN tab) — DISTINCT
|
||||
// from the active/capture-target bank (book().activeBankId()). Empty when there
|
||||
// are no named banks. Reconciled each fingerprint pass so it always names a live
|
||||
// named bank (or is empty).
|
||||
// The named bank the banks region shows — distinct from the active/capture-target
|
||||
// bank. Reconciled each fingerprint pass so it always names a live bank (or empty).
|
||||
std::string shownBankId;
|
||||
|
||||
// Tab-strip horizontal scroll offset (px), clamped to the strip's max each frame.
|
||||
int tabScroll = 0;
|
||||
|
||||
// --- Drag (sample move between regions/onto a tab) ------------------------
|
||||
// A drag begins only after the pointer moves past a threshold from a press that
|
||||
// landed on a SELECTED grid cell — this is how it is disambiguated from the M5
|
||||
// multi-select drag (which begins immediately on any grid press). See handleClick/
|
||||
// onMouseMove. dragging is true once the threshold is crossed.
|
||||
bool dragArmed = false; // pressed on a selected cell; watching for threshold
|
||||
bool dragging = false; // threshold crossed; a move-drag is in progress
|
||||
// A drag begins only after the pointer moves past a threshold from a press
|
||||
// that landed on a selected grid cell — disambiguates from the multi-select
|
||||
// drag (which begins immediately on any grid press).
|
||||
bool dragArmed = false;
|
||||
bool dragging = false;
|
||||
int dragStartX = 0, dragStartY = 0;
|
||||
Region dragSourceRegion = Region::Pool;
|
||||
std::string dragSourceBankId; // the bank the dragged samples come from
|
||||
std::vector<std::string> dragSampleIds;// snapshot of the selection at drag start
|
||||
std::string dragPrimaryId; // the single card grabbed (the focus) — the L7
|
||||
// reorder/replace subject (see onLBtnUp dispatch)
|
||||
DropKind dropKind = DropKind::None; // live drop target under the pointer
|
||||
std::string dragSourceBankId;
|
||||
std::vector<std::string> dragSampleIds;
|
||||
std::string dragPrimaryId; // the single card grabbed — the reorder/replace subject
|
||||
DropKind dropKind = DropKind::None;
|
||||
std::string dropBankId; // destination bank id when dropKind==Tab
|
||||
|
||||
// --- L7 in-grid reorder/replace drag --------------------------------------
|
||||
// The live card gesture resolved by the pure card_drag::decideCardGesture each mouse-
|
||||
// move (drives the cursor cue AND the drop dispatch), plus the same-bank target slot the
|
||||
// pointer sits over (>= 0 only for a Reorder/Replace over the source bank's own grid; -1
|
||||
// otherwise). A Reorder highlights dragTargetSlot's cell; Replace + a live cursor cue
|
||||
// signal the Alt-over-occupied case. Reset with the rest of the drag state on drop/cancel.
|
||||
// Resolved each mouse-move by card_drag::decideCardGesture. dragTargetSlot >= 0
|
||||
// only for a Reorder/Replace over the source bank's own grid.
|
||||
CardGesture cardGesture = CardGesture::None;
|
||||
int dragTargetSlot = -1;
|
||||
|
||||
// --- S17 drop-and-load (InstrumentDrop) -----------------------------------
|
||||
// While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an
|
||||
// InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX
|
||||
// hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the
|
||||
// dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last
|
||||
// resolved FX-hotspot track (null when the pointer is not over an FX button) — read on
|
||||
// release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched.
|
||||
// While a single-capture drag is over REAPER's own UI, heading for a track's TCP FX
|
||||
// button: on release this adds a ReaSampler 9000 preloaded with the capture. Null
|
||||
// when the pointer is not over an FX button.
|
||||
MediaTrack* instrumentDropTrack = nullptr;
|
||||
|
||||
// --- Tail-mode toggle -----------------------------------------------------
|
||||
// The authoritative tail setting lives in ReaSamplerSession (session->tail()),
|
||||
// NOT in panel state, so it travels inside the .rpp (persist serializes it on save,
|
||||
// restores it on project load). The panel reads it for drawing and mutates it via
|
||||
// the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the
|
||||
// project dirty so the choice saves. bankPanelTailSetting is the read seam for the
|
||||
// capture actions. Held here only through the session pointer above.
|
||||
// Authoritative tail setting lives in ReaSamplerSession, not here; panel reads it for
|
||||
// drawing and mutates via footer click / scroll-wheel. bankPanelTailSetting is the
|
||||
// capture actions' read seam.
|
||||
|
||||
// --- Audition preview -----------------------------------------------------
|
||||
preview_register_t preview{};
|
||||
PCM_source* previewSrc = nullptr;
|
||||
bool previewActive = false;
|
||||
bool previewInited = false; // guards double init / deinit
|
||||
|
||||
// --- New-content detection (D2 Wave 2) ------------------------------------
|
||||
//
|
||||
// Each timer tick diffs the live track+item GUID set against the previous tick to
|
||||
// auto-tag content created SINCE the last tick into the then-active mode. The
|
||||
// baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its
|
||||
// first observe()) so pre-existing content is never mass-tagged (it stays Arrange).
|
||||
//
|
||||
// Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a
|
||||
// pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact
|
||||
// tick persist restores a project's membership + active mode (the same tick it
|
||||
// reapplies the active mode); that sets reloadPending so the NEXT detect tick this
|
||||
// same tick re-baselines against the fully-loaded set and reports nothing new. This
|
||||
// replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than
|
||||
// persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto
|
||||
// a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then
|
||||
// diffed against the previous project's stale baseline and were mass-tagged into the
|
||||
// active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the
|
||||
// two identity checks agree by construction.
|
||||
//
|
||||
// Lives for the extension's lifetime alongside the session, independent of panel
|
||||
// open/close — detection must run whether or not the dock is visible (content is
|
||||
// created in the arrange, not the panel).
|
||||
// auto-tag new content. GuidBaseline self-arms on first observe() so pre-existing
|
||||
// content is never mass-tagged. Project-load re-arm is driven by persist's load
|
||||
// signal, not a ReaProject* compare — a recycled address previously mis-tagged tracks.
|
||||
GuidBaseline contentBaseline;
|
||||
bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick
|
||||
};
|
||||
|
||||
// The one shared panel state blob. Defined in panel_window.cpp (the lifecycle owner).
|
||||
// Defined in panel_window.cpp (the lifecycle owner).
|
||||
extern PanelState g_panel;
|
||||
|
||||
// --- L7 slot-order display bridge ---------------------------------------------
|
||||
//
|
||||
// L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted
|
||||
// SlotMap order (sparse, gap-preserving), NOT BankModel insertion order. regionDisplay
|
||||
// (panel_layout.cpp) is the single place that resolves a region's display, composed
|
||||
// purely from bank_book's slot order (orderedSampleIds) + card_drag's sparse slot rects
|
||||
// (computeSlotRects) — the shell adds no layout math of its own.
|
||||
// The grid draws in the bank's persisted SlotMap order (sparse, gap-preserving),
|
||||
// NOT BankModel insertion order. regionDisplay (panel_layout.cpp) is the single
|
||||
// place that resolves a region's display.
|
||||
//
|
||||
// TWO INDEX SPACES the whole panel must keep straight:
|
||||
// * SLOT — a display position 0..maxSlot; gaps are empty slots that draw as empty
|
||||
// cells and are valid drop targets. This is what pixels/hit-tests speak.
|
||||
// * SELECTION — the DENSE occupied-ordinal [0, occupied) space the pure Selection /
|
||||
// applyClick / navigate reason in. Selection index i <-> orderedIds[i].
|
||||
// Keyboard navigation therefore traverses ONLY occupied cells and SKIPS
|
||||
// gaps (spec: skip-vs-land-on-gap is unspecified -> skip, documented here).
|
||||
// RegionDisplay carries both plus the translation between them, resolved FRESH each call
|
||||
// (never cached across a mutation, per the reference-invalidation guardrail).
|
||||
// * SLOT — display position 0..maxSlot; gaps are empty, valid drop targets.
|
||||
// What pixels/hit-tests speak.
|
||||
// * SELECTION — the dense occupied-ordinal [0, occupied) space the pure
|
||||
// Selection/applyClick/navigate reason in. Selection index i <->
|
||||
// orderedIds[i]. Keyboard nav therefore skips gaps.
|
||||
// RegionDisplay carries both plus the translation, resolved fresh each call (never
|
||||
// cached across a mutation, per the reference-invalidation guardrail).
|
||||
struct RegionDisplay {
|
||||
std::vector<std::string> orderedIds; // occupied ids in slot order (selection space)
|
||||
std::vector<SlotCellRect> slotRects; // one rect per slot 0..maxSlot, viewport coords
|
||||
@@ -486,27 +378,18 @@ struct RegionDisplay {
|
||||
int occupiedCount() const { return static_cast<int>(orderedIds.size()); }
|
||||
};
|
||||
|
||||
// --- Toolbar row vocabulary (Phase L, L4/L5/L6) --------------------------------
|
||||
//
|
||||
// One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix
|
||||
// at fire time — never a hardcoded numeric id), its terse on-button FACE label, its full action
|
||||
// NAME for the hover tooltip (already prefix-stripped — the "ReaSampler:" display prefix is
|
||||
// dropped at build), and the task cluster it belongs to. The order of a toolbar's row list IS
|
||||
// the flat action index the pure action_bar slots carry, so each list is built cluster-by-cluster
|
||||
// in its toolbar's cluster order. Built by panel_layout (topBarRows / bottomBarRows /
|
||||
// overflowMenuRows); consumed by the render draw, the input click routing, and the drag hover.
|
||||
// One action button. Row order IS the flat action index the pure action_bar slots
|
||||
// carry. Built by panel_layout; consumed by render, input, drag.
|
||||
struct ActionBarRow {
|
||||
std::string suffix;
|
||||
std::string shortLabel;
|
||||
std::string fullName;
|
||||
ActionCluster cluster = ActionCluster::Capture;
|
||||
bool enabled = true; // L5: opposite-mode gate for the bottom-bar tag buttons; always true
|
||||
// for the top bar (its actions are unconditional triggers).
|
||||
bool enabled = true; // opposite-mode gate for bottom-bar tag buttons; always true
|
||||
// for the top bar (unconditional triggers)
|
||||
};
|
||||
|
||||
// --- Shared one-liner helpers --------------------------------------------------
|
||||
|
||||
// Modifier state at event time. Alt = the L7 replace modifier.
|
||||
// Modifier state at event time. Alt = the replace modifier.
|
||||
inline bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; }
|
||||
inline bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; }
|
||||
inline bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; }
|
||||
@@ -515,7 +398,7 @@ inline void invalidatePanel() {
|
||||
if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE);
|
||||
}
|
||||
|
||||
// --- Cross-seam contract (grouped by OWNING TU; all plain free functions) ------
|
||||
// Cross-seam contract, grouped by owning TU; all plain free functions.
|
||||
|
||||
// panel_bank_ops.cpp — book/bank accessors + the bank-CRUD verbs + menus.
|
||||
BankBook* book();
|
||||
@@ -536,7 +419,7 @@ void showSelectionMenu(int screenX, int screenY);
|
||||
void showMoreMenu();
|
||||
|
||||
// panel_layout.cpp — toolbar/footer/menu rects, row/cluster builders, split geometry,
|
||||
// region rects, the L7 display bridge. Draw and hit-test both call these so they never drift.
|
||||
// region rects, the display bridge. Draw and hit-test both call these so they never drift.
|
||||
int modeCount();
|
||||
MenuButtonRect topMenuButtonRect(int w);
|
||||
ActionBarRect topToolbarActionRect(int w);
|
||||
@@ -579,8 +462,8 @@ const Envelope& thumbnailFor(const Sample& sample, int width, const std::string&
|
||||
bool refreshFingerprint();
|
||||
void reconcileShownBank();
|
||||
|
||||
// panel_audition.cpp — the preview engine (HOT PATH: direct call-through, never
|
||||
// virtual, no added header->TU indirection — T4-28 / Q-W2 guardrail).
|
||||
// panel_audition.cpp — the preview engine (hot path: direct call-through, never
|
||||
// virtual, no added header->TU indirection).
|
||||
void initPreview();
|
||||
void deinitPreview();
|
||||
void stopAudition();
|
||||
@@ -595,7 +478,7 @@ void registerAccel();
|
||||
void unregisterAccel();
|
||||
|
||||
// panel_drag.cpp — the card-drag/hover state machine (pure mirror: core/ui/card_drag).
|
||||
// Per-mouse-move work stays plain free-function calls (T4-28).
|
||||
// Per-mouse-move work stays plain free-function calls.
|
||||
void onMouseMove(int x, int y);
|
||||
void onLBtnUp(int x, int y);
|
||||
void handleRightClick(int x, int y);
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
// panel_thumbnails.cpp — thumbnail compute + cache seam of the docked bank panel
|
||||
// (Q-W2 split of bank_panel.cpp; M5/FA3). Owns the per-sample PCM read via PCM_source
|
||||
// fed to peaks::computeEnvelope (one bin per drawn pixel column) and the in-memory
|
||||
// thumbnail cache keyed by (sample id, bin width, bank generation) — plus the
|
||||
// bank-change fingerprint pass that OWNS that generation key: refreshFingerprint bumps
|
||||
// the generation, clears the cache, resets selection/audition, and reconciles the
|
||||
// shown bank on any book mutation. (The fingerprint pass lives here rather than in
|
||||
// panel_input because the cache + generation it invalidates are this seam's state —
|
||||
// a Q-W2 placement judgment; the T4-01 audit lumped it under the input seam's range.)
|
||||
// panel_thumbnails.cpp — thumbnail compute + cache seam of the docked bank panel.
|
||||
// Owns the per-sample PCM read fed to peaks::computeEnvelope and the in-memory
|
||||
// thumbnail cache keyed by (sample id, bin width, bank generation), plus the
|
||||
// bank-change fingerprint pass that owns that generation key — it lives here
|
||||
// because the cache + generation it invalidates are this seam's state.
|
||||
//
|
||||
// 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). DAW-verified, not unit tested.
|
||||
// main.cpp owns the API pointers; here they are extern. DAW-verified, not unit tested.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
@@ -29,8 +23,7 @@ namespace {
|
||||
|
||||
constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k)
|
||||
|
||||
// --- Thumbnail computation (M5; `width` is a BIN count since FA3 oversampling) --
|
||||
|
||||
// `width` is a BIN count.
|
||||
Envelope computeThumbnail(const std::string& absPath, int width) {
|
||||
if (width <= 0 || absPath.empty()) return {};
|
||||
|
||||
@@ -98,13 +91,11 @@ const Envelope& thumbnailFor(const Sample& sample, int width,
|
||||
return ins.first->second.envelope;
|
||||
}
|
||||
|
||||
// --- Bank-change detection ----------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag
|
||||
// + per-sample id/path. Catches every mutation the panel must redraw for: capture,
|
||||
// project load, and B4's own create/rename/delete/move/activate.
|
||||
// project load, and create/rename/delete/move/activate.
|
||||
std::string bookFingerprint() {
|
||||
BankBook* b = book();
|
||||
if (!b) return {};
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// panel_window.cpp — the window-lifecycle seam of the docked bank panel (Q-W2 split
|
||||
// of bank_panel.cpp; M5 Wave A). Owns the SWELL dialog (IDD_BANK_PANEL) docked via
|
||||
// DockWindowAddEx / undocked via DockWindowRemove, the dialog proc that routes
|
||||
// messages to the input/drag/render/audition seams, the S8 OS drop-target opt-in
|
||||
// (WM_DROPFILES -> ingest), and the shared PanelState blob's definition.
|
||||
// panel_window.cpp — window-lifecycle seam of the docked bank panel. Owns the SWELL
|
||||
// dialog (docked via DockWindowAddEx / undocked via DockWindowRemove), the dialog
|
||||
// proc routing to the input/drag/render/audition seams, the OS drop-target opt-in
|
||||
// (WM_DROPFILES -> ingest), and the shared PanelState blob's definition. The panel
|
||||
// never inserts into the arrange. Dock title + persisted-position identstr both
|
||||
// come from app_version (channel-qualified).
|
||||
//
|
||||
// READ-ONLY of the TIMELINE (load-bearing principle): the panel never inserts into
|
||||
// the arrange. Channel-qualified dock identity (Phase V, V4): title + persisted-
|
||||
// position identstr both come from app_version.
|
||||
//
|
||||
// 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). DAW-verified, not unit tested.
|
||||
// main.cpp owns the API pointers; here they are extern. DAW-verified, not unit tested.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -18,12 +13,12 @@
|
||||
#include "shell/panel/panel_state.h"
|
||||
#include "shell/panel/panel_window.h"
|
||||
|
||||
#include "shell/panel/draw_kit.h" // kitFontsInit/Shutdown — the kit's cached AA fonts (L1)
|
||||
#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest
|
||||
#include "shell/panel/draw_kit.h"
|
||||
#include "ingest.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
||||
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest
|
||||
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish
|
||||
#endif
|
||||
|
||||
#include "resource.h"
|
||||
@@ -40,26 +35,20 @@ extern REAPER_PLUGIN_HINSTANCE g_hInst;
|
||||
|
||||
namespace reasampler::panel {
|
||||
|
||||
// The one shared panel state blob (declared extern in panel_state.h). Defined here —
|
||||
// the lifecycle seam owns the state's lifetime, mirroring the old single-TU global.
|
||||
// Defined here — the lifecycle seam owns the state's lifetime.
|
||||
PanelState g_panel;
|
||||
|
||||
// --- Dialog proc + docking ----------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands
|
||||
// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all into the active
|
||||
// bank (bank-fill only — no assignment to any live instance). Always DragFinish's the HDROP
|
||||
// (frees the shell-allocated drop buffer) on every path. DragQueryFile(hDrop, 0xFFFFFFFF, ...)
|
||||
// returns the file count; then each path is queried by index. Both Win32 and SWELL expose
|
||||
// DragQueryFile/DragFinish with this contract.
|
||||
// DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; each path is then
|
||||
// queried by index (length first, excludes NUL, then a sized buffer). DragFinish
|
||||
// always frees the shell-allocated drop buffer. Multi-file drop imports all into
|
||||
// the active bank (bank-fill only — no assignment to any live instance).
|
||||
void handleDropFiles(HDROP hDrop) {
|
||||
std::vector<std::string> paths;
|
||||
const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0);
|
||||
paths.reserve(count);
|
||||
for (UINT i = 0; i < count; ++i) {
|
||||
// Query the required length first (excludes the NUL), then read into a sized buffer.
|
||||
const UINT len = DragQueryFile(hDrop, i, nullptr, 0);
|
||||
if (len == 0) continue;
|
||||
std::vector<char> buf(static_cast<std::size_t>(len) + 1, '\0');
|
||||
@@ -74,8 +63,6 @@ void handleDropFiles(HDROP hDrop) {
|
||||
WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
switch (msg) {
|
||||
case WM_DROPFILES:
|
||||
// S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import
|
||||
// into the active bank (bank-fill only). wParam is the HDROP.
|
||||
handleDropFiles(reinterpret_cast<HDROP>(wParam));
|
||||
return 0;
|
||||
case WM_PAINT: {
|
||||
@@ -101,10 +88,8 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||
return 0;
|
||||
case WM_CAPTURECHANGED:
|
||||
// Capture lost (pointer left window pre-threshold and released outside, or another
|
||||
// window stole capture mid-drag) — cancel the whole drag as a NO-OP so no stale
|
||||
// state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is
|
||||
// mutated on a cancel; the cursor is restored to the arrow.
|
||||
// Capture lost (pointer left pre-threshold, or another window stole it
|
||||
// mid-drag) — cancel the drag as a no-op, mirroring onLBtnUp's reset.
|
||||
if (g_panel.dragArmed || g_panel.dragging) {
|
||||
resetDragState();
|
||||
SetCursor(LoadCursor(nullptr, IDC_ARROW));
|
||||
@@ -112,13 +97,9 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
}
|
||||
return 0;
|
||||
case WM_MOUSEWHEEL: {
|
||||
// Fine-adjust the Manual tail length when the wheel is over the footer.
|
||||
// UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in
|
||||
// lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so
|
||||
// convert to client space before hit-testing the footer. The signed wheel
|
||||
// delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120,
|
||||
// matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer
|
||||
// handler acts, so scrolling elsewhere in the dock still behaves normally.
|
||||
// Unlike button messages, WM_MOUSEWHEEL carries SCREEN coords in lParam
|
||||
// (Win32 and SWELL agree), so convert to client space first. Wheel delta
|
||||
// is the HIWORD of wParam. Consume (return 1) only when the footer acts.
|
||||
POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
|
||||
ScreenToClient(hwnd, &pt);
|
||||
const int delta = static_cast<short>(HIWORD(wParam));
|
||||
@@ -147,30 +128,23 @@ void openPanel() {
|
||||
}
|
||||
initPreview();
|
||||
|
||||
// Create the kit's cached AA fonts before the first paint (Phase L, L1). Idempotent, so
|
||||
// a reopen after closePanel (which leaves the fonts alive) is a cheap no-op; the fonts
|
||||
// are torn down once at bankPanelShutdown. All panel text draws through these.
|
||||
// Idempotent — a reopen after closePanel (fonts left alive) is a cheap no-op;
|
||||
// torn down once at bankPanelShutdown.
|
||||
kitFontsInit();
|
||||
|
||||
g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL),
|
||||
GetMainHwnd(), dlgProc, 0);
|
||||
if (!g_panel.hwnd) return;
|
||||
|
||||
// Channel-qualified dock identity (Phase V, V4). The title and the persisted-position
|
||||
// identstr both come from app_version, so a beta panel is distinguishable ("ReaSampler
|
||||
// Bank beta") and does not fight over stable's saved dock slot (the identstr is a
|
||||
// REAPER-global collision surface — it keys the persisted dock position).
|
||||
// identstr is a REAPER-global collision surface keying the persisted dock
|
||||
// position — channel-qualified so beta doesn't fight over stable's slot.
|
||||
DockWindowAddEx(g_panel.hwnd, dockTitle().c_str(), dockIdent().c_str(), true);
|
||||
DockWindowActivate(g_panel.hwnd);
|
||||
g_panel.open = true;
|
||||
|
||||
// S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles).
|
||||
// DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it,
|
||||
// so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3
|
||||
// instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration
|
||||
// surface is out of scope for this dispatch. WM_DROPFILES handling itself uses
|
||||
// DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means
|
||||
// would still ingest — only the accept opt-in is gated.
|
||||
// DragAcceptFiles is native Win32 (shellapi.h); SWELL doesn't expose it, so the
|
||||
// accept opt-in is Windows-only. DragQueryFile/DragFinish ARE SWELL-provided,
|
||||
// so a drop delivered by other means would still ingest.
|
||||
#ifdef _WIN32
|
||||
DragAcceptFiles(g_panel.hwnd, TRUE);
|
||||
#endif
|
||||
@@ -199,27 +173,20 @@ void closePanel() {
|
||||
|
||||
} // namespace reasampler::panel
|
||||
|
||||
// --- Public API (the lifecycle seam — panel_window.h) --------------------------
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
void bankPanelInit(ReaSamplerSession* session) {
|
||||
panel::g_panel.session = session;
|
||||
}
|
||||
|
||||
// Returns true only when the panel window is actually visible to the user right now.
|
||||
// IsWindowVisible() returns false when the docker is hidden via Alt+D even though the
|
||||
// HWND and g_panel.open are still live — the live query is the source of truth for
|
||||
// toggle decisions and the Actions-list checkmark (OnToggleAction in main.cpp).
|
||||
// Alt+D hides the docker without destroying the window, leaving HWND/g_panel.open
|
||||
// live but IsWindowVisible false — the live query is the source of truth for
|
||||
// toggle decisions and the Actions-list checkmark.
|
||||
static bool panelEffectivelyVisible() {
|
||||
return panel::g_panel.hwnd && IsWindowVisible(panel::g_panel.hwnd);
|
||||
}
|
||||
|
||||
void bankPanelToggle() {
|
||||
// Decide from live visibility, not the cached g_panel.open flag.
|
||||
// Alt+D hides the docker without destroying the window, leaving g_panel.open
|
||||
// stale (true) while the panel is gone. Using IsWindowVisible avoids the
|
||||
// double-fire needed to re-show the panel after a docker hide.
|
||||
if (panelEffectivelyVisible())
|
||||
panel::closePanel();
|
||||
else
|
||||
@@ -227,8 +194,6 @@ void bankPanelToggle() {
|
||||
}
|
||||
|
||||
bool bankPanelIsOpen() {
|
||||
// Derive from live window state so the Actions-list checkmark stays honest
|
||||
// even after Alt+D hides the docker without notifying the extension.
|
||||
return panelEffectivelyVisible();
|
||||
}
|
||||
|
||||
@@ -239,7 +204,7 @@ void bankPanelInvalidate() {
|
||||
void bankPanelShutdown() {
|
||||
panel::closePanel();
|
||||
panel::deinitPreview();
|
||||
kitFontsShutdown(); // free the kit's cached AA fonts + their owned HFONTs (L1)
|
||||
kitFontsShutdown();
|
||||
panel::g_panel.cache.clear();
|
||||
panel::g_panel.session = nullptr;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,29 @@
|
||||
#pragma once
|
||||
// panel_window — the window-lifecycle seam of the docked bank panel (Q-W2 split of
|
||||
// bank_panel.h; M5, Wave A). REAPER-facing shell: the .cpp owns a SWELL dialog
|
||||
// (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via DockWindowRemove,
|
||||
// toggled open/closed, plus the OS drop-target opt-in (S8) and the dialog proc that
|
||||
// routes messages to the input/drag/render seams. The panel itself NEVER inserts
|
||||
// into the arrange or mutates the project (CONTEXT.md §load-bearing principle).
|
||||
//
|
||||
// The header is REAPER-free: main.cpp drives the panel through these free functions,
|
||||
// passing the live session so the panel reads the current bank. All SWELL / LICE /
|
||||
// PCM_source use is confined to the shell/panel/ .cpp seams.
|
||||
// panel_window — window-lifecycle seam of the docked bank panel: owns the SWELL
|
||||
// dialog (dock/undock, toggle), the drop-target opt-in, and the dialog proc that
|
||||
// routes to the input/drag/render seams. Panel never inserts into the arrange or
|
||||
// mutates the project. Header is REAPER-free; main.cpp drives it via these
|
||||
// free functions.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// Wires the panel into main.cpp's lifecycle. Called once after the API pointers
|
||||
// are loaded, BEFORE the toggle action is registered. `session` must outlive the
|
||||
// panel (it is the extension-lifetime g_session). Stores the session pointer the
|
||||
// panel reads on every repaint; does not create the window yet.
|
||||
// `session` must outlive the panel (extension-lifetime g_session). Call once
|
||||
// after API pointers load, before the toggle action registers.
|
||||
void bankPanelInit(ReaSamplerSession* session);
|
||||
|
||||
// Toggles the docked window: creates+docks it if hidden, hides+undocks it if
|
||||
// shown. Bound to the "toggle bank panel" action. Safe to call before the first
|
||||
// timer tick.
|
||||
// Creates+docks if hidden, hides+undocks if shown.
|
||||
void bankPanelToggle();
|
||||
|
||||
// Whether the panel window is currently open/visible. Feeds the action's
|
||||
// checked-state (toggleaction) so REAPER shows a tick next to the menu entry.
|
||||
// Feeds the toggle action's checked-state.
|
||||
bool bankPanelIsOpen();
|
||||
|
||||
// Requests an immediate repaint of the panel if it is open. A no-op when the panel
|
||||
// is closed (safe to call unconditionally). Called by the actions layer after a
|
||||
// mode change so the footer [Arrange|Design] toggle reflects the new mode without
|
||||
// requiring a hide/reshow.
|
||||
// No-op if closed. Called after a mode change so the footer reflects it without
|
||||
// a hide/reshow.
|
||||
void bankPanelInvalidate();
|
||||
|
||||
// Tears the panel down on extension unload: destroys the window and releases any
|
||||
// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened.
|
||||
// Mirror of bankPanelInit; safe if never opened.
|
||||
void bankPanelShutdown();
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
// ext_state_io.cpp — the ext-state ↔ JSON serialization half of the persist seam
|
||||
// (Q-W5 split of the former persist.cpp; see session.h for the TU map and
|
||||
// ext_state_io.h for the key contract): the session's save/load/assignment-request
|
||||
// bridge, plus the shared persist_detail helpers (active-project read, growing
|
||||
// ext-state read, GUID minting, bank-folder relocation) the sibling TUs call.
|
||||
// ext_state_io.cpp — the ext-state <-> JSON serialization half of the persist
|
||||
// seam (see session.h for the TU map, ext_state_io.h for the key contract):
|
||||
// the session's save/load/assignment-request bridge, plus the shared
|
||||
// persist_detail helpers the sibling TUs call.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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).
|
||||
//
|
||||
// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the
|
||||
// whole BankBook (pool as bank-zero + named banks) is written under key "banks"
|
||||
// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on
|
||||
// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre-
|
||||
// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so
|
||||
// the banks travel with the project automatically (CONTEXT.md §Persistence & paths).
|
||||
// The only thing that does NOT travel for free is the physical bank folder; on
|
||||
// Save-As to a new directory we relocate it so the indices' relative paths still
|
||||
// resolve (poll(), session.cpp, executes the relocation this TU implements).
|
||||
// Storage: SetProjExtState/GetProjExtState, namespace "reasampler". The whole
|
||||
// BankBook (pool as bank-zero + named banks) is written under key "banks"
|
||||
// (authoritative); the legacy single-bank key "bank_index" is retired —
|
||||
// cleared on save, read only once to migrate a pre-multi-bank project into
|
||||
// the pool. Ext state is stored inside the .rpp, so the banks travel with the
|
||||
// project automatically; the physical bank folder does not, so a Save-As to a
|
||||
// new directory relocates it (poll(), session.cpp).
|
||||
//
|
||||
// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY
|
||||
// our own reasampler_bank/ folder. It never touches the user's media, items, or
|
||||
// other ext-state namespaces.
|
||||
// Non-destructive: this module writes only our own ext-state keys and moves
|
||||
// only our own reasampler_bank/ folder.
|
||||
|
||||
#include "shell/persist/ext_state_io.h"
|
||||
|
||||
@@ -53,11 +49,9 @@ namespace reasampler::persist_detail {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// Read the active project pointer and its .rpp path in one shot. idx=-1 is the
|
||||
// current project tab (SDK header line ~1262). The out-buffer receives the full
|
||||
// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel —
|
||||
// same fact capture.cpp relies on). Returns nullptr proj only when there is no
|
||||
// active project at all.
|
||||
// idx=-1 is the current project tab. rppPathOut is empty for a never-saved
|
||||
// project (the reliable unsaved sentinel); returns nullptr only with no active
|
||||
// project at all.
|
||||
void* readActiveProject(std::string& rppPathOut) {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
ReaProject* proj = EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
@@ -65,24 +59,13 @@ void* readActiveProject(std::string& rppPathOut) {
|
||||
return proj;
|
||||
}
|
||||
|
||||
// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
|
||||
// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the
|
||||
// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp
|
||||
// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths
|
||||
// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both
|
||||
// artifacts share one implementation rather than duplicating the parent-of-.rpp step.
|
||||
// NOT GetProjectPathEx, which returns the recording path, not the .rpp's own
|
||||
// directory. Delegates to the pure projectDirOfRpp so both artifacts share one
|
||||
// implementation.
|
||||
std::string projectDirOf(const std::string& rppPath) {
|
||||
return capture::projectDirOfRpp(rppPath);
|
||||
}
|
||||
|
||||
// GetProjExtState needs a caller-supplied buffer; the index JSON can be large
|
||||
// (many samples). The grow-until-strict-fit retry policy is the SHARED pure
|
||||
// wire::readProjExtStateGrowing (T2-04 — the same policy the usage_scan and
|
||||
// VST-bridge reads run); this wrapper binds the REAPER call and
|
||||
// folds the terminal cases persist's callers expect: "" for an absent key (a valid
|
||||
// empty bank, not an error) and a console warning + "" for a value exceeding the
|
||||
// 16 MB ceiling, so an over-large value reads as "too large to load", not silent
|
||||
// data loss (mirrors the malformed-JSON warning in loadFromProject).
|
||||
std::string getProjExtStateString(void* proj, const char* ns, const char* key) {
|
||||
using wire::GrowingExtStateRead;
|
||||
const GrowingExtStateRead read = wire::readProjExtStateGrowing(
|
||||
@@ -103,8 +86,7 @@ std::string getProjExtStateString(void* proj, const char* ns, const char* key) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString.
|
||||
// guidToString wants a >=64-char destination (SDK header line ~3846).
|
||||
// guidToString wants a >=64-char destination.
|
||||
std::string genProjectGuidString() {
|
||||
GUID g{};
|
||||
genGuid(&g);
|
||||
@@ -113,13 +95,8 @@ std::string genProjectGuidString() {
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Ensure a SAVED project carries a stored GUID, minting and writing one if it
|
||||
// has none yet (a project saved before this feature shipped, or a brand-new
|
||||
// first save). Returns the effective GUID: the existing one, the freshly minted
|
||||
// one, or "" for an unsaved project (no .rpp to store ext state into — the same
|
||||
// gate SetProjExtState/saveToActiveProject already respect on empty path).
|
||||
// Called from BOTH prime and the Load branch so identity is established the same
|
||||
// way on every entry to a project (peer-symmetry: no path skips the mint).
|
||||
// Returns the existing GUID, a freshly minted one, or "" for an unsaved
|
||||
// project. Called from both prime and the Load branch so no path skips the mint.
|
||||
std::string ensureProjectGuid(void* proj, const std::string& rppPath,
|
||||
const std::string& currentGuid) {
|
||||
if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID
|
||||
@@ -130,11 +107,9 @@ std::string ensureProjectGuid(void* proj, const std::string& rppPath,
|
||||
return minted;
|
||||
}
|
||||
|
||||
// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not
|
||||
// move — see the handoff for the copy-vs-move rationale). Overwrites existing
|
||||
// files at the destination so a re-save is idempotent. Best-effort: filesystem
|
||||
// errors are swallowed and reported to the console rather than thrown across the
|
||||
// REAPER boundary. Returns true if the copy ran (source existed).
|
||||
// Copy, not move (non-destructive); overwrites existing files at the
|
||||
// destination so a re-save is idempotent. Best-effort: filesystem errors are
|
||||
// swallowed and reported to the console. Returns true if the copy ran.
|
||||
bool relocateBankFolder(const std::string& oldBankDir,
|
||||
const std::string& newBankDir) {
|
||||
std::error_code ec;
|
||||
@@ -168,58 +143,37 @@ bool ReaSamplerSession::saveToActiveProject() {
|
||||
if (!proj) return false; // no active project — nothing to persist
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
|
||||
// Phase B: the whole book (pool as bank-zero + named banks) is authoritative and
|
||||
// rides in the `banks` key.
|
||||
const std::string banksJson = book_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBanksKey, banksJson.c_str());
|
||||
|
||||
// Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty
|
||||
// value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This
|
||||
// realizes retirement concretely — after any save, a formerly-legacy project
|
||||
// carries `banks` and NO `bank_index`, and going forward the legacy key is never
|
||||
// written. Cheap and idempotent when the key is already absent.
|
||||
// Retire the legacy single-bank key: SetProjExtState with an empty value
|
||||
// deletes it. Idempotent when already absent.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtIndexKey, "");
|
||||
|
||||
// Additive: the Design-View model rides alongside the banks in its own key.
|
||||
// Independent write — does not disturb the `banks` blob above.
|
||||
// Each of the following rides in its own key, independent of `banks`.
|
||||
const std::string viewJson = view_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtViewKey, viewJson.c_str());
|
||||
|
||||
// Additive: the docked panel's tail setting rides alongside in its own key, so the
|
||||
// tail choice travels inside the .rpp. Independent write — does not disturb the
|
||||
// bank_index or view_state above.
|
||||
const std::string tailJson = capture::serializeTailSetting(tail_);
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtTailKey, tailJson.c_str());
|
||||
|
||||
// Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own
|
||||
// `owned_files` key. Independent write — does not disturb the blobs above. Written
|
||||
// on EVERY save so a capture's manifest record survives Save / Save-As / reopen,
|
||||
// and so the manifest and the bank stay in lockstep on disk (both persisted by the
|
||||
// same saveToActiveProject the capture add-path calls). Uses the channel-derived
|
||||
// namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too.
|
||||
// Written on every save so the manifest and the bank stay in lockstep on disk.
|
||||
const std::string ownedJson = owned_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtOwnedKey, ownedJson.c_str());
|
||||
|
||||
// Phase V (V1/V4): stamp the WRITING version — the build producing this save — under
|
||||
// the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty
|
||||
// stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is
|
||||
// the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as
|
||||
// Stamped on read-back and stays byte-identical to stable regardless of channel; the
|
||||
// channel is already carried by the isolated namespace (projExtNamespace) this writes to.
|
||||
// stampVersion() (not appVersion()) is the numeric triple only, no "-beta"
|
||||
// suffix, so the stamp is byte-identical to stable regardless of channel
|
||||
// — the channel is already carried by the isolated namespace.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtVersionKey, version::stampVersion().c_str());
|
||||
|
||||
// S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME
|
||||
// seam so the counter and MarkProjectDirty stay paired. The value is whatever
|
||||
// bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so
|
||||
// every content mutation's own save carries the fresh generation the instrument reads. The
|
||||
// format is the SHARED pure encoder (instrument::map::formatBankGeneration) so writer and reader agree
|
||||
// byte-for-byte — a decimal integer. Additive: does not disturb the blobs above.
|
||||
// Whatever bumpBankGeneration() advanced the counter to since the last
|
||||
// save (0 if never bumped). Shared encoder so writer/reader agree byte-for-byte.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBankGenKey,
|
||||
instrument::map::formatBankGeneration(bankGeneration_).c_str());
|
||||
@@ -234,11 +188,8 @@ bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
|
||||
if (!proj) return false; // no active project — nothing to signal
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
|
||||
// One-shot write of the ingest assignment request under its own key (S8). Independent
|
||||
// of the book/view/tail blobs — this is a transient signal to the instrument, not
|
||||
// session state that must ride every save. Uses the channel-derived namespace
|
||||
// (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta
|
||||
// instrument reads only a beta extension's assignment requests.
|
||||
// One-shot write under its own key: a transient signal to the instrument,
|
||||
// not session state that rides every save.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtAssignKey, wire.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
@@ -247,12 +198,8 @@ bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
|
||||
|
||||
namespace {
|
||||
|
||||
// Load the Design-View model from a project's view_state key, or return a fresh
|
||||
// default. An absent/empty key (older project with no view state) yields a
|
||||
// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful,
|
||||
// never a crash. Malformed JSON is warned and also falls back to default, mirroring
|
||||
// the bank's malformed-index handling. The whole model round-trips: modes,
|
||||
// membership, show-both, snapshots, and active mode all ride inside the one blob.
|
||||
// Absent/empty key -> default-constructed model, graceful, never a crash.
|
||||
// Malformed JSON is warned and also falls back to default.
|
||||
ViewModeModel loadViewModel(ReaProject* proj) {
|
||||
if (!proj) return ViewModeModel{};
|
||||
const std::string viewJson =
|
||||
@@ -266,10 +213,8 @@ ViewModeModel loadViewModel(ReaProject* proj) {
|
||||
return std::move(*loaded);
|
||||
}
|
||||
|
||||
// Load the tail setting from a project's tail_setting key, or return the default. An
|
||||
// absent/empty key (older / never-adjusted project) yields the default setting (None /
|
||||
// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back
|
||||
// to default, mirroring the bank's and view's malformed handling.
|
||||
// Absent/empty key -> default (None / 2 s manual). Malformed JSON warns and
|
||||
// falls back to default.
|
||||
capture::TailSetting loadTailSetting(ReaProject* proj) {
|
||||
if (!proj) return capture::TailSetting{};
|
||||
const std::string tailJson =
|
||||
@@ -284,12 +229,9 @@ capture::TailSetting loadTailSetting(ReaProject* proj) {
|
||||
return *loaded;
|
||||
}
|
||||
|
||||
// Load the owned-file manifest from a project's owned_files key, or return an empty
|
||||
// manifest. An absent/empty key (older / never-captured project) yields an empty
|
||||
// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to
|
||||
// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then
|
||||
// sees an empty ownership record and (safely) attributes nothing until the next capture
|
||||
// rebuilds it — losing the record degrades safety, never correctness.
|
||||
// Absent/empty key -> empty manifest. Malformed JSON warns and falls back to
|
||||
// empty; prune then attributes nothing until the next capture rebuilds it —
|
||||
// degrades safety, never correctness.
|
||||
model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
|
||||
if (!proj) return model::OwnedFileManifest{};
|
||||
const std::string ownedJson =
|
||||
@@ -307,45 +249,28 @@ model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
|
||||
// Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the
|
||||
// single choke point for every load path (prime, project switch/open, forked-
|
||||
// sibling load), so setting it here — and NOT on the Save-As branch, which keeps
|
||||
// the in-memory model as-is — makes the signal fire exactly when a fresh view
|
||||
// model has been installed and its active mode's visibility needs reapplying.
|
||||
// main.cpp drains it via consumeLoadSignal() on the same tick.
|
||||
// loadFromProject is the single choke point for every load path (prime,
|
||||
// project switch/open, forked-sibling load) — NOT the Save-As branch,
|
||||
// which keeps the in-memory model as-is. main.cpp drains this via
|
||||
// consumeLoadSignal() on the same tick.
|
||||
loadPending_ = true;
|
||||
|
||||
// The view model is restored on EVERY load path (peer-symmetry with the bank
|
||||
// reset below): switching to a project with no view state must clear stale
|
||||
// in-memory state, not inherit the previous project's. D3 restores MODEL STATE
|
||||
// only — no visibility/processing is applied here (that is D4).
|
||||
// view_/tail_/owned_ are all restored on EVERY load path: switching to a
|
||||
// project with no stored state must reset to default, never inherit the
|
||||
// previous project's. An undo/redo reload must re-read the restored
|
||||
// values so they match the rolled-back state.
|
||||
view_ = loadViewModel(static_cast<ReaProject*>(proj));
|
||||
|
||||
// The tail setting is restored on EVERY load path too (peer-symmetry): switching
|
||||
// to a project with no stored setting must fall back to the default, not inherit
|
||||
// the previous project's choice (this REPLACES the old session-carry behavior).
|
||||
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
|
||||
|
||||
// The owned-file manifest is restored on EVERY load path too (peer-symmetry with the
|
||||
// bank/view/tail resets): switching to a project with no stored manifest must reset
|
||||
// to empty, not inherit the previous project's ownership record; an undo/redo reload
|
||||
// (R-B) must re-read the restored manifest so it matches the rolled-back bank state.
|
||||
owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));
|
||||
|
||||
// Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry
|
||||
// with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed
|
||||
// one as Unknown — both silent, no console warning (a pre-versioning project is not
|
||||
// an error). getProjExtStateString returns "" for an absent key, which is exactly the
|
||||
// PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default.
|
||||
// An absent stamp classifies as PreVersioning, a malformed one as Unknown
|
||||
// — both silent. proj == nullptr -> "" -> default.
|
||||
writingVersion_ = version::classifyWritingVersion(
|
||||
proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtVersionKey)
|
||||
: std::string{});
|
||||
|
||||
// S9: recover the bank-generation counter on EVERY load path (peer-symmetry with
|
||||
// writingVersion_/tail_/view_ above), so it continues monotonic from the stored value
|
||||
// rather than resetting to 0 on reopen — a next bump then reads > the stored value. A
|
||||
// project switch reads THAT project's counter, not the previous one's; an absent/malformed
|
||||
// stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0.
|
||||
// Continues monotonic from the stored value rather than resetting to 0 on
|
||||
// reopen; absent/malformed parses to 0 via the shared decoder.
|
||||
bankGeneration_ = instrument::map::parseBankGeneration(
|
||||
proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtBankGenKey)
|
||||
: std::string{});
|
||||
@@ -355,14 +280,9 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
|
||||
return;
|
||||
}
|
||||
|
||||
// Read both possible sources: the authoritative `banks` blob and the retired-but-
|
||||
// possibly-still-present legacy `bank_index`. The precedence + migration decision
|
||||
// (`banks` wins; else the legacy index migrates into the pool; else an empty book)
|
||||
// is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only
|
||||
// so a malformed `banks` blob can be warned on the console (single parse) — a corrupt
|
||||
// blob must read as "ignored", not silent loss, mirroring the prior malformed-index
|
||||
// warning. A malformed `banks` degrades to an empty book and does NOT fall back to
|
||||
// the stale legacy key (which would resurrect superseded single-bank state).
|
||||
// `banks` is authoritative when present; a malformed blob degrades to an
|
||||
// empty book rather than falling back to the stale legacy key (which
|
||||
// would resurrect superseded single-bank state).
|
||||
const std::string banksJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtBanksKey);
|
||||
if (!banksJson.empty()) {
|
||||
@@ -374,29 +294,18 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
|
||||
book_ = std::move(*loaded);
|
||||
}
|
||||
} else {
|
||||
// No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool
|
||||
// by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or-
|
||||
// empty tail; passing "" for banksJson takes exactly that branch.
|
||||
// No `banks` yet — migrate the legacy `bank_index` into the pool.
|
||||
const std::string legacyJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtIndexKey);
|
||||
book_ = BankBook::loadFromPersisted(std::string{}, legacyJson);
|
||||
}
|
||||
|
||||
// L7 slot migration: seed every bank's display-position SlotMap from its index
|
||||
// insertion order when the loaded blob carried none (a pre-L7 project -> dense,
|
||||
// gap-free, visually identical on first post-L7 load), and reconcile a partial map
|
||||
// (drop stale markers, append unmapped samples) for a blob written by an earlier L7
|
||||
// build. One-way: once the book is re-saved the reconciled slot data is authoritative.
|
||||
// Idempotent, so a fresh empty book is a cheap no-op.
|
||||
// Seed each bank's display-position SlotMap from insertion order when the
|
||||
// loaded blob carried none, and reconcile a partial map. Idempotent.
|
||||
book_.reconcileSlots();
|
||||
|
||||
// Project-relative resolution is a READ-time concern: every BankModel in the book
|
||||
// stores only relative paths (invariant, enforced per-bank at add()), and consumers
|
||||
// (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via
|
||||
// resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to
|
||||
// absolute here — that would break the relative-only invariant and travel-with-.rpp.
|
||||
// projectDir is threaded through for those consumers; nothing to do at load time
|
||||
// beyond replacing the in-memory book.
|
||||
// Paths stay relative (read-time resolution is the consumers' job);
|
||||
// nothing to do here beyond replacing the in-memory book.
|
||||
(void)projectDir;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,40 @@
|
||||
#pragma once
|
||||
// ext_state_io — the ext-state ↔ JSON serialization half of the persist seam
|
||||
// (Q-W5 split of the former persist god-TU; session.h holds the ReaSamplerSession
|
||||
// lifecycle, prune_fs.cpp the prune scan + the single file-deletion authority).
|
||||
// ext_state_io — the ext-state <-> JSON serialization half of the persist seam.
|
||||
// This header owns the persist-side key spellings and the channel-derived
|
||||
// namespace accessor; the TU (ext_state_io.cpp) implements the session's
|
||||
// save/load/assignment-request bridge plus the GUID minting and bank-folder
|
||||
// relocation helpers the poll executes.
|
||||
// namespace accessor; ext_state_io.cpp implements the session's save/load/
|
||||
// assignment-request bridge plus GUID minting and bank-folder relocation.
|
||||
//
|
||||
// The ext-state namespace + the WIRE-SHARED key names are the contract between this
|
||||
// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h
|
||||
// (pure, REAPER-free) and are included here — not duplicated. The namespace is
|
||||
// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace()
|
||||
// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte-
|
||||
// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both
|
||||
// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace
|
||||
// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project
|
||||
// saved by stable shows empty/default state in beta and vice versa; that isolation is the
|
||||
// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug.
|
||||
// The per-key semantics persist relies on (spellings owned by ext_keys.h):
|
||||
// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks).
|
||||
// AUTHORITATIVE going forward; the VST reads this key to see the live bank.
|
||||
// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared
|
||||
// on save); READ once on load to migrate a legacy project into the pool.
|
||||
// * kProjExtViewKey : the Design-View ViewModeModel JSON.
|
||||
// * kProjExtTailKey : the docked panel's TailSetting JSON.
|
||||
// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll()
|
||||
// tells a Save-As from a recycled-pointer project switch by it).
|
||||
// All are FOREVER-STABLE once shipped: changing any strands every already-saved
|
||||
// project's stored state under that key.
|
||||
// The namespace + wire-shared key names are the contract with the VST3
|
||||
// instrument; they live in ext_keys.h (pure, REAPER-free), included here, not
|
||||
// duplicated. Channel-derived: "reasampler" on stable, "reasampler_beta" on
|
||||
// beta — a project saved by stable shows empty/default state in beta and vice
|
||||
// versa; that isolation is deliberate.
|
||||
//
|
||||
// Per-key semantics (spellings owned by ext_keys.h): kProjExtBanksKey is the
|
||||
// whole serialized BankBook, authoritative; kProjExtIndexKey is the retired
|
||||
// legacy single-bank key (read once to migrate); kProjExtViewKey/TailKey are
|
||||
// the Design-View and tail JSON; kProjExtGuidKey is the per-project minted
|
||||
// GUID poll() uses to tell Save-As from a recycled-pointer switch. All are
|
||||
// FOREVER-STABLE once shipped.
|
||||
|
||||
#include "core/version/app_version.h"
|
||||
#include "ext_keys.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this
|
||||
// is the const char* the SetProjExtState/GetProjExtState calls pass. Kept as an
|
||||
// accessor (not a literal) because the string is channel-derived at build time.
|
||||
// Accessor, not a literal, because the string is channel-derived at build time.
|
||||
inline const char* projExtNamespace() { return version::extStateNamespace().c_str(); }
|
||||
|
||||
// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument
|
||||
// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h:
|
||||
//
|
||||
// owned_files — the owned-file manifest JSON (project-relative files the capture path
|
||||
// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's
|
||||
// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT
|
||||
// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it
|
||||
// strands every saved project's ownership record (prune falls back to an empty manifest —
|
||||
// graceful, but the attribution safety net is lost until the next capture rebuilds it).
|
||||
// Extension-only keys — not part of the VST wire contract, so they live here
|
||||
// rather than in ext_keys.h.
|
||||
|
||||
// Project-relative files the capture path itself created, consumed by prune
|
||||
// to tell the bank system's own orphans from hand-dropped files. A sibling
|
||||
// key, not folded into `banks`. FOREVER-STABLE.
|
||||
inline constexpr const char* kProjExtOwnedKey = "owned_files";
|
||||
|
||||
// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on
|
||||
// every save, so every saved .rpp records which build produced its state — the seam a
|
||||
// future within-channel forward migration keys off. An absent key is the explicit
|
||||
// pre-versioning case, read silently, never an error. FOREVER-STABLE key string.
|
||||
// The ReaSampler version that last wrote this project. An absent key is the
|
||||
// pre-versioning case, read silently. FOREVER-STABLE.
|
||||
inline constexpr const char* kProjExtVersionKey = "version";
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// persist_internal.h — INTERNAL shared helpers for the persist TU family (Q-W5:
|
||||
// session / ext_state_io / prune_fs, split out of the former persist.cpp god-TU).
|
||||
// Included ONLY by those three TUs — never a public seam (mirror of the panel's
|
||||
// panel_state.h / the editor's editor_internal.h internal-seam precedent). Holds the
|
||||
// former anonymous-namespace helpers that more than one split TU needs; every
|
||||
// definition lives in ext_state_io.cpp (they are all ext-state / GUID / path / folder
|
||||
// machinery). Behavior-identical to the pre-split definitions.
|
||||
// persist_internal.h — internal shared helpers for the persist TU family
|
||||
// (session / ext_state_io / prune_fs). Included only by those three TUs, never
|
||||
// a public seam. Every definition lives in ext_state_io.cpp.
|
||||
//
|
||||
// REAPER-FREE HEADER: the project handle crosses this seam as the same opaque void*
|
||||
// the public session header already uses, so no SDK type leaks; the .cpps cast at
|
||||
// the API boundary.
|
||||
// REAPER-free header: the project handle crosses this seam as the same opaque
|
||||
// void* the public session header uses, so no SDK type leaks.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -29,8 +24,7 @@ std::string projectDirOf(const std::string& rppPath);
|
||||
// Growing GetProjExtState read for `key` in namespace `ns` against `proj`. Returns
|
||||
// "" when the key is absent (a valid empty bank, not an error) and warns on the
|
||||
// console for a value exceeding the 16 MB read ceiling (unreadable whole, ignored).
|
||||
// The retry policy itself is the shared pure wire::readProjExtStateGrowing
|
||||
// (T2-04; rehomed to core/wire in Q-W6); this wrapper binds the REAPER call + persist's fold.
|
||||
// Binds wire::readProjExtStateGrowing (the shared retry policy) to the REAPER call.
|
||||
std::string getProjExtStateString(void* proj, const char* ns, const char* key);
|
||||
|
||||
// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString.
|
||||
|
||||
+89
-128
@@ -1,21 +1,17 @@
|
||||
// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler
|
||||
// (Q-W5 split of the former persist.cpp; see session.h for the TU map).
|
||||
// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler.
|
||||
//
|
||||
// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove on
|
||||
// SWELL platforms) is the ONLY code in the system that deletes USER files — the sole
|
||||
// deletion authority over the bank folder's bytes (the R3 prune; shells removing a
|
||||
// transient scratch file they themselves just created, e.g. the drop path's temp
|
||||
// .vstpreset, are self-cleanup, not authority over user data). It is deliberately
|
||||
// file-local (anonymous namespace): nothing outside this TU can reach it. The Q-W5
|
||||
// split CONCENTRATES the deletion authority here — it must never
|
||||
// spread (CONTEXT.md §Phase Q deletion-authority isolation;
|
||||
// docs/product/code-organization.md §7). The safety-critical "which files are
|
||||
// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove
|
||||
// on SWELL platforms) is the ONLY code in the system that deletes USER files —
|
||||
// the sole deletion authority over the bank folder's bytes (a shell removing a
|
||||
// transient scratch file it just created, e.g. the drop path's temp
|
||||
// .vstpreset, is self-cleanup, not authority over user data). Deliberately
|
||||
// file-local (anonymous namespace): nothing outside this TU can reach it, and
|
||||
// this concentration must never spread. The safety-critical "which files are
|
||||
// orphans" decision stays in the pure core (prune_reconcile); this TU only
|
||||
// enumerates, resolves, stats, and — after the R3 confirm — executes.
|
||||
// enumerates, resolves, stats, and — after the confirm — executes.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. REAPER-facing only through the
|
||||
// persist_detail helpers (active-project read) and usage_scan (the pS-usage
|
||||
// instance-hold reads); this TU itself calls no REAPER API directly.
|
||||
// Compiled into the reaper_reasampler module. REAPER-facing only through the
|
||||
// persist_detail helpers and usage_scan; this TU itself calls no REAPER API directly.
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
@@ -25,12 +21,11 @@
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is
|
||||
// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK
|
||||
// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... },
|
||||
// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL
|
||||
// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the
|
||||
// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing.
|
||||
// Move-to-trash surface, trash-preferred. Windows reaches the Recycle Bin via
|
||||
// SHFileOperationW + FOF_ALLOWUNDO (verified against shellapi.h: SHFILEOPSTRUCTW
|
||||
// { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, FO_DELETE=0x3,
|
||||
// FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on SWELL (macOS/Linux),
|
||||
// so those platforms fall back to unlink — see deleteOrphanFile below.
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
@@ -38,7 +33,7 @@
|
||||
|
||||
#include "shell/persist/persist_internal.h"
|
||||
#include "shell/persist/session.h"
|
||||
#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`)
|
||||
#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths — instance holds join `referenced`
|
||||
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder
|
||||
#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies
|
||||
@@ -52,31 +47,24 @@ namespace fs = std::filesystem;
|
||||
using persist_detail::projectDirOf;
|
||||
using persist_detail::readActiveProject;
|
||||
|
||||
// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always
|
||||
// exact (tallied over the full orphan set), but the enumerated file list handed to the
|
||||
// console is clipped to this many entries so a project with thousands of orphans does
|
||||
// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can
|
||||
// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling.
|
||||
// The dry-run file-list display cap: count and size are always exact
|
||||
// (tallied over the full orphan set), but the enumerated list handed to the
|
||||
// console is clipped so a project with thousands of orphans does not flood
|
||||
// the report. PruneReport::truncated flags the clip.
|
||||
constexpr std::size_t kPruneListDisplayCap = 64;
|
||||
|
||||
// A fresh enumerate + pure-core prune compute for the active project. Shared by the
|
||||
// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion
|
||||
// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path
|
||||
// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the
|
||||
// active project, enumerates the folder) but writes nothing.
|
||||
// A fresh enumerate + pure-core prune compute for the active project. Shared
|
||||
// by the dry-run report, the full-set query, and the deletion so all three
|
||||
// agree on one resolution + enumeration + set-algebra path — no divergence
|
||||
// between what is shown and what is deleted. REAPER-facing but writes nothing.
|
||||
//
|
||||
// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty
|
||||
// when there is no active/saved project, no project dir, or no folder on
|
||||
// disk yet -> the caller treats an empty dir as "nothing to reclaim".
|
||||
// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration
|
||||
// order, untruncated. The pure core decides; this only supplies inputs.
|
||||
// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
|
||||
// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could
|
||||
// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY —
|
||||
// the prune must halt rather than proceed with degraded protection.
|
||||
// An empty orphan set is itself the delete-side guarantee (every
|
||||
// consumer of this scan deletes at most `orphans ∩ ...`), the flag is
|
||||
// what lets the action TELL the user instead of claiming "no orphans".
|
||||
// * bankDirAbs — the resolved current bank folder. Empty when there is no
|
||||
// active/saved project, no project dir, or no folder yet.
|
||||
// * orphans — the full orphan set, untruncated. The pure core decides.
|
||||
// * sizeByRel — per-orphan on-disk byte size (0 when it could not be stat'd).
|
||||
// * abortedUnreadableUsage — true iff a present rsusage_* record could not
|
||||
// be read/decoded: `orphans` is left EMPTY, the prune must
|
||||
// halt rather than proceed with degraded protection.
|
||||
struct PruneScan {
|
||||
std::string bankDirAbs;
|
||||
std::vector<std::string> orphans;
|
||||
@@ -95,10 +83,8 @@ PruneScan scanPruneOrphans(const BankBook& book,
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan
|
||||
|
||||
// Resolve the CURRENT bank folder the same way the index does (M4): project dir of
|
||||
// the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a
|
||||
// Save-As relocation is followed automatically. resolveBankFile is the shared M4
|
||||
// arithmetic; feeding it the bank subfolder as the "relative path" yields the folder.
|
||||
// Resolve the current bank folder the same way the index does — never a
|
||||
// stored absolute path, so a Save-As relocation is followed automatically.
|
||||
const std::string projectDir = projectDirOf(rppPath);
|
||||
const std::string bankDir =
|
||||
capture::resolveBankFile(projectDir, capture::kBankSubfolder);
|
||||
@@ -109,15 +95,11 @@ PruneScan scanPruneOrphans(const BankBook& book,
|
||||
return scan; // no bank folder captured yet -> nothing to reclaim
|
||||
}
|
||||
|
||||
// Enumerate the folder into project-relative index-spelled paths, spelled the SAME
|
||||
// way the capture path spelled them (bankRelativeForName == deriveBankPaths's
|
||||
// convention) so the pure core's exact-string match lines up with referencedPaths()
|
||||
// and the manifest. Non-recursive: the bank folder is flat (capture writes files
|
||||
// directly here); skip any subdirectory. Size is stat'd here and cached by relative
|
||||
// path so the report's byte tally reuses the same on-disk read.
|
||||
// Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration
|
||||
// failure (file removed, permission flip) breaks out with a best-effort partial list
|
||||
// rather than propagating std::filesystem_error across REAPER's C ABI.
|
||||
// Enumerate into project-relative paths spelled the SAME way the capture
|
||||
// path spells them, so the pure core's exact-string match lines up with
|
||||
// referencedPaths() and the manifest. Non-recursive: the bank folder is
|
||||
// flat. Manual iterator form (it.increment(ec)) keeps the loop
|
||||
// non-throwing on a mid-iteration failure.
|
||||
std::vector<std::string> present;
|
||||
fs::directory_iterator it(bankDir, ec);
|
||||
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
|
||||
@@ -133,24 +115,20 @@ PruneScan scanPruneOrphans(const BankBook& book,
|
||||
scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
|
||||
}
|
||||
|
||||
// The decision lives in the pure core — read-only inputs from the book and manifest.
|
||||
// referencedPaths() unions across the whole book (pool included); owned().paths() is
|
||||
// the manifest set. pS-usage: the referenced set additionally unions every LIVE
|
||||
// ReaSampler 9000 instance's held captures (usage_scan reads the per-instance
|
||||
// rsusage_* records + the live FX enumeration; sample_usage decides liveness,
|
||||
// including the protect-all net when zero instances were identified) — a capture
|
||||
// any live instance holds can NEVER be an orphan, even when its bank entry was
|
||||
// deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY,
|
||||
// preserving this scan's no-write contract. This shell only enumerates, resolves,
|
||||
// and stats.
|
||||
// The decision lives in the pure core — read-only inputs from the book and
|
||||
// manifest. referencedPaths() unions across the whole book; the referenced
|
||||
// set additionally unions every LIVE ReaSampler 9000 instance's held
|
||||
// captures (usage_scan + sample_usage decide liveness) — a capture any
|
||||
// live instance holds can never be an orphan, even if its bank entry was
|
||||
// deleted while the instance kept its ref. liveInstanceHeldPaths is
|
||||
// read-only; this shell only enumerates, resolves, and stats.
|
||||
scan.bankDirAbs = bankDir;
|
||||
const UsageScanResult usage = liveInstanceHeldPaths(proj);
|
||||
if (usage.abortPrune) {
|
||||
// FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the
|
||||
// protected set is unknowable. Compute NO orphans — every downstream consumer
|
||||
// (dry-run report, confirm set, fresh-recompute delete plan) then deletes
|
||||
// nothing. The flag + key names surface the reason so the action can name each
|
||||
// offending key for operator recovery.
|
||||
// FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded,
|
||||
// so the protected set is unknowable. Compute NO orphans — every
|
||||
// downstream consumer then deletes nothing. The key names let the
|
||||
// action tell the user which keys to recover.
|
||||
scan.abortedUnreadableUsage = true;
|
||||
scan.offendingUsageKeys = usage.offendingKeys;
|
||||
return scan;
|
||||
@@ -162,38 +140,31 @@ PruneScan scanPruneOrphans(const BankBook& book,
|
||||
return scan;
|
||||
}
|
||||
|
||||
// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the
|
||||
// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases:
|
||||
// * `outAlreadyAbsent` set true — the file was already gone before we touched it;
|
||||
// the caller folds this into the stale/staleness tally, NOT reclaimedCount.
|
||||
// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error);
|
||||
// the caller folds this into skippedCount.
|
||||
// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception
|
||||
// may cross the C ABI.
|
||||
// Deletes ONE orphan file, trash-preferred. Returns true iff deleted by this
|
||||
// call. Returns false with `outAlreadyAbsent` set when the file was already
|
||||
// gone (caller folds into staleness, not reclaimedCount); false with it unset
|
||||
// on a real delete failure (locked, conversion error — folds into
|
||||
// skippedCount). `absPath` is the resolved absolute path. Non-throwing: no
|
||||
// exception may cross the C ABI.
|
||||
//
|
||||
// Per-platform routing:
|
||||
// * Windows — SHFileOperationW(FO_DELETE, pFrom=<double-NUL path>, FOF_ALLOWUNDO |
|
||||
// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the
|
||||
// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our
|
||||
// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true.
|
||||
// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this
|
||||
// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3
|
||||
// confirm guardrail. `outUsedTrash` left as-is (false).
|
||||
// Windows routes through SHFileOperationW + FOF_ALLOWUNDO (Recycle Bin,
|
||||
// recoverable); the no-UI flags suppress REAPER-blocking dialogs since our own
|
||||
// confirm already happened. Other platforms (SWELL: macOS/Linux) have no
|
||||
// portable move-to-trash surface, so they fall back to std::filesystem::remove
|
||||
// (hard unlink) behind the confirm guardrail.
|
||||
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
|
||||
bool& outAlreadyAbsent) {
|
||||
#ifdef _WIN32
|
||||
// Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string.
|
||||
// SHFileOperation's pFrom is a list; a single path still needs the extra terminating
|
||||
// NUL. Backslashes are required (shell APIs reject forward slashes in some cases).
|
||||
// Back-slashed, double-NUL-terminated wide string: SHFileOperation's
|
||||
// pFrom is a list (needs the extra terminating NUL) and rejects forward
|
||||
// slashes in some cases.
|
||||
std::string win = absPath;
|
||||
for (char& c : win) if (c == '/') c = '\\';
|
||||
|
||||
const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0);
|
||||
if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false)
|
||||
if (wlen <= 0) return false; // conversion failed -> real skip
|
||||
std::vector<wchar_t> wbuf(static_cast<std::size_t>(wlen) + 1, L'\0'); // +1 for list NUL
|
||||
MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen);
|
||||
// wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen]
|
||||
// makes it the double-NUL-terminated single-element list SHFileOperation wants.
|
||||
|
||||
SHFILEOPSTRUCTW op{};
|
||||
op.hwnd = nullptr;
|
||||
@@ -207,22 +178,20 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
|
||||
outUsedTrash = true;
|
||||
return true; // deleted this call -> reclaimed
|
||||
}
|
||||
// SHFileOperation failed (e.g. file already gone yields a nonzero code on some
|
||||
// versions, or a lock). Distinguish "already absent" from a real failure so the
|
||||
// caller can tally them separately (absent -> staleness skip; failure -> locked skip).
|
||||
// Distinguish "already absent" (nonzero return on some REAPER versions
|
||||
// for a vanished file) from a real failure so the caller can tally separately.
|
||||
std::error_code ec;
|
||||
if (!fs::exists(absPath, ec)) {
|
||||
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
|
||||
outAlreadyAbsent = true;
|
||||
}
|
||||
return false;
|
||||
#else
|
||||
// No portable trash surface on SWELL platforms -> hard unlink behind the confirm.
|
||||
// No portable trash surface on SWELL platforms -> hard unlink.
|
||||
std::error_code ec;
|
||||
const bool removed = fs::remove(absPath, ec);
|
||||
if (removed) return true; // deleted this call -> reclaimed
|
||||
if (ec) return false; // a real failure (locked / permission) -> skip
|
||||
// remove returned false with no error == the file did not exist -> already gone.
|
||||
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
|
||||
if (removed) return true;
|
||||
if (ec) return false; // real failure (locked/permission) -> skip
|
||||
outAlreadyAbsent = true; // no error, no removal -> already gone
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
@@ -231,53 +200,47 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
|
||||
|
||||
reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||
// buildPruneReport tallies count / byte-sum / display-truncation — no report logic
|
||||
// re-implemented here. An empty scan (no project / no folder) yields a zero report.
|
||||
reclaim::PruneReport report =
|
||||
reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
|
||||
// pS-usage fail-safe: surface the unreadable-record abort so the action halts with
|
||||
// an explicit message instead of reporting "no orphaned files" (the count IS zero —
|
||||
// the scan computed nothing — but the user must know the prune refused to run).
|
||||
// The offending key names propagate so the action can name each one for recovery.
|
||||
// Surface the unreadable-usage abort so the action halts with an explicit
|
||||
// message instead of reporting "no orphaned files" — the count IS zero,
|
||||
// but the user must know the prune refused to run.
|
||||
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
|
||||
report.offendingUsageKeys = scan.offendingUsageKeys;
|
||||
return report;
|
||||
}
|
||||
|
||||
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
|
||||
return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated
|
||||
return scanPruneOrphans(book_, owned_).orphans; // full set, untruncated
|
||||
}
|
||||
|
||||
reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
|
||||
const std::vector<std::string>& confirmed) const {
|
||||
reclaim::PruneDeletionResult result;
|
||||
|
||||
// Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets
|
||||
// exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became
|
||||
// referenced between confirm and delete drops out of freshOrphans and is skipped; a
|
||||
// newly-appeared orphan not in `confirmed` is never swept without its own confirm.
|
||||
// Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
|
||||
// and NO hand-dropped file — the R-C/R-D safety survives the recompute.
|
||||
// pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an
|
||||
// EMPTY orphan set, so the plan below intersects to empty and nothing is deleted —
|
||||
// the fail-safe holds even in the confirm→delete window, with no extra branch here.
|
||||
// Re-enumerate + run the pure core FRESH (never a stale set): deletion
|
||||
// targets exactly `confirmed ∩ freshOrphans`, so a file that vanished or
|
||||
// became referenced between confirm and delete is skipped, and a newly-
|
||||
// appeared orphan not in `confirmed` is never swept. If this fresh scan
|
||||
// hits an unreadable usage record it aborts with an EMPTY orphan set, so
|
||||
// the plan below intersects to empty and nothing is deleted — the
|
||||
// fail-safe holds even in the confirm-to-delete window.
|
||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
|
||||
|
||||
const std::vector<std::string> plan =
|
||||
reclaim::pruneDeletePlan(confirmed, scan.orphans);
|
||||
|
||||
// Staleness skip count: entries the user confirmed that are no longer fresh orphans
|
||||
// (vanished or became referenced between confirm and delete). pruneDeletePlan already
|
||||
// de-dups confirmed internally, so compute the unique-confirmed size to avoid counting
|
||||
// de-duplicated entries as stale — that would be dishonest.
|
||||
// Staleness skip count: confirmed entries no longer fresh orphans.
|
||||
// pruneDeletePlan de-dups confirmed internally, so compare against the
|
||||
// unique-confirmed size to avoid counting de-duped entries as stale.
|
||||
const std::size_t uniqueConfirmedCount =
|
||||
std::unordered_set<std::string>(confirmed.begin(), confirmed.end()).size();
|
||||
result.skippedCount += uniqueConfirmedCount - plan.size();
|
||||
|
||||
for (const std::string& rel : plan) {
|
||||
// Reconstruct the absolute path from the resolved bank dir + the entry's file name.
|
||||
// rel is index-spelled "<kBankSubfolder>/<name>"; the name is the tail after '/'.
|
||||
// rel is index-spelled "<kBankSubfolder>/<name>"; reconstruct the
|
||||
// absolute path from the resolved bank dir + the tail after '/'.
|
||||
const std::string::size_type slash = rel.find_last_of('/');
|
||||
const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1);
|
||||
if (name.empty()) { ++result.skippedCount; continue; }
|
||||
@@ -291,9 +254,7 @@ reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
|
||||
++result.reclaimedCount;
|
||||
result.reclaimedBytes += bytes;
|
||||
} else if (alreadyAbsent) {
|
||||
// File vanished between plan and delete — treat as staleness, same as the
|
||||
// confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it).
|
||||
++result.skippedCount;
|
||||
++result.skippedCount; // vanished between plan and delete -> staleness
|
||||
} else {
|
||||
++result.skippedCount; // locked / conversion failure -> recorded, not thrown
|
||||
}
|
||||
|
||||
@@ -1,65 +1,25 @@
|
||||
// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (Q-W5
|
||||
// split of the former persist.cpp; see session.h for the TU map): the poll-driven
|
||||
// identity-transition detection and the deferred undo/redo reload drain.
|
||||
// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (see
|
||||
// session.h for the identity-transition design and the TU map).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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).
|
||||
//
|
||||
// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism):
|
||||
// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active
|
||||
// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext
|
||||
// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the
|
||||
// secondary disambiguator (classifyProjectTransition owns the exact order):
|
||||
// * different stored GUID -> a different project of record -> LOAD its index;
|
||||
// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's
|
||||
// address, so a reopened/new project can present the previous pointer with a
|
||||
// different GUID), new/unsaved<->saved, and switching between distinct saved
|
||||
// projects.
|
||||
// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via
|
||||
// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings
|
||||
// diverge going forward.
|
||||
// * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new
|
||||
// location -> relocate the bank folder from the old dir to the new one, then
|
||||
// re-GUID.
|
||||
// Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only)
|
||||
// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a
|
||||
// fork and its parent share a GUID on disk; switching between them read as a
|
||||
// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer
|
||||
// RECYCLING — a reopened/new project reusing the previous project's address read
|
||||
// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first
|
||||
// catches recycling; the pointer then separates a fork (same GUID, different
|
||||
// object -> Load) from a Save-As (same GUID, same object, new path -> relocate).
|
||||
// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject`
|
||||
// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free
|
||||
// and testable; poll() executes the verdict.
|
||||
// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject`
|
||||
// bool so the decision stays REAPER-free and testable; poll() executes the
|
||||
// verdict. REAPER exposes no stable per-project GUID, so we mint one (genGuid/
|
||||
// guidToString) under kProjExtGuidKey; Save-As copies the whole .rpp including
|
||||
// our ext state, so the new project initially shares the old GUID, and poll()
|
||||
// re-GUIDs it after relocating (or on the forked-sibling Load branch).
|
||||
//
|
||||
// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no
|
||||
// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not
|
||||
// a cross-open identity), so we MINT one with genGuid/guidToString and store it
|
||||
// under kProjExtGuidKey (ext_state_io.cpp owns the minting helpers). On Save-As
|
||||
// REAPER copies the whole .rpp incl. our ext state, so the new project initially
|
||||
// shares the old GUID; poll() re-GUIDs it (after relocating, or on the forked-
|
||||
// sibling Load branch) so identities diverge.
|
||||
//
|
||||
// Rationale for the timer: the brief mandates ext-state storage (rules out the
|
||||
// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with
|
||||
// ext-state while covering identity-transition load + Save-As detection in one
|
||||
// place.
|
||||
//
|
||||
// DIVISION OF LABOUR (R-B undo):
|
||||
// * Identity-transition poll (this file, classifyProjectTransition) owns
|
||||
// open / tab-switch / new / forked-sibling / Save-As-relocation — every case
|
||||
// where the project OF RECORD changes.
|
||||
// * The `projectconfig` hook (main.cpp registers project_config_extension_t;
|
||||
// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project
|
||||
// identity is unchanged but its ext state rolled back/forward on disk. The
|
||||
// identity poll sees NoOp there and would never re-read ext state, so the hook
|
||||
// requests a reload (requestReload) that poll() drains on the next tick, once
|
||||
// REAPER has restored the <EXTSTATE> block. See requestReload / the poll drain.
|
||||
// The hook fires on undo AND redo (isUndo true for both), and on normal open
|
||||
// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open
|
||||
// flows solely through the identity-transition Load path and never double-loads.
|
||||
// Division of labour for undo/redo: the identity-transition poll (this file)
|
||||
// owns open/tab-switch/new/forked-sibling/Save-As. The `projectconfig` hook
|
||||
// (main.cpp, BeginLoadProjectState with isUndo) owns undo/redo, where identity
|
||||
// is unchanged but ext state rolled back/forward on disk — the identity poll
|
||||
// would see NoOp there, so the hook requests a reload that poll() drains next
|
||||
// tick, once REAPER has restored the <EXTSTATE> block. The hook fires on
|
||||
// undo, redo, AND normal open, but the reload flag is set only for isUndo, so
|
||||
// a normal open never double-loads.
|
||||
|
||||
#include "shell/persist/session.h"
|
||||
|
||||
@@ -91,9 +51,8 @@ bool ReaSamplerSession::consumeLoadSignal() {
|
||||
}
|
||||
|
||||
void ReaSamplerSession::requestReload() {
|
||||
// Set-only; poll() drains it on the next tick (see the poll() drain block for why
|
||||
// the read is deferred past the projectconfig callback). Cheap and idempotent —
|
||||
// multiple undo/redo callbacks before the next tick collapse to one reload.
|
||||
// Set-only; poll() drains it next tick. Idempotent — multiple undo/redo
|
||||
// callbacks before the next tick collapse to one reload.
|
||||
reloadRequested_ = true;
|
||||
}
|
||||
|
||||
@@ -116,19 +75,13 @@ void ReaSamplerSession::poll() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier
|
||||
// below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID,
|
||||
// and .rpp path — so classifyProjectTransition would return NoOp and never re-read
|
||||
// ext state, leaving book_/view_ stale after the on-disk ext state rolled back.
|
||||
// The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_
|
||||
// one or more ticks ago; by NOW REAPER has finished restoring the project's
|
||||
// <EXTSTATE> block, so GetProjExtState returns the POST-undo value. Reload from the
|
||||
// current active project and identity-adopt it (no relocation — the path is
|
||||
// unchanged), then return. loadFromProject raises loadPending_, so the existing
|
||||
// consumeLoadSignal() glue re-baselines the panel detector and reapplies the active
|
||||
// mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is
|
||||
// the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect
|
||||
// an undo (Daniel's directive: the hook drives it, not a poll heuristic).
|
||||
// Undo/redo reload, owned by the projectconfig hook, not the identity
|
||||
// classifier below: an undo/redo keeps the same project identity, so
|
||||
// classifyProjectTransition would return NoOp and never re-read ext
|
||||
// state. By now REAPER has finished restoring the <EXTSTATE> block, so
|
||||
// GetProjExtState returns the post-undo value. Reload and identity-adopt
|
||||
// (no relocation — path unchanged). This is the ONLY undo/redo reload
|
||||
// path — the timer never polls ext-state content to detect an undo.
|
||||
if (reloadRequested_) {
|
||||
reloadRequested_ = false;
|
||||
loadFromProject(proj, projectDirOf(rppPath));
|
||||
@@ -150,20 +103,14 @@ void ReaSamplerSession::poll() {
|
||||
return;
|
||||
|
||||
case capture::ProjectTransition::Load: {
|
||||
// A different project of record is active (open / tab switch / new /
|
||||
// reopened / recycled pointer / forked sibling). Load ITS index; never
|
||||
// relocate.
|
||||
// A different project of record is active. Load its index; never relocate.
|
||||
//
|
||||
// Forked-sibling divergence: gate on `!sameProjectObject` so this fires
|
||||
// ONLY for a step-2 Load (same GUID, different object) — a Save-As fork
|
||||
// that copied our GUID and never re-saved (its fresh GUID was runtime-
|
||||
// only on the sibling we came from). A recycled-pointer Load (step 1:
|
||||
// currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct
|
||||
// identity. currentGuid == lastGuid_ can only hold here when step 1 did
|
||||
// NOT fire, i.e. this is the fork case; the explicit !sameProjectObject
|
||||
// makes that intent load-bearing rather than incidental. Do this BEFORE
|
||||
// loadFromProject reads the index (order is irrelevant — GUID and
|
||||
// bank_index are distinct keys — but self-contained is clearest).
|
||||
// Forked-sibling re-GUID: gate on `!sameProjectObject` so this fires
|
||||
// only for the fork case (same GUID, different object) — a Save-As
|
||||
// fork that copied our GUID and never re-saved. A recycled-pointer
|
||||
// Load (currentGuid != lastGuid_) must NOT re-GUID — it is already
|
||||
// a distinct identity; currentGuid == lastGuid_ can only hold here
|
||||
// when that case did not fire.
|
||||
if (proj && !sameProjectObject && !currentGuid.empty() &&
|
||||
currentGuid == lastGuid_ && !rppPath.empty()) {
|
||||
const std::string fresh = genProjectGuidString();
|
||||
@@ -186,12 +133,10 @@ void ReaSamplerSession::poll() {
|
||||
}
|
||||
|
||||
case capture::ProjectTransition::SaveAsRelocate: {
|
||||
// SAME project object + new .rpp path: a genuine Save-As (the pointer
|
||||
// proves it — a fork tab-switch is a DIFFERENT object and took the Load
|
||||
// branch above). Relocate the bank folder from the old dir to the new
|
||||
// one so the wavs sit under the new .rpp and the index's relative paths
|
||||
// still resolve. Keep the in-memory bank as-is (Save-As copied our ext
|
||||
// state, the relative paths are unchanged) — do NOT reload.
|
||||
// Same project object, new .rpp path: a genuine Save-As. Relocate
|
||||
// the bank folder so the wavs sit under the new .rpp and the
|
||||
// index's relative paths still resolve. Keep the in-memory bank
|
||||
// as-is (Save-As copied our ext state) — do NOT reload.
|
||||
const std::string oldDir = projectDirOf(lastRppPath_);
|
||||
const std::string newDir = projectDirOf(rppPath);
|
||||
const capture::BankRelocation plan =
|
||||
@@ -200,11 +145,9 @@ void ReaSamplerSession::poll() {
|
||||
relocateBankFolder(plan.oldBankDir, plan.newBankDir);
|
||||
}
|
||||
|
||||
// Save-As duplicated our ext state, so the new project B currently
|
||||
// shares A's GUID. Mint a FRESH GUID for B and write it, so A and B
|
||||
// no longer collide on identity when reopened later. Adopt the fresh
|
||||
// GUID as our last-seen identity. Mark dirty so the fresh GUID flushes
|
||||
// to the new .rpp on the next normal save / close-prompt.
|
||||
// Save-As duplicated our ext state, so the new project shares the
|
||||
// old GUID; mint a fresh one and mark dirty so it flushes on the
|
||||
// next save, and A/B no longer collide on identity when reopened.
|
||||
const std::string fresh = genProjectGuidString();
|
||||
if (proj) {
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
|
||||
+92
-240
@@ -1,36 +1,23 @@
|
||||
#pragma once
|
||||
// session — the ReaSamplerSession lifecycle owner of the persist seam (Q-W5 split of
|
||||
// the former persist god-TU; CLAUDE.md §load-bearing split; CONTEXT.md §Persistence &
|
||||
// paths). One class, three implementation TUs by responsibility:
|
||||
// session — the ReaSamplerSession lifecycle owner of the persist seam. One
|
||||
// class, three implementation TUs by responsibility:
|
||||
// * session.cpp — poll() identity-transition detection (load / Save-As /
|
||||
// forked sibling / recycled pointer) + the deferred undo/redo reload drain.
|
||||
// * ext_state_io.cpp — save/load/writeAssignmentRequest: the ext-state <->
|
||||
// JSON bridge, GUID minting, bank-folder relocation (see ext_state_io.h).
|
||||
// * prune_fs.cpp — pruneDryRun/pruneOrphanSet/pruneReclaim: the prune
|
||||
// scan and THE SINGLE FILE-DELETION AUTHORITY over user files in the bank
|
||||
// folder. Nothing else in the system deletes bank-folder bytes (a shell's
|
||||
// self-cleanup of its own transient scratch file is not this authority).
|
||||
//
|
||||
// * session.cpp — poll() (identity-transition detection: load / Save-As /
|
||||
// forked sibling / recycled pointer) + the deferred undo/redo reload drain
|
||||
// (requestReload, raised by main.cpp's projectconfig BeginLoadProjectState hook)
|
||||
// + the D4 load signal.
|
||||
// * ext_state_io.cpp — saveToActiveProject / loadFromProject /
|
||||
// writeAssignmentRequest: the ext-state ↔ JSON serialization bridge, plus GUID
|
||||
// minting and bank-folder relocation (see ext_state_io.h for the key contract).
|
||||
// * prune_fs.cpp — pruneDryRun / pruneOrphanSet / pruneReclaim: the prune scan
|
||||
// and THE SINGLE FILE-DELETION AUTHORITY over USER files in the bank folder in
|
||||
// ReaSampler (deleteOrphanFile via SHFileOperationW). Nothing else in the system
|
||||
// deletes bank-folder bytes; a shell's self-cleanup of a transient scratch file
|
||||
// it just created (the drop path's .vstpreset temp, the realtime finalize temp)
|
||||
// is excluded from this authority.
|
||||
// Save: BankModel JSON -> SetProjExtState under namespace "reasampler" (ext
|
||||
// state lives inside the .rpp, so the index travels with the project for
|
||||
// free). Load: GetProjExtState -> deserialize -> resolve each entry's bank
|
||||
// file against the CURRENT project dir, so a project opened from a new
|
||||
// location still finds its bank. Save-As: relocate the physical bank folder
|
||||
// so the wavs end up under the new .rpp; the index's relative paths stay valid.
|
||||
//
|
||||
// Save: serialize the BankModel JSON -> SetProjExtState under namespace
|
||||
// "reasampler" (ext state lives inside the .rpp, so the index travels with the
|
||||
// project for free).
|
||||
// Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory
|
||||
// BankModel, then resolve each entry's bank file against the CURRENT project
|
||||
// dir (project-relative resolution — a project opened from a new location still
|
||||
// finds its bank).
|
||||
// Save-As: when the project path changes, relocate the physical bank folder so
|
||||
// the wavs end up under the new .rpp (the index's relative paths stay valid).
|
||||
//
|
||||
// The header is REAPER-free (no SDK types leak here): callers interact through a
|
||||
// ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API
|
||||
// calls live in the three TUs. It depends on bank_model (pure) for JSON round-trip
|
||||
// and capture_paths (pure) for the path arithmetic it drives.
|
||||
// REAPER-free header — all REAPER API calls live in the three TUs.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
@@ -46,268 +33,133 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence
|
||||
// Owns the session's BankBook (pool + named banks) and drives persistence
|
||||
// against the active REAPER project. One instance lives for the extension's
|
||||
// lifetime (main.cpp). It tracks
|
||||
// the project identity it last saw so the timer tick can detect a project load
|
||||
// (a different project became active) and a Save-As (SAME project, path changed):
|
||||
// lifetime. Tracks the project identity last seen so the timer tick can
|
||||
// detect a project load (a different project became active, so load the
|
||||
// index from ext state) vs. a Save-As (same project, path changed, so
|
||||
// relocate the bank folder under the new .rpp).
|
||||
//
|
||||
// * project load -> load the index from ext state, resolve bank paths
|
||||
// * Save-As (new dir) -> relocate the bank folder under the new .rpp
|
||||
// Identity is layered GUID-primary: the minted GUID (content-based, immune to
|
||||
// REAPER recycling a closed project's ReaProject* address) is checked first;
|
||||
// the live pointer disambiguates only the same-GUID case — a forked sibling
|
||||
// (same GUID, different object -> Load) vs. a genuine Save-As (same GUID,
|
||||
// same object, new path -> relocate). Two prior designs each broke one
|
||||
// direction: GUID-only misread a Save-As fork as the parent project;
|
||||
// pointer-primary misread a recycled ReaProject* address as no-op. GUID-first
|
||||
// catches recycling; the pointer then separates fork from Save-As.
|
||||
//
|
||||
// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of
|
||||
// record, immune to REAPER recycling a closed project's ReaProject* address) is
|
||||
// checked FIRST, and the live pointer disambiguates only the same-GUID case — a
|
||||
// forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same
|
||||
// GUID, same object, new path -> relocate). GUID-first catches pointer recycling
|
||||
// (a reopened/new project reusing the previous address with a different GUID — the
|
||||
// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As
|
||||
// copies our GUID onto a distinct object — the W10 defect that clobbered a bank).
|
||||
//
|
||||
// The book itself is exposed for the capture/action layer to mutate; persist
|
||||
// only reads it on save and replaces it on load.
|
||||
// The book is exposed for the capture/action layer to mutate; persist only
|
||||
// reads it on save and replaces it on load.
|
||||
class ReaSamplerSession {
|
||||
public:
|
||||
ReaSamplerSession() = default;
|
||||
|
||||
// The multi-bank book (Phase B): the pool + named banks, each wrapping a
|
||||
// BankModel, plus the active-bank id. The action layer (B3) creates / renames /
|
||||
// reorders / deletes banks and moves samples here; the panel (B4) reads it;
|
||||
// persist serializes it under the `banks` key on save and replaces it on load.
|
||||
// Pool + named banks + active-bank id; persist serializes under `banks`.
|
||||
BankBook& book() { return book_; }
|
||||
const BankBook& book() const { return book_; }
|
||||
|
||||
// The capture add-target: the ACTIVE bank's BankModel (defaults to the pool).
|
||||
// The capture path adds a captured Sample through this seam, so a capture lands
|
||||
// in whichever bank is active — the single behavioural change B2 wires in over
|
||||
// M7/M8 (the capture backends are untouched; only the target index moved). The
|
||||
// panel/insert readers that displayed the single index continue to read it here
|
||||
// unchanged; today it resolves to the pool (default active), matching prior
|
||||
// single-bank behaviour, until B3/B4 let the user switch the active bank.
|
||||
// The capture add-target: the active bank's BankModel (defaults to the pool).
|
||||
model::BankModel& bank() { return book_.activeIndex(); }
|
||||
const model::BankModel& bank() const { return book_.activeIndex(); }
|
||||
|
||||
// The in-memory Design-View model. The view/action layer mutates it (tag,
|
||||
// toggle, snapshot); persist serializes it on save and replaces it on project
|
||||
// load — exactly as it treats the bank. D3 persists MODEL STATE only; applying
|
||||
// visibility/processing (reapply-on-open) is D4's job, not this member's.
|
||||
// Design-View model; persists MODEL STATE only (visibility on open is the view shell's job).
|
||||
ViewModeModel& view() { return view_; }
|
||||
const ViewModeModel& view() const { return view_; }
|
||||
|
||||
// The docked panel's tail setting (mode + manualMs), authoritative here — NOT in
|
||||
// panel state — so it travels inside the .rpp: persist serializes it on save and
|
||||
// replaces it on project load exactly as it treats the bank and view model. The
|
||||
// panel reads/writes it through this seam (bank_panel holds the session), and the
|
||||
// capture actions read it via bankPanelTailSetting. Default None / 2 s manual for
|
||||
// an unsaved or pre-feature project (no stored key -> this default survives load).
|
||||
// Docked panel's tail setting, authoritative here so it travels inside the .rpp.
|
||||
capture::TailSetting& tail() { return tail_; }
|
||||
const capture::TailSetting& tail() const { return tail_; }
|
||||
|
||||
// The owned-file manifest (Phase B B-cap): the set of project-relative files the
|
||||
// capture path itself created. The capture add-path records each created file here
|
||||
// (main.cpp, alongside the bank add), exactly as it adds the Sample to the active
|
||||
// bank; persist serializes it under the `owned_files` key on save and replaces it on
|
||||
// project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it;
|
||||
// B-cap only writes and persists it (no prune logic here).
|
||||
// Project-relative files the capture path itself created; prune consumes it.
|
||||
model::OwnedFileManifest& owned() { return owned_; }
|
||||
const model::OwnedFileManifest& owned() const { return owned_; }
|
||||
|
||||
// The ReaSampler version that last WROTE the active project, recovered from its
|
||||
// ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no
|
||||
// stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the
|
||||
// exact stored string otherwise — all silent, never an error. Replaced on every load
|
||||
// path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved
|
||||
// or never-loaded session. Exposed so a future migration step (or diagnostics) can
|
||||
// reason about the origin build without re-reading ext state.
|
||||
// The version that last wrote the active project: PreVersioning (no
|
||||
// stamp), Unknown (malformed), or Stamped.
|
||||
const version::WritingVersion& writingVersion() const { return writingVersion_; }
|
||||
|
||||
// The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic
|
||||
// per project: recovered on load (so it continues from the stored value rather than
|
||||
// resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on
|
||||
// every saveToActiveProject(). Exposed const for the writer sites to read/log.
|
||||
// Monotonic per project; recovered on load, written on every saveToActiveProject().
|
||||
std::int64_t bankGeneration() const { return bankGeneration_; }
|
||||
|
||||
// Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes
|
||||
// what a live instance would PLAY (capture add, re-capture-in-place, sample remove,
|
||||
// move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create /
|
||||
// rename / activate / reorder a bank), which change no existing (bankId, sampleId) ->
|
||||
// content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call
|
||||
// the same mutation already makes (the counter rides the persist blob, so there is no
|
||||
// separate write). In-memory only here — cheap and REAPER-free; the persist is the write.
|
||||
// Over-bumping is safe (a reload that finds unchanged content atomically re-installs the
|
||||
// same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err
|
||||
// toward bumping. Idempotent per logical op — call once per mutation, before the persist.
|
||||
// Call at every bank-CONTENT mutation that changes what a live instance
|
||||
// would play, NOT the organizational verbs (create/rename/reorder a
|
||||
// bank). Rides the next persist. Over-bumping is safe; under-bumping
|
||||
// misses a hands-free refresh, so call sites err toward bumping.
|
||||
void bumpBankGeneration() { ++bankGeneration_; }
|
||||
|
||||
// Serialize the current book (under the `banks` key), view model, and tail setting
|
||||
// to the active project's ext state (namespace "reasampler"), and clear the retired
|
||||
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
|
||||
// Safe to call when there is no active/saved project (it no-ops).
|
||||
//
|
||||
// Returns true iff a persist actually happened (an active, SAVED project existed);
|
||||
// false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a
|
||||
// caller wrapping this in an undo block skip the block when nothing was written, so
|
||||
// no dangling no-effect undo entry is opened on an unsaved project.
|
||||
// Serializes book/view/tail to ext state, clears the retired legacy
|
||||
// `bank_index` key. No-ops with no active/saved project. Returns true iff
|
||||
// a persist happened, so a caller can skip an undo block when nothing was written.
|
||||
bool saveToActiveProject();
|
||||
|
||||
// Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY,
|
||||
// deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project-
|
||||
// relative machinery the index/persist use — never a stale absolute path, so it is
|
||||
// correct across a Save-As relocation), spells every enumerated entry with the index's
|
||||
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
|
||||
// and feeds the R1 pure core with (present, referenced, owned().paths()) where
|
||||
// `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's
|
||||
// held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state
|
||||
// records + the live FX enumeration; sample_usage decides liveness) — a capture any
|
||||
// live instance holds can never be an orphan, so the prune can never delete it.
|
||||
// FAIL-SAFE: a present-but-unreadable usage record sets the report's
|
||||
// abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts.
|
||||
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
|
||||
// list. The decision stays in the pure core — this method only enumerates, resolves,
|
||||
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
|
||||
// save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file.
|
||||
//
|
||||
// Yields an empty report (count 0) when there is no active/saved project or no bank
|
||||
// folder on disk yet — an unsaved or never-captured project has nothing to reclaim.
|
||||
// Report-only prune dry-run: feeds the pure core with (present,
|
||||
// referenced, owned), where `referenced` = book references union every
|
||||
// live instance's held captures (usage_scan + sample_usage decide
|
||||
// liveness). FAIL-SAFE: an unreadable usage record sets
|
||||
// abortedUnreadableUsage with an EMPTY orphan set. Read-only throughout.
|
||||
reclaim::PruneReport pruneDryRun() const;
|
||||
|
||||
// The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh
|
||||
// enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no
|
||||
// 64-cap display clip) as project-relative index-spelled paths, in enumeration order.
|
||||
// The R3 action calls this to obtain the exact set it will CONFIRM and then delete
|
||||
// (pruneDryRun's truncated list is for the console readout; the delete set must be
|
||||
// complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is
|
||||
// no active/saved project or no bank folder yet.
|
||||
// The full (untruncated) orphan set, same compute as pruneDryRun. The
|
||||
// prune action confirms this set before deleting it. Read-only.
|
||||
std::vector<std::string> pruneOrphanSet() const;
|
||||
|
||||
// Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path
|
||||
// in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest.
|
||||
// Given the orphan set the user was shown and confirmed (`confirmed`, typically the
|
||||
// full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs
|
||||
// the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan)
|
||||
// so a file that vanished or became referenced between confirm and delete is skipped,
|
||||
// never wrongly deleted — and a newly-appeared orphan the user did NOT see is never
|
||||
// swept. Deletion routes to the OS trash where a portable move-to-trash is verified
|
||||
// (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to
|
||||
// std::filesystem unlink behind this confirm guardrail (see prune_fs.cpp for
|
||||
// per-platform routing). Non-throwing: every filesystem call uses error_code forms; a
|
||||
// per-file failure (locked, already gone) is recorded and skipped, never thrown across
|
||||
// the C ABI.
|
||||
//
|
||||
// Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does
|
||||
// NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present)
|
||||
// algebra naturally once it is off disk — no persist write, so no undo-point question
|
||||
// and no risk to the referenced/owned safety). Writes NO ext-state at all.
|
||||
//
|
||||
// No-ops (empty result) when there is no active/saved project, no bank folder, or the
|
||||
// delete plan is empty (everything went stale). The caller is responsible for having
|
||||
// shown the confirm; this method does NOT prompt.
|
||||
// Delete the confirmed orphan set — the sole file-deletion path,
|
||||
// callable only after an explicit user confirm. Re-enumerates and runs
|
||||
// the pure core fresh, deleting exactly `confirmed ∩ freshOrphans` so a
|
||||
// file that vanished or became referenced since confirm is skipped, and
|
||||
// an orphan the user did not see is never swept. Trash-preferred
|
||||
// (Windows Recycle Bin; unlink elsewhere). Does not modify the book or
|
||||
// OwnedFileManifest, writes no ext-state. No-ops when nothing to delete;
|
||||
// does not prompt.
|
||||
reclaim::PruneDeletionResult pruneReclaim(
|
||||
const std::vector<std::string>& confirmed) const;
|
||||
|
||||
// Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the
|
||||
// `assign_request` key, namespace "reasampler"): the extension telling the active
|
||||
// sampler instance "play THIS sample now." `wire` is the pure assignment_request
|
||||
// encoding (assignment_request.h); this method only routes the already-encoded value
|
||||
// to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and
|
||||
// the encode live in the ingest shell (the pure module) so persist stays a thin bridge.
|
||||
//
|
||||
// A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an
|
||||
// assignment request is a transient "just assigned" signal the instrument reads and
|
||||
// acts on, so it rides its own key and is written only at ingest time, never on every
|
||||
// book save. Returns true iff written (an active, SAVED project existed); false on a
|
||||
// no-active / unsaved project (nothing to write into — the assign is dropped, matching
|
||||
// the book/manifest quiet-persist idiom the ingest add-path already tolerates).
|
||||
// Write the ingest assignment request (`assign_request` key): "the active
|
||||
// sampler instance should now play THIS sample." `wire` is pre-encoded
|
||||
// (assignment_request.h); a sibling one-shot write, not part of
|
||||
// saveToActiveProject's blob. Returns true iff written.
|
||||
bool writeAssignmentRequest(const std::string& wire);
|
||||
|
||||
// Poll the active project. Detects a project load (active project changed)
|
||||
// and a Save-As (active project's .rpp path changed) and reacts accordingly.
|
||||
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
|
||||
//
|
||||
// Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z
|
||||
// keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the
|
||||
// identity classifier below reads it as NoOp and would never re-read ext state.
|
||||
// The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state
|
||||
// restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the
|
||||
// (now-restored) ext state of the current project — before the identity check, so
|
||||
// the undo is reflected in-session without any content polling.
|
||||
// Detects a project load or Save-As and reacts. Driven by REAPER's
|
||||
// "timer" register; idempotent per tick. Also drains a pending undo/redo
|
||||
// reload (requestReload): the identity classifier alone would read an
|
||||
// undo/redo as NoOp since identity is unchanged, so the projectconfig
|
||||
// hook's reload flag is honored FIRST, before the identity check.
|
||||
void poll();
|
||||
|
||||
// Request a reload of book_ + view_ + tail_ from the CURRENT active project's ext
|
||||
// state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY
|
||||
// on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read)
|
||||
// because the projectconfig callback fires BEFORE REAPER has restored the project's
|
||||
// <EXTSTATE> block — reading GetProjExtState synchronously there would return the
|
||||
// PRE-undo value. Draining it on the next timer tick reads the restored value. This
|
||||
// is REAPER-facing shell state; the request itself carries no REAPER types.
|
||||
// Request a reload of book_/view_/tail_ on the next poll() tick. Raised
|
||||
// by the projectconfig hook only on an undo/redo state restore. Deferred
|
||||
// because the hook fires BEFORE REAPER restores the <EXTSTATE> block —
|
||||
// reading synchronously there would return the pre-undo value.
|
||||
void requestReload();
|
||||
|
||||
// Load signal for the D4 reapply-on-open glue. poll() raises this whenever it
|
||||
// (re)loads the view model from a project — prime, a project switch/open, or a
|
||||
// forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears
|
||||
// it, so the integration layer (main.cpp) can react by reapplying the saved
|
||||
// active mode's visibility exactly once, then goes quiet on idle ticks.
|
||||
//
|
||||
// Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view
|
||||
// shell), so there is no persist -> view dependency. main.cpp owns the glue —
|
||||
// it drives both persist.poll() and view::applyMode, so the reapply wiring lives
|
||||
// where those two already meet. D3 deliberately deferred exactly this to D4.
|
||||
// Load signal for the reapply-on-open glue: poll() raises this whenever
|
||||
// it (re)loads the view model; consumeLoadSignal() returns true once and
|
||||
// clears it. Signal-based since persist stays model-only (never calls
|
||||
// the view shell); main.cpp owns the glue.
|
||||
bool consumeLoadSignal();
|
||||
|
||||
private:
|
||||
BankBook book_;
|
||||
ViewModeModel view_; // reset to default on a project with no stored view_state
|
||||
capture::TailSetting tail_; // reset to default (None / 2s) with no stored tail key
|
||||
model::OwnedFileManifest owned_; // reset to empty/stored on EVERY load path, never inherited
|
||||
version::WritingVersion writingVersion_; // recovered per load; PreVersioning default
|
||||
std::int64_t bankGeneration_ = 0; // recovered per load (absent -> 0); monotonic
|
||||
|
||||
// The Design-View model. Default-constructed = Arrange + Design seeded, active
|
||||
// = Arrange; loadFromProject leaves this default when a project has no stored
|
||||
// view_state (older project), so an absent key is graceful, not a crash.
|
||||
ViewModeModel view_;
|
||||
|
||||
// The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it
|
||||
// to this default when a project has no stored tail_setting key (older / never-
|
||||
// adjusted project), so an absent key is graceful. Peer to bank_/view_.
|
||||
capture::TailSetting tail_;
|
||||
|
||||
// The owned-file manifest. Default empty; loadFromProject resets it to empty (or the
|
||||
// stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to
|
||||
// a project with no stored manifest must not inherit the previous project's ownership
|
||||
// record, and an undo that rolled back a capture must re-read the restored manifest so
|
||||
// the in-memory set matches disk. Absent key -> empty is graceful (older project).
|
||||
model::OwnedFileManifest owned_;
|
||||
|
||||
// The writing-version stamp recovered on load (Phase V). Default PreVersioning;
|
||||
// loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so
|
||||
// switching to a pre-versioning project reports PreVersioning rather than inheriting
|
||||
// the previous project's stamp. Read-only to consumers via writingVersion().
|
||||
version::WritingVersion writingVersion_;
|
||||
|
||||
// The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path
|
||||
// from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it
|
||||
// continues monotonic from the persisted value across reopen and resets cleanly on a
|
||||
// project switch (a different project's counter, not the previous one's). bumped by
|
||||
// bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject().
|
||||
// Default 0 for an unsaved / never-loaded / pre-S9 session.
|
||||
std::int64_t bankGeneration_ = 0;
|
||||
|
||||
// The project identity last observed by poll(), used to detect load/Save-As.
|
||||
// The GUID is the PRIMARY signal (a different stored GUID = a different project
|
||||
// of record = Load, immune to pointer recycling). The pointer disambiguates the
|
||||
// same-GUID case (different object = forked sibling -> Load; same object + new
|
||||
// path -> Save-As) and drives forked-sibling re-divergence; the path tells a
|
||||
// Save-As from an idle tick.
|
||||
// Held as void* so the header stays REAPER-free; it is a compared-only opaque
|
||||
// handle (never dereferenced), so a stale/recycled address is harmless.
|
||||
void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only)
|
||||
// Project identity last observed by poll(). GUID is primary; the pointer
|
||||
// disambiguates the same-GUID case. Held as void* (compare-only, never
|
||||
// dereferenced) so the header stays REAPER-free.
|
||||
void* lastProject_ = nullptr;
|
||||
std::string lastGuid_; // "" until the first saved project is seen
|
||||
std::string lastRppPath_; // .rpp path last seen for lastProject_
|
||||
std::string lastRppPath_;
|
||||
bool primed_ = false; // false until the first poll() observes state
|
||||
bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal
|
||||
bool reloadRequested_ = false; // raised by requestReload (projectconfig undo/redo); drained by poll
|
||||
bool reloadRequested_ = false; // raised by requestReload; drained by poll
|
||||
|
||||
// Load the book from the given project's ext state (the `banks` key, else the
|
||||
// legacy `bank_index` key migrated into the pool) and resolve bank paths against
|
||||
// projectDir at read time. Replaces the in-memory book. Also restores view_, tail_,
|
||||
// and owned_ from their sibling keys on every load path. projectDir empty -> the
|
||||
// book is reset to empty (unsaved project has no resolvable banks).
|
||||
// Load the book from `proj`'s ext state (`banks`, else legacy
|
||||
// `bank_index` migrated into the pool); also restores view_/tail_/owned_.
|
||||
void loadFromProject(void* proj, const std::string& projectDir);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
// 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.
|
||||
// usage_scan.cpp — see usage_scan.h. The REAPER reads behind the instance-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)
|
||||
// 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). REAPER symbols used here (EnumProjExtState,
|
||||
// GetProjExtState, CountTracks/GetTrack/GetMasterTrack, TrackFX_GetCount/
|
||||
// GetRecCount/GetNamedConfigParm, CountMediaItems/GetMediaItem, CountTakes/
|
||||
// GetMediaItemTake, GetMediaItemTrack, TakeFX_GetCount/GetNamedConfigParm) are
|
||||
// verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h.
|
||||
|
||||
#include "shell/persist/usage_scan.h"
|
||||
|
||||
@@ -26,11 +20,11 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/version/app_version.h" // vstPluginName / vstOutputName (channel name needles)
|
||||
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy)
|
||||
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing — the shared grow-loop policy
|
||||
#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
|
||||
#include "shell/capture/track_guid.h" // guidString — the canonical GUID key formatter
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjExtState
|
||||
@@ -52,10 +46,9 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Real-namespace-home using-directive (Q-W6: the namespaces.h shim is retired):
|
||||
// this TU speaks the sample_usage wire vocabulary wholesale (UsageRecord /
|
||||
// decodeUsageRecord / foldUsageRecords / identityMatches / toUpperAscii) plus the
|
||||
// channel-identity accessors + the preset class-id hex.
|
||||
// This TU speaks the sample_usage wire vocabulary wholesale (UsageRecord /
|
||||
// decodeUsageRecord / foldUsageRecords / identityMatches / toUpperAscii) plus
|
||||
// the channel-identity accessors + the preset class-id hex.
|
||||
using namespace reasampler::wire;
|
||||
using version::vstOutputName;
|
||||
using version::vstPluginName;
|
||||
@@ -76,16 +69,15 @@ struct FxIdentityNeedles {
|
||||
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).
|
||||
// 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 (a
|
||||
// renamed instance may keep its original_name; fx_ident carries the module
|
||||
// path and survives a rename). 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 (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,
|
||||
@@ -96,12 +88,9 @@ bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId,
|
||||
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.
|
||||
// Descent budget exhausted on a node that IS a container: we cannot
|
||||
// prove none of its children is an instance, so treat the incomplete
|
||||
// walk as a positive identification (protect direction).
|
||||
return true;
|
||||
}
|
||||
const int n = std::atoi(countStr.c_str());
|
||||
@@ -116,9 +105,9 @@ bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId,
|
||||
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.
|
||||
// Real-world FX containers are typically 2-4 levels deep; 32 is unreachable
|
||||
// in practice while remaining finite. The truncation->protect-all guard above
|
||||
// is the primary protection even at this depth.
|
||||
constexpr int kMaxContainerDepth = 32;
|
||||
|
||||
std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) {
|
||||
@@ -153,11 +142,9 @@ bool trackHasInstance(MediaTrack* tr, const FxIdentityNeedles& id) {
|
||||
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).
|
||||
// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just
|
||||
// active — a non-active take's instance still exists and reactivates with
|
||||
// the take). Same identity walk as the track path.
|
||||
bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) {
|
||||
const int takes = CountTakes(item);
|
||||
for (int t = 0; t < takes; ++t) {
|
||||
@@ -174,15 +161,11 @@ bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Growing GetProjExtState read: the usage record scales with the hold count, so a
|
||||
// fixed buffer risks a truncated decode. The retry policy is the SHARED pure
|
||||
// wire::readProjExtStateGrowing (T2-04 — one loop for persist, this
|
||||
// prune-safety-adjacent read, and the VST bridge; the rules cannot drift).
|
||||
// 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).
|
||||
// The usage record scales with the hold count, so a fixed buffer risks a
|
||||
// truncated decode; uses the shared grow-loop policy. Returns nullopt when
|
||||
// the key cannot be read whole (absent, or > 16 MB give-up). The caller only
|
||||
// queries keys the enumeration just listed, so nullopt here is a
|
||||
// present-but-unreadable record: it folds to abortPrune.
|
||||
std::optional<std::string> readExtStateValue(ReaProject* proj, const char* key) {
|
||||
const GrowingExtStateRead read = readProjExtStateGrowing(
|
||||
[&](char* buf, int cap) {
|
||||
@@ -198,10 +181,8 @@ 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.
|
||||
// Enumerate rsusage_* keys, then read+decode via the growing reader
|
||||
// (EnumProjExtState's fixed val buffer could truncate a large record).
|
||||
std::vector<std::string> usageKeys;
|
||||
{
|
||||
const std::string prefix = kProjExtUsageKeyPrefix; // hoisted: one alloc, not N
|
||||
@@ -232,8 +213,8 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
|
||||
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.
|
||||
// Enumerate live ReaSampler 9000 hosts; a track needs only one instance to
|
||||
// keep all its records live.
|
||||
FxIdentityNeedles id;
|
||||
id.uidHexUpper = toUpperAscii(vstClassIdHex());
|
||||
id.outputNameUpper = toUpperAscii(vstOutputName());
|
||||
@@ -270,14 +251,12 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The pure fold decides: abort on any unreadable record; protect-all when zero
|
||||
// instances were identified; otherwise the per-record liveness rule.
|
||||
// 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();
|
||||
if (!result.abortPrune) result.offendingKeys.clear(); // only meaningful on abort
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,37 @@
|
||||
#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?
|
||||
// usage_scan — the extension-side shell of the instance-usage seam (see
|
||||
// sample_usage.h for the pure core and fail-safe folds). 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).
|
||||
// Three reads, no writes: (1) enumerate every "rsusage_<guid>" key and decode
|
||||
// each record — unreadable/undecodable folds to abortPrune; (2) enumerate
|
||||
// every ReaSampler 9000 FX instance (all tracks incl. master, normal +
|
||||
// record/input chains, containers recursively, take FX) via
|
||||
// sample_usage::identityMatches; (3) fold with the pure liveness rule — zero
|
||||
// instances identified anywhere protects every record's paths.
|
||||
//
|
||||
// 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.
|
||||
// Feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans — a
|
||||
// held capture can never be an orphan. abortPrune propagates 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*).
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers). The header stays
|
||||
// REAPER-free (`proj` is the opaque ReaProject* passed 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).
|
||||
// When abortPrune is true, a present rsusage_* record could not be read or
|
||||
// decoded — the caller MUST halt the prune. offendingKeys names the exact
|
||||
// keys that triggered the abort, so the action can print them for recovery
|
||||
// (clear via ReaScript: reaper.SetProjExtState(0, "reasampler", "<key>", "")).
|
||||
// heldPaths on abort is the protect-all set — a belt-and-braces fallback; the
|
||||
// abort flag is authoritative. Otherwise heldPaths is every project-relative
|
||||
// path held by a live instance, de-duped, in record order.
|
||||
struct UsageScanResult {
|
||||
bool abortPrune = false;
|
||||
std::vector<std::string> offendingKeys; // non-empty iff abortPrune
|
||||
|
||||
+130
-269
@@ -1,12 +1,7 @@
|
||||
// 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.
|
||||
// See view.h. Compiled into the reaper_reasampler module; includes
|
||||
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
|
||||
// Tree arithmetic lives in view_tree (pure); this file owns REAPER reads/writes
|
||||
// and the snapshot-before-park ordering.
|
||||
|
||||
#include "shell/view/view.h"
|
||||
|
||||
@@ -37,8 +32,8 @@
|
||||
#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.
|
||||
// Lane minting (D2 Wave 3): item-side lane reads/writes to assign each item to
|
||||
// its mode's managed lane.
|
||||
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
@@ -47,7 +42,6 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
|
||||
using view::buildFolderTree;
|
||||
using view::isOnManualLane;
|
||||
using view::managedLaneKey;
|
||||
@@ -56,35 +50,24 @@ using view::TrackFolderEntry;
|
||||
|
||||
namespace {
|
||||
|
||||
// Track fixed-lane mode value (I_FREEMODE=2). See SDK: 0=normal, 1=free item
|
||||
// positioning, 2=fixed lanes.
|
||||
// I_FREEMODE value for fixed lanes. 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.
|
||||
// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane
|
||||
// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist
|
||||
// but displays as non-fixed-lane).
|
||||
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.
|
||||
// C_LANESETTINGS &32 = hide per-lane buttons; OR'd in, never clobbering the
|
||||
// mask. Deliberately NOT setting &1 (auto-remove empty lanes): the lazy-mint
|
||||
// decision never mints an empty lane, so &1 buys nothing and risks REAPER
|
||||
// silently removing a managed lane out from under the ownership index.
|
||||
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.
|
||||
// Makes a tool-split track's display read as an ordinary track. Gated by every
|
||||
// caller on the tool-driven transition INTO fixed lanes (freeMode != 2 before
|
||||
// the flip) — a track already in fixed-lane mode (the user's own) never
|
||||
// reaches this, so a user's comp-lane display prefs are never stomped.
|
||||
void applyTransparentLaneDisplay(MediaTrack* tr) {
|
||||
SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED",
|
||||
static_cast<double>(kLanesDisplayAsNormal));
|
||||
@@ -93,8 +76,6 @@ void applyTransparentLaneDisplay(MediaTrack* tr) {
|
||||
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";
|
||||
@@ -105,11 +86,9 @@ const char* flagParm(Flag f) {
|
||||
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.
|
||||
// The master track is not enumerated by GetTrack (index space excludes it),
|
||||
// so it can never enter the tree — the master-untouched invariant holds by
|
||||
// construction. Also caches each MediaTrack* by GUID for later resolve().
|
||||
std::vector<TrackFolderEntry> readFolderEntries(
|
||||
ReaProject* proj,
|
||||
std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||
@@ -137,10 +116,8 @@ MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& hand
|
||||
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).
|
||||
// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO;
|
||||
// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract).
|
||||
TrackSnapshot snapshotTrack(MediaTrack* tr) {
|
||||
TrackSnapshot snap;
|
||||
snap.showInTcp = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP"));
|
||||
@@ -156,16 +133,14 @@ TrackSnapshot snapshotTrack(MediaTrack* tr) {
|
||||
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.
|
||||
// The pure park plan leaves fxOffline empty by design; expand it here from the
|
||||
// live FX count.
|
||||
void parkFxOffline(MediaTrack* tr) {
|
||||
int fxCount = TrackFX_GetCount(tr);
|
||||
for (int fx = 0; fx < fxCount; ++fx) {
|
||||
@@ -173,15 +148,13 @@ void parkFxOffline(MediaTrack* tr) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Restores per-FX offline from the snapshot, bounds-checked against the live
|
||||
// FX count (prune-safe if the chain changed while parked).
|
||||
//
|
||||
// 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.
|
||||
// HAZARD (open, tracked in docs/TODO.md): this remaps by slot INDEX, not
|
||||
// plugin identity. If the FX chain reshuffled while parked, snapshot slot k
|
||||
// restores onto whatever plugin now occupies slot k. Accepted for now;
|
||||
// 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) {
|
||||
@@ -190,18 +163,14 @@ void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline)
|
||||
}
|
||||
}
|
||||
|
||||
// -- 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.
|
||||
// Managed-lane application: the pure planner keys LanePlayOps by the lane's
|
||||
// DURABLE name; REAPER's C_LANEPLAYS:N is keyed by current ordinal, which
|
||||
// renumbers on reorder. So every write here re-resolves durable key -> current
|
||||
// ordinal first. A lane whose name lacks the managed prefix never enters this
|
||||
// map and so 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).
|
||||
// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed /
|
||||
// unavailable (non-fixed-lane track).
|
||||
std::string laneName(MediaTrack* tr, int laneIdx) {
|
||||
char parm[32];
|
||||
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
|
||||
@@ -210,9 +179,8 @@ std::string laneName(MediaTrack* tr, int laneIdx) {
|
||||
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.
|
||||
// Managed lane durable key -> current ordinal on `tr`. Manual lanes are
|
||||
// omitted, so a key absent from the map must not be driven.
|
||||
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"));
|
||||
@@ -223,37 +191,25 @@ std::map<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
|
||||
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.)
|
||||
// Track-side C_LANEPLAYS:N alone hides+silences every item on lane N (SDK:
|
||||
// item-side C_LANEPLAYS is read-only, so no per-item write exists or is
|
||||
// needed). B_FIXEDLANE_HIDDEN is also read-only — hide/show follows from
|
||||
// C_LANEPLAYS=0/1, never written directly.
|
||||
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.
|
||||
// Groups ops by track, reconciles each op's durable laneKey to the track's
|
||||
// current ordinal (a stale/renamed/deleted key is pruned, never mis-driven),
|
||||
// enables fixed-lane mode on any track carrying a managed lane, and drives
|
||||
// C_LANEPLAYS. UpdateTimeline() is the caller's job when this returns true
|
||||
// (SDK: required after an I_FREEMODE change).
|
||||
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);
|
||||
|
||||
@@ -262,22 +218,18 @@ bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handle
|
||||
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.
|
||||
// Every track reaching here already owns a managed lane (planToggle
|
||||
// only emits ops for managed lanes), so re-asserting fixed-lane mode
|
||||
// is always a tool-driven (re)split — never a user's untouched
|
||||
// manual-fixed-lane track — and gets the same transparent display.
|
||||
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
|
||||
applyTransparentLaneDisplay(tr);
|
||||
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);
|
||||
@@ -288,20 +240,12 @@ bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handle
|
||||
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.
|
||||
// Managed-lane minting: the DECISION (which tracks split, which lanes, which
|
||||
// item goes where) is planLaneMinting; this shell only reads live per-item
|
||||
// mode+lane state, calls it, and applies the resulting 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).
|
||||
// Maps every item GUID on `tr` to its handle in one pass (avoids a per-item
|
||||
// re-scan in the assign loop).
|
||||
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
|
||||
std::map<std::string, MediaItem*> byGuid;
|
||||
const int itemCount = CountTrackMediaItems(tr);
|
||||
@@ -314,23 +258,18 @@ std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
|
||||
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.
|
||||
// An untagged item is Arrange by default (mirrors leafBelongsToMode). A
|
||||
// show-both/multi-mode item resolves to its first mode id — unusual for lane
|
||||
// content, and any one mode is sufficient for the decision.
|
||||
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
|
||||
if (modes.empty()) return kArrangeModeId;
|
||||
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.
|
||||
// Builds the per-track LaneItem picture the pure decision consumes. Manual-
|
||||
// lane reads are skipped on a non-fixed-lane track (isOnManualLane is false
|
||||
// there regardless of name).
|
||||
std::vector<LaneTrack> readLaneTracks(
|
||||
const ViewModeModel& model,
|
||||
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||
@@ -353,9 +292,6 @@ std::vector<LaneTrack> readLaneTracks(
|
||||
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));
|
||||
@@ -365,12 +301,9 @@ std::vector<LaneTrack> readLaneTracks(
|
||||
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".)
|
||||
// Idempotent: writes I_FIXEDLANE only when it differs from the item's current
|
||||
// lane. Non-destructive — only this reversible flag is written, never a move
|
||||
// in time or across tracks.
|
||||
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
|
||||
@@ -378,22 +311,16 @@ bool assignItemToLane(MediaTrack* tr, MediaItem* it, int 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).
|
||||
// Applies the pure LaneMintPlan. Returns true if any project write actually
|
||||
// changed state (⇒ 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.
|
||||
// The plan only ever names managed-prefixed lanes and only ever assigns
|
||||
// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk,
|
||||
// so a user's existing manual lanes 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;
|
||||
@@ -403,37 +330,26 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
|
||||
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.
|
||||
// A track not already in fixed-lane mode is one the tool is splitting
|
||||
// now, so it owns the display; a track already at I_FREEMODE==2 (the
|
||||
// user's own, or a prior tool run) skips this and keeps its display prefs.
|
||||
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
|
||||
applyTransparentLaneDisplay(tr);
|
||||
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.
|
||||
// Grow-only: a track may already carry the user's manual lanes, so the
|
||||
// lane count only ever increases; managed lanes occupy the tail ordinals.
|
||||
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];
|
||||
@@ -445,10 +361,6 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
|
||||
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]) {
|
||||
@@ -465,21 +377,18 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
|
||||
} // 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;
|
||||
return false; // reject before touching the project — no partial apply
|
||||
}
|
||||
|
||||
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.
|
||||
// Prune snapshots for tracks no longer in the live enumeration before
|
||||
// planning (membership is intentionally left alone — see model.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);
|
||||
@@ -488,31 +397,25 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
|
||||
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.
|
||||
// PARK: snapshot before mutating, store into the model, then apply.
|
||||
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;
|
||||
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
|
||||
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.
|
||||
// Snapshot ONCE, at first park: a snapshot already present means the
|
||||
// track is still parked from a prior apply, so its live flags are the
|
||||
// parked values — recapturing would overwrite the true pre-park state
|
||||
// with zeros and a later restore would hide it for good. 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.
|
||||
// RESTORE: apply verbatim, then drop the consumed snapshot.
|
||||
for (const TrackPlan& tp : plan.restore) {
|
||||
if (tp.flags.empty()) continue;
|
||||
const std::string& guid = tp.flags.front().guid;
|
||||
@@ -524,21 +427,13 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
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.
|
||||
// MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows
|
||||
// and every other managed lane is silenced+hidden. Empty for a D1-only
|
||||
// project, leaving that behavior byte-identical.
|
||||
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.
|
||||
// PARENT VISIBILITY (never parked): recomputed every toggle, never
|
||||
// snapshotted. Only the two visibility flags — never mainSend/FX on a parent.
|
||||
std::set<std::string> visible = model.visibleTracks(tree, targetModeId);
|
||||
for (const FolderNode& node : tree.nodes) {
|
||||
if (!node.isParent) continue;
|
||||
@@ -549,10 +444,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
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.
|
||||
// Target is guaranteed registered (checked at entry); fall back to the id
|
||||
// defensively if that ever changes.
|
||||
const Mode* targetMode = model.modes().query(targetModeId);
|
||||
const std::string undoLabel =
|
||||
"ReaSampler: activate " +
|
||||
@@ -560,18 +453,14 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
|
||||
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.
|
||||
// Force REAPER to rebuild the TCP/MCP now rather than on the next user
|
||||
// interaction: TrackList_AdjustWindows(false) does the full relayout owed
|
||||
// when tracks appear/disappear; UpdateArrange() repaints.
|
||||
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.
|
||||
// UpdateTimeline() is owed only when a track was actually toggled into
|
||||
// fixed lanes this apply (SDK requirement for I_FREEMODE changes).
|
||||
if (laneModeChanged) UpdateTimeline();
|
||||
|
||||
Undo_EndBlock2(proj, undoLabel.c_str(), -1);
|
||||
@@ -581,63 +470,41 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
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.
|
||||
// The tree is needed to detect a content-bearing folder derived-visible in
|
||||
// >1 mode, exactly as applyMode builds it.
|
||||
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.
|
||||
// Plan was non-empty but every write was already satisfied — discard
|
||||
// the empty undo point rather than flooding history every detect 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.
|
||||
// The arrange still needs a redraw: this no-op path is reached when a
|
||||
// freshly-inserted item already landed on the active mode's playing
|
||||
// lane (REAPER places new items on the playing lane), so
|
||||
// assignItemToLane wrote nothing even though the item needs to appear
|
||||
// there now. UpdateArrange() alone (no I_FREEMODE change happened, so
|
||||
// UpdateTimeline isn't owed) is a repaint, not a mutation — stays
|
||||
// outside the undo block.
|
||||
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.
|
||||
// Reapply the active mode's lane visibility so freshly-minted lanes take
|
||||
// their play/show state immediately. applyMode is deliberately NOT reused
|
||||
// here — it would re-park/restore whole tracks and recompute parent
|
||||
// visibility, which a lane-only mint must not touch.
|
||||
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();
|
||||
UpdateTimeline(); // a split happened this call — refresh is owed
|
||||
UpdateArrange();
|
||||
|
||||
Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", -1);
|
||||
@@ -648,12 +515,9 @@ 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.
|
||||
// Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/
|
||||
// I_FIXEDLANE written) plus an ownership-index write, recovering managed
|
||||
// classification from the durable name. An unprefixed lane is left alone.
|
||||
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
|
||||
@@ -666,15 +530,12 @@ void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) {
|
||||
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.
|
||||
// A mode id encoded in the name may no longer be registered (e.g.
|
||||
// removed since the project was saved). Recording it managed would
|
||||
// make the toggle planner drive a lane keyed to a mode that can
|
||||
// never be active — permanently silenced, orphaning its items. So
|
||||
// skip: the lane stays off the index (manual-by-default) but keeps
|
||||
// its name, so a later re-registration of the mode heals cleanly.
|
||||
if (!model.modes().contains(*mode)) continue;
|
||||
model.lanes().setManaged(guid, *key, *mode);
|
||||
}
|
||||
|
||||
+20
-78
@@ -1,95 +1,37 @@
|
||||
#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.
|
||||
// REAPER-facing shell of Design View (D2): reads the live folder tree, runs
|
||||
// ViewModeModel's pure planner, and applies the resulting flag / per-FX /
|
||||
// lane writes. The .cpp is the sole REAPER-facing TU here (CLAUDE.md contract:
|
||||
// only main.cpp defines the API pointers). See src/shell/view/CLAUDE.md for
|
||||
// the enforced invariants (never touch master/mute/solo, snapshot-based restore).
|
||||
|
||||
#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.
|
||||
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
|
||||
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.
|
||||
// Snapshots each about-to-park track's flags into `model`, runs planToggle,
|
||||
// applies park/restore writes plus parent visibility flags, then sets the
|
||||
// active mode. Wrapped in one Undo block. Returns false (no mutation) if
|
||||
// `targetModeId` isn't registered. `proj` == nullptr means the 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.
|
||||
// Splits any track visible in more than one mode while carrying its own media
|
||||
// into fixed lanes (one managed lane per involved mode), assigns items, and
|
||||
// records ownership in `model`. Never touches manual lanes. Runs planLaneMinting
|
||||
// (model + tree aware); wraps the mutation in one Undo block when non-empty.
|
||||
// Returns true if any lane was minted (repaint hint). `proj` == nullptr means
|
||||
// the current project.
|
||||
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.
|
||||
// Recovers managed-lane ownership from durable P_LANENAME on project open —
|
||||
// self-healing, without minting/reassigning anything and without touching
|
||||
// membership. A lane without the managed prefix is left untouched (manual).
|
||||
// `proj` == nullptr means the current project.
|
||||
void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
Reference in New Issue
Block a user