feat: add M6 insert — place selected bank sample at edit cursor

InsertMedia-driven placement reading the bank panel selection, undo-wrapped.
Native length by default; conform-to-tempo is an explicit second action, never
a silent stretch. Pure mode-bit logic (insert_plan) unit-tested.
This commit is contained in:
2026-07-23 05:23:37 -04:00
parent 60add4afc7
commit 172d5902c8
9 changed files with 557 additions and 5 deletions
+16
View File
@@ -778,6 +778,22 @@ bool bankPanelIsOpen() {
return g_panel.open;
}
std::vector<std::string> bankPanelSelectedSampleIds() {
std::vector<std::string> ids;
const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr;
if (!bank) return ids;
const std::vector<Sample>& samples = bank->all();
const int count = static_cast<int>(samples.size());
// selection.indices is sorted-ascending unique (bank_grid invariant), so the
// returned ids come out in bank order. Guard each index against the live count
// in case the selection outran a shrink the fingerprint pass hasn't cleared yet.
for (int idx : g_panel.selection.indices) {
if (idx >= 0 && idx < count)
ids.push_back(samples[static_cast<std::size_t>(idx)].id);
}
return ids;
}
void bankPanelRefresh() {
if (!g_panel.open || !g_panel.hwnd) return;
// Repaint only when the bank actually changed (generation bump). Cheap tick
+16 -2
View File
@@ -1,8 +1,8 @@
#pragma once
// bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns
// a SWELL dialog docked via DockWindowAddEx, and paints the current project's
// bank as a grid of LICE-drawn waveform thumbnails. Read-only this wave: it NEVER
// inserts into the arrange or mutates the project/bank (CONTEXT.md §load-bearing
// bank as a grid of LICE-drawn waveform thumbnails. The panel itself NEVER inserts
// into the arrange or mutates the project/bank (CONTEXT.md §load-bearing
// principle). Audition / multi-select / keyboard nav are Wave B.
//
// The header is REAPER-free as practical: main.cpp drives the panel through these
@@ -10,6 +10,9 @@
// All SWELL / LICE / PCM_source use is confined to bank_panel.cpp. The pure
// layout math and cache keys live in bank_grid (unit-tested outside the DAW).
#include <string>
#include <vector>
namespace reasampler {
class ReaSamplerSession;
@@ -29,6 +32,17 @@ void bankPanelToggle();
// checked-state (toggleaction) so REAPER shows a tick next to the menu entry.
bool bankPanelIsOpen();
// 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.
std::vector<std::string> bankPanelSelectedSampleIds();
// 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.
+120
View File
@@ -0,0 +1,120 @@
// insert.cpp — REAPER-facing placement shell (M6). See insert.h.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are
// extern (CLAUDE.md §contract).
//
// THIS IS THE INTENDED PLACEMENT PATH. Unlike capture / bank_panel (which never
// touch the arrange), insert deliberately adds items to the arrange — that is its
// whole job (CONTEXT.md §load-bearing principle). It runs ONLY from its own action.
//
// FLAGGED RUNTIME ASSUMPTIONS (semantics the header does not fully specify — must
// be DAW-verified by Daniel post-merge; see the handoff):
// A. InsertMedia base modes 0/1 insert AT THE EDIT CURSOR. The header names the
// base targets ("add to current track" / "add new track") but does not spell
// out an explicit "at edit cursor" bit — placement at the edit cursor is
// REAPER's documented convention for these modes, relied on here.
// B. InsertMedia ADVANCES the edit cursor to the end of the inserted media. This
// is the behavior that makes sequential multi-insert lay items end-to-end. It
// is REAPER's long-standing behavior but is not stated in the header — flagged.
// We do NOT re-read/patch the cursor between inserts (we trust B); if B proved
// false in the DAW, the fix is to advance the cursor ourselves by the inserted
// item length. Not done now (no evidence it is needed, and item length is not
// returned by InsertMedia).
// C. New-track insert (mode base 1) creates the track and leaves it selected;
// current-track insert (base 0) targets the current/last-selected track. We do
// not force a track selection — the user's current selection is the target for
// base 0, matching REAPER's drag-to-track semantics.
#include "insert.h"
#include <filesystem>
#include <string>
#include <vector>
#include "bank_model.h"
#include "bank_panel.h"
#include "capture_paths.h"
#include "persist.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetCursorPosition
#define REAPERAPI_WANT_InsertMedia
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
namespace fs = std::filesystem;
// The current project's directory (mirrors bank_panel/capture/persist). The bank
// index stores relative paths; resolving a bank file needs the current .rpp dir.
// FOLLOW-UP (already noted in bank_panel.cpp): a shared "current project dir"
// REAPER helper is a clean small refactor now that a fourth consumer exists — out
// of scope for M6.
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
std::string rpp(buf.data());
if (rpp.empty()) return {}; // unsaved project: no resolvable bank
return normalizeSlashes(fs::path(rpp).parent_path().string());
}
} // namespace
InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) {
InsertResult result;
if (!session) { result.status = InsertStatus::NoSelection; return result; }
// WHAT to place: the panel's current selection (ids, in bank order).
const std::vector<std::string> ids = bankPanelSelectedSampleIds();
if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; }
// WHERE the bank lives on disk. An unsaved project has no resolvable bank dir;
// insert is a no-op rather than resolving against CWD (CLAUDE.md invariant).
const std::string projectDir = currentProjectDir();
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
const BankIndex& bank = session->bank();
const int mode = computeInsertMode(request.options);
// Wrap the whole placement in ONE undo block so a single undo removes every
// item inserted by this action (proj=nullptr -> active project). Opened before
// the first InsertMedia and closed after the last, unconditionally, so the block
// is always balanced even if nothing resolves (an empty block is harmless).
Undo_BeginBlock2(nullptr);
for (const std::string& id : ids) {
const Sample* sample = bank.query(id);
if (!sample) { ++result.skipped; continue; } // id no longer in the bank
const std::string abs = resolveBankFile(projectDir, sample->relativePath);
if (abs.empty()) { ++result.skipped; continue; } // unresolvable relative path
if (!fs::exists(fs::path(abs))) { ++result.skipped; continue; } // file missing
// Insert AT THE EDIT CURSOR (assumption A). InsertMedia advances the cursor
// to the end of the inserted media (assumption B), so the next iteration
// lands contiguously — no manual cursor math needed. Non-destructive to the
// bank: this references abs, it does not modify the file or the index.
InsertMedia(abs.c_str(), mode);
++result.inserted;
}
// Label reflects the count and the conform choice so the undo history reads
// clearly ("ReaSampler: insert 2 samples" etc.). extraflags 0 = default scope.
const std::string label =
"ReaSampler: insert " + std::to_string(result.inserted) +
(result.inserted == 1 ? " sample" : " samples") +
(request.options.conform == TempoConform::None ? "" : " (conform)");
Undo_EndBlock2(nullptr, label.c_str(), -1);
if (result.inserted == 0) result.status = InsertStatus::NothingResolved;
else result.status = InsertStatus::Ok;
return result;
}
} // namespace reasampler
+59
View File
@@ -0,0 +1,59 @@
#pragma once
// insert — placement of bank samples into the arrange (M6). REAPER-facing shell:
// it reads the bank_panel's current selection, resolves each selected sample's
// file, and drops it into the arrange at the edit cursor via InsertMedia, wrapped
// in an undo block.
//
// THE INTENDED PLACEMENT PATH (CONTEXT.md §load-bearing principle): capture NEVER
// auto-inserts; `insert` is the deliberate, user-invoked placement act, so it IS
// allowed and expected to add items to the arrange. It must only ever run from its
// own action — never from a capture path.
//
// Non-destructive to the bank: insert references the bank file (adds an arrange
// item pointing at it); it never modifies the bank, the bank files, or ext state.
// No SILENT time-stretch: conform-to-tempo is an explicit opt-in on the request,
// defaulting OFF (native length). See insert_plan for the mode-bit computation.
//
// The header is SDK-free: all REAPER API use lives in insert.cpp. The pure
// mode-bit arithmetic lives in insert_plan (unit-tested outside the DAW).
#include "insert_plan.h"
namespace reasampler {
class ReaSamplerSession;
// What one insert action does. Carries the InsertMedia options (target track +
// tempo-conform choice) so the two action variants (native-length vs
// conform-to-tempo) differ only by this struct — no divergent code paths.
struct InsertRequest {
InsertOptions options; // defaults: new track, no conform, native length
};
// The outcome of an insert action, for the caller to log to the console.
enum class InsertStatus {
Ok, // one or more samples inserted
NoSelection, // the panel had no selection — a no-op (not an error)
NoProject, // no saved project, so no resolvable bank dir — no-op
NothingResolved, // a selection existed but no sample resolved to a file
};
struct InsertResult {
InsertStatus status = InsertStatus::NoSelection;
int inserted = 0; // how many samples were actually placed
int skipped = 0; // selected-but-unresolvable/unreadable samples skipped
};
// Runs the insert: reads bank_panel's selection, resolves each sample against the
// current project dir, and inserts them AT THE EDIT CURSOR in bank order, advancing
// the cursor so multiple samples lay end-to-end. The whole placement is wrapped in
// a single Undo_BeginBlock2 / Undo_EndBlock2 so one undo removes the entire insert.
// `session` supplies the live bank the selected ids resolve against.
//
// Multi-select behavior: each selected sample is inserted sequentially at the
// then-current edit cursor; InsertMedia advances the cursor to the end of the
// inserted media, so N samples lay contiguously left-to-right. Single-select is the
// N==1 case of the same path.
InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request);
} // namespace reasampler
+49
View File
@@ -0,0 +1,49 @@
// insert_plan.cpp — see insert_plan.h. Pure InsertMedia mode-bit arithmetic.
#include "insert_plan.h"
namespace reasampler {
namespace {
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track).
constexpr int kBaseCurrentTrack = 0; // add to current track
constexpr int kBaseNewTrack = 1; // add new track
// Tempo-conform bits, verbatim from the header doc-comment.
constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x
constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x
constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x
constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo
} // namespace
int computeInsertMode(const InsertOptions& opts) {
int mode = opts.target == InsertTarget::NewTrack ? kBaseNewTrack
: kBaseCurrentTrack;
switch (opts.conform) {
case TempoConform::None:
// No tempo bits: native length, no stretch. (Also never &4.)
return mode;
case TempoConform::Ratio1x:
mode |= kMatchTempo1x;
break;
case TempoConform::RatioHalf:
mode |= kMatchTempoHalf;
break;
case TempoConform::RatioDouble:
mode |= kMatchTempoDbl;
break;
}
// Tempo bits are set (conform != None). Add the pitch-shift bit only when the
// caller asked NOT to preserve pitch. When conform == None we already returned
// above, so this can never fire without a tempo bit present.
if (!opts.preservePitch)
mode |= kDontPreservePitch;
return mode;
}
} // namespace reasampler
+75
View File
@@ -0,0 +1,75 @@
#pragma once
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing
// the InsertMedia `mode` bitmask from a small options struct.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW
// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to
// get wrong and its bits are load-bearing for the "no silent time-stretch"
// invariant, so it is factored here and unit-tested. The REAPER-bound placement
// (InsertMedia call, edit-cursor movement, undo block) lives in insert.cpp and is
// DAW-verified.
//
// The bit meanings below are transcribed VERBATIM from the authoritative header
// doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia):
// mode: 0=add to current track, 1=add new track, 3=add to selected items as
// takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x,
// &16=try to match tempo 0.5x, &32=try to match tempo 2x,
// &64=don't preserve pitch when matching tempo, ...
// We intentionally use only the base target (0/1) and the tempo-conform bits
// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is
// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals).
#include <cstdint>
namespace reasampler {
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3).
// We expose only the two placement targets M6 needs; "add as takes" (3) is a
// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's
// convention for base modes 0/1 (the header names no explicit edit-cursor bit;
// see the flagged runtime assumption in insert.cpp).
enum class InsertTarget {
NewTrack, // mode base 1: add a new track for the item
CurrentTrack, // mode base 0: add to the current/selected track
};
// Tempo-conform choice. Default is None: insert at the file's native length with
// NO stretching (the precision-preserving default). The three ratios are the
// explicit opt-in "try to match project tempo" paths — never applied silently.
// Ratio1x is the ordinary "conform to tempo"; Half/Double are the octave-shifted
// variants REAPER exposes for half/double-time material.
enum class TempoConform {
None, // no tempo bits set: native length, no stretch (default)
Ratio1x, // &8: try to match tempo 1x
RatioHalf,// &16: try to match tempo 0.5x
RatioDouble,// &32: try to match tempo 2x
};
// Options that shape one InsertMedia call. Defaults encode the safe path:
// new track, no conform, pitch preserved.
struct InsertOptions {
InsertTarget target = InsertTarget::NewTrack;
TempoConform conform = TempoConform::None;
// Only meaningful when conform != None. When true, adds &64 ("don't preserve
// pitch when matching tempo") so a tempo match also shifts pitch (classic
// varispeed). Default false = preserve pitch across the tempo match. Ignored
// when conform == None (no tempo bits set, so pitch is moot).
bool preservePitch = true;
};
// Computes the InsertMedia `mode` integer for the given options.
//
// Guarantees enforced here (and asserted in tests):
// * The &4 stretch-to-time-selection bit is NEVER set (no silent stretch).
// * When conform == None, NONE of the tempo bits (&8/&16/&32/&64) are set — the
// item lands at native length.
// * Exactly one base target bit pattern is used (0 or 1), never 3.
int computeInsertMode(const InsertOptions& opts);
// The forbidden stretch bit, exposed so a test can assert it is never present in
// any computed mode (the "no silent time-stretch" invariant, made checkable).
inline constexpr int kStretchToTimeSelBit = 4;
} // namespace reasampler
+86 -2
View File
@@ -24,6 +24,7 @@
#include "bank_model.h"
#include "bank_panel.h"
#include "capture.h"
#include "insert.h"
#include "persist.h"
#include "view.h"
@@ -52,6 +53,14 @@ static int g_cmdCaptureMasterSpike = 0;
// shows/hides it; it never captures, inserts, or mutates the bank.
static int g_cmdToggleBankPanel = 0;
// Command ids for the M6 insert actions. FOREVER-STABLE strings. Two variants that
// differ ONLY in the InsertOptions they build: the default inserts at native length
// (no stretch, no conform); the "conform" variant is the EXPLICIT opt-in to REAPER's
// try-to-match-project-tempo path (CONTEXT.md §insert: conform is opt-in, never
// silent). Both read the bank panel's current selection and place at the edit cursor.
static int g_cmdInsertSelected = 0;
static int g_cmdInsertSelectedConform = 0;
// The persistence session (M4): owns the in-memory BankIndex and bridges it to
// project ext state. A timer tick drives g_session.poll() to detect project
// load / Save-As; capture adds Samples to g_session.bank(); after a capture we
@@ -125,13 +134,55 @@ static void RunCaptureMasterSpike()
ShowConsoleMsg(log.c_str());
}
// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor
// via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match
// variant (never silent — it fires only from the distinct "conform" action). This
// is the INTENDED placement path: it adds items to the arrange on purpose
// (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action.
static void RunInsertSelected(bool conform)
{
reasampler::InsertRequest req;
req.options.target = reasampler::InsertTarget::NewTrack; // sensible default: own track
req.options.conform =
conform ? reasampler::TempoConform::Ratio1x : reasampler::TempoConform::None;
// preservePitch stays true: a tempo conform matches tempo without varispeeding
// pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.)
reasampler::InsertResult res = reasampler::runInsert(&g_session, req);
std::string msg;
switch (res.status)
{
case reasampler::InsertStatus::Ok:
msg = "ReaSampler: inserted " + std::to_string(res.inserted) +
(res.inserted == 1 ? " sample" : " samples") +
(conform ? " (conformed to tempo)" : " (native length)");
if (res.skipped > 0)
msg += ", skipped " + std::to_string(res.skipped) + " unresolvable";
msg += "\n";
break;
case reasampler::InsertStatus::NoSelection:
msg = "ReaSampler insert: nothing selected in the bank panel.\n";
break;
case reasampler::InsertStatus::NoProject:
msg = "ReaSampler insert: no saved project, so the bank has no location.\n";
break;
case reasampler::InsertStatus::NothingResolved:
msg = "ReaSampler insert: selected sample(s) could not be resolved to a file.\n";
break;
}
ShowConsoleMsg(msg.c_str());
}
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
// return false otherwise so REAPER keeps looking.
static bool OnHookCommand(int command, int /*flag*/)
{
if (command == 0) return false;
if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; }
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; }
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; }
if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; }
// Design View action family (D4). Claims only its own ids; returns false for the
// rest so this hook keeps looking (per the contract).
if (reasampler::designViewHandleCommand(command)) return true;
@@ -150,6 +201,8 @@ static int OnToggleAction(int command)
// gaccel storage must outlive registration — REAPER holds the pointer.
static gaccel_register_t g_accelCaptureMaster{};
static gaccel_register_t g_accelToggleBankPanel{};
static gaccel_register_t g_accelInsertSelected{};
static gaccel_register_t g_accelInsertSelectedConform{};
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
@@ -166,6 +219,12 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
// Tear down the Design View action family (D4) — mirror-unregisters each
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
reasampler::designViewUnregisterActions(g_rec);
g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM"));
g_rec->Register("-gaccel", (void*)&g_accelInsertSelected);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED"));
g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL"));
@@ -221,6 +280,31 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
rec->Register("toggleaction", (void*)&OnToggleAction);
}
// Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two
// variants: native-length (default, no stretch) and the EXPLICIT conform-to-
// tempo opt-in. Both read the bank panel selection and place at the edit cursor.
g_cmdInsertSelected = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED"));
if (g_cmdInsertSelected)
{
g_accelInsertSelected.accel.cmd = g_cmdInsertSelected;
g_accelInsertSelected.desc =
"ReaSampler: insert selected sample at edit cursor";
rec->Register("gaccel", (void*)&g_accelInsertSelected);
}
g_cmdInsertSelectedConform = rec->Register(
"command_id",
(void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM"));
if (g_cmdInsertSelectedConform)
{
g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform;
g_accelInsertSelectedConform.desc =
"ReaSampler: insert selected sample at edit cursor (conform to tempo)";
rec->Register("gaccel", (void*)&g_accelInsertSelectedConform);
}
// Register the Design View action family (D4): toggle/activate mode, tag/untag/
// show-both selected tracks. Each mints its own command_id + gaccel; the single
// hookcommand below routes them via designViewHandleCommand. Registered before