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:
+16
-1
@@ -65,6 +65,15 @@ add_library(view_tree STATIC src/view_tree.cpp)
|
||||
target_include_directories(view_tree PUBLIC src)
|
||||
target_link_libraries(view_tree PUBLIC view_mode_model)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2e) Pure insert_plan library — NO REAPER, NO SWELL. The InsertMedia `mode`
|
||||
# bitmask arithmetic behind the `insert` shell (M6). Split out so the
|
||||
# load-bearing bit computation (no silent stretch, opt-in conform) is
|
||||
# unit-tested outside the DAW; the InsertMedia call itself is DAW-verified.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(insert_plan STATIC src/insert_plan.cpp)
|
||||
target_include_directories(insert_plan PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -93,6 +102,10 @@ add_executable(view_tree_tests tests/test_view_tree.cpp)
|
||||
target_link_libraries(view_tree_tests PRIVATE view_tree)
|
||||
add_test(NAME view_tree_tests COMMAND view_tree_tests)
|
||||
|
||||
add_executable(insert_plan_tests tests/test_insert_plan.cpp)
|
||||
target_link_libraries(insert_plan_tests PRIVATE insert_plan)
|
||||
add_test(NAME insert_plan_tests COMMAND insert_plan_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -112,6 +125,8 @@ add_library(reaper_reasampler MODULE
|
||||
src/capture.cpp
|
||||
src/persist.cpp
|
||||
src/bank_panel.cpp
|
||||
src/insert.cpp
|
||||
src/insert_plan.cpp
|
||||
${LICE_SRC}
|
||||
src/view_mode_model.cpp
|
||||
src/view_tree.cpp
|
||||
@@ -119,7 +134,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/track_guid.cpp
|
||||
src/actions.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model insert_plan)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Standalone tests for reasampler::insert_plan — no REAPER, no framework.
|
||||
// The insert shell is DAW-bound; this covers the one genuinely pure piece: the
|
||||
// InsertMedia `mode` bitmask arithmetic. The bits are load-bearing for the
|
||||
// "no silent time-stretch" invariant, so the assertions here are the checkable
|
||||
// proof that the forbidden stretch bit is never set and that native-length insert
|
||||
// carries no tempo bits.
|
||||
|
||||
#include "../src/insert_plan.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <initializer_list>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// The tempo bits, mirrored here so the tests assert against literal expected
|
||||
// values independently of the .cpp's private constants (a test that reused the
|
||||
// implementation's constants would be tautological).
|
||||
constexpr int STRETCH_FIT = 4;
|
||||
constexpr int MATCH_1X = 8;
|
||||
constexpr int MATCH_HALF = 16;
|
||||
constexpr int MATCH_DBL = 32;
|
||||
constexpr int NO_PITCH = 64;
|
||||
|
||||
static void testDefaultIsNewTrackNativeLength() {
|
||||
// Defaults: new track (base 1), no conform, pitch preserved.
|
||||
InsertOptions opts;
|
||||
const int mode = computeInsertMode(opts);
|
||||
CHECK(mode == 1); // base 1 only, no other bits
|
||||
CHECK((mode & STRETCH_FIT) == 0); // never stretch-to-time-sel
|
||||
CHECK((mode & MATCH_1X) == 0); // no tempo bits at native length
|
||||
CHECK((mode & MATCH_HALF) == 0);
|
||||
CHECK((mode & MATCH_DBL) == 0);
|
||||
CHECK((mode & NO_PITCH) == 0);
|
||||
}
|
||||
|
||||
static void testCurrentTrackBaseIsZero() {
|
||||
InsertOptions opts;
|
||||
opts.target = InsertTarget::CurrentTrack;
|
||||
const int mode = computeInsertMode(opts);
|
||||
CHECK((mode & 3) == 0); // base 0 = add to current track
|
||||
CHECK((mode & STRETCH_FIT) == 0);
|
||||
CHECK((mode & (MATCH_1X | MATCH_HALF | MATCH_DBL | NO_PITCH)) == 0);
|
||||
}
|
||||
|
||||
static void testConform1xSetsOnlyMatchBit() {
|
||||
InsertOptions opts; // new track base 1
|
||||
opts.conform = TempoConform::Ratio1x;
|
||||
const int mode = computeInsertMode(opts);
|
||||
CHECK((mode & MATCH_1X) == MATCH_1X); // the 1x match bit is set
|
||||
CHECK((mode & 3) == 1); // base target unchanged
|
||||
CHECK((mode & STRETCH_FIT) == 0); // still never the stretch bit
|
||||
CHECK((mode & (MATCH_HALF | MATCH_DBL)) == 0); // no other ratio bits
|
||||
CHECK((mode & NO_PITCH) == 0); // pitch preserved by default
|
||||
}
|
||||
|
||||
static void testConformHalfAndDoubleRatios() {
|
||||
InsertOptions half;
|
||||
half.conform = TempoConform::RatioHalf;
|
||||
CHECK((computeInsertMode(half) & MATCH_HALF) == MATCH_HALF);
|
||||
CHECK((computeInsertMode(half) & (MATCH_1X | MATCH_DBL)) == 0);
|
||||
|
||||
InsertOptions dbl;
|
||||
dbl.conform = TempoConform::RatioDouble;
|
||||
CHECK((computeInsertMode(dbl) & MATCH_DBL) == MATCH_DBL);
|
||||
CHECK((computeInsertMode(dbl) & (MATCH_1X | MATCH_HALF)) == 0);
|
||||
}
|
||||
|
||||
static void testPreservePitchGatesTheNoPitchBit() {
|
||||
// preservePitch=false only takes effect when a tempo match is active.
|
||||
InsertOptions conformShiftPitch;
|
||||
conformShiftPitch.conform = TempoConform::Ratio1x;
|
||||
conformShiftPitch.preservePitch = false;
|
||||
CHECK((computeInsertMode(conformShiftPitch) & NO_PITCH) == NO_PITCH);
|
||||
|
||||
InsertOptions conformKeepPitch;
|
||||
conformKeepPitch.conform = TempoConform::Ratio1x;
|
||||
conformKeepPitch.preservePitch = true;
|
||||
CHECK((computeInsertMode(conformKeepPitch) & NO_PITCH) == 0);
|
||||
|
||||
// preservePitch=false with NO conform must NOT set the pitch bit (nothing to
|
||||
// pitch-shift; the bit is meaningless and would be spurious).
|
||||
InsertOptions noConformNoPitch;
|
||||
noConformNoPitch.conform = TempoConform::None;
|
||||
noConformNoPitch.preservePitch = false;
|
||||
CHECK((computeInsertMode(noConformNoPitch) & NO_PITCH) == 0);
|
||||
CHECK(computeInsertMode(noConformNoPitch) == 1); // just base 1, nothing else
|
||||
}
|
||||
|
||||
static void testStretchBitNeverSetAcrossAllOptions() {
|
||||
// Exhaustively enumerate every option combination and assert the forbidden
|
||||
// stretch-to-time-selection bit is absent from every computed mode. This is
|
||||
// the machine-checkable form of the "no silent time-stretch" invariant.
|
||||
const InsertTarget targets[] = {InsertTarget::NewTrack, InsertTarget::CurrentTrack};
|
||||
const TempoConform conforms[] = {TempoConform::None, TempoConform::Ratio1x,
|
||||
TempoConform::RatioHalf, TempoConform::RatioDouble};
|
||||
for (InsertTarget t : targets)
|
||||
for (TempoConform c : conforms)
|
||||
for (bool pp : {true, false}) {
|
||||
InsertOptions o;
|
||||
o.target = t; o.conform = c; o.preservePitch = pp;
|
||||
CHECK((computeInsertMode(o) & kStretchToTimeSelBit) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDefaultIsNewTrackNativeLength();
|
||||
testCurrentTrackBaseIsZero();
|
||||
testConform1xSetsOnlyMatchBit();
|
||||
testConformHalfAndDoubleRatios();
|
||||
testPreservePitchGatesTheNoPitchBit();
|
||||
testStretchBitNeverSetAcrossAllOptions();
|
||||
|
||||
if (g_fail == 0) std::printf("insert_plan: all tests passed\n");
|
||||
else std::printf("insert_plan: %d FAILED\n", g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user