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_include_directories(view_tree PUBLIC src)
|
||||||
target_link_libraries(view_tree PUBLIC view_mode_model)
|
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).
|
# 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)
|
target_link_libraries(view_tree_tests PRIVATE view_tree)
|
||||||
add_test(NAME view_tree_tests COMMAND view_tree_tests)
|
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).
|
# 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/capture.cpp
|
||||||
src/persist.cpp
|
src/persist.cpp
|
||||||
src/bank_panel.cpp
|
src/bank_panel.cpp
|
||||||
|
src/insert.cpp
|
||||||
|
src/insert_plan.cpp
|
||||||
${LICE_SRC}
|
${LICE_SRC}
|
||||||
src/view_mode_model.cpp
|
src/view_mode_model.cpp
|
||||||
src/view_tree.cpp
|
src/view_tree.cpp
|
||||||
@@ -119,7 +134,7 @@ add_library(reaper_reasampler MODULE
|
|||||||
src/track_guid.cpp
|
src/track_guid.cpp
|
||||||
src/actions.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})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||||
|
|
||||||
|
|||||||
@@ -778,6 +778,22 @@ bool bankPanelIsOpen() {
|
|||||||
return g_panel.open;
|
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() {
|
void bankPanelRefresh() {
|
||||||
if (!g_panel.open || !g_panel.hwnd) return;
|
if (!g_panel.open || !g_panel.hwnd) return;
|
||||||
// Repaint only when the bank actually changed (generation bump). Cheap tick
|
// Repaint only when the bank actually changed (generation bump). Cheap tick
|
||||||
|
|||||||
+16
-2
@@ -1,8 +1,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns
|
// 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
|
// 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
|
// bank as a grid of LICE-drawn waveform thumbnails. The panel itself NEVER inserts
|
||||||
// inserts into the arrange or mutates the project/bank (CONTEXT.md §load-bearing
|
// into the arrange or mutates the project/bank (CONTEXT.md §load-bearing
|
||||||
// principle). Audition / multi-select / keyboard nav are Wave B.
|
// principle). Audition / multi-select / keyboard nav are Wave B.
|
||||||
//
|
//
|
||||||
// The header is REAPER-free as practical: main.cpp drives the panel through these
|
// 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
|
// 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).
|
// layout math and cache keys live in bank_grid (unit-tested outside the DAW).
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
class ReaSamplerSession;
|
class ReaSamplerSession;
|
||||||
@@ -29,6 +32,17 @@ void bankPanelToggle();
|
|||||||
// checked-state (toggleaction) so REAPER shows a tick next to the menu entry.
|
// checked-state (toggleaction) so REAPER shows a tick next to the menu entry.
|
||||||
bool bankPanelIsOpen();
|
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).
|
// 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
|
// Cheap when nothing changed. Driven by the timer so a capture / project load is
|
||||||
// reflected without the panel diffing the bank itself.
|
// 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_model.h"
|
||||||
#include "bank_panel.h"
|
#include "bank_panel.h"
|
||||||
#include "capture.h"
|
#include "capture.h"
|
||||||
|
#include "insert.h"
|
||||||
#include "persist.h"
|
#include "persist.h"
|
||||||
#include "view.h"
|
#include "view.h"
|
||||||
|
|
||||||
@@ -52,6 +53,14 @@ static int g_cmdCaptureMasterSpike = 0;
|
|||||||
// shows/hides it; it never captures, inserts, or mutates the bank.
|
// shows/hides it; it never captures, inserts, or mutates the bank.
|
||||||
static int g_cmdToggleBankPanel = 0;
|
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
|
// 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
|
// 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
|
// 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());
|
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,
|
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
|
||||||
// return false otherwise so REAPER keeps looking.
|
// return false otherwise so REAPER keeps looking.
|
||||||
static bool OnHookCommand(int command, int /*flag*/)
|
static bool OnHookCommand(int command, int /*flag*/)
|
||||||
{
|
{
|
||||||
if (command == 0) return false;
|
if (command == 0) return false;
|
||||||
if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; }
|
if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; }
|
||||||
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); 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
|
// Design View action family (D4). Claims only its own ids; returns false for the
|
||||||
// rest so this hook keeps looking (per the contract).
|
// rest so this hook keeps looking (per the contract).
|
||||||
if (reasampler::designViewHandleCommand(command)) return true;
|
if (reasampler::designViewHandleCommand(command)) return true;
|
||||||
@@ -150,6 +201,8 @@ static int OnToggleAction(int command)
|
|||||||
// gaccel storage must outlive registration — REAPER holds the pointer.
|
// gaccel storage must outlive registration — REAPER holds the pointer.
|
||||||
static gaccel_register_t g_accelCaptureMaster{};
|
static gaccel_register_t g_accelCaptureMaster{};
|
||||||
static gaccel_register_t g_accelToggleBankPanel{};
|
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(
|
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||||
REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
|
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
|
// Tear down the Design View action family (D4) — mirror-unregisters each
|
||||||
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
|
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
|
||||||
reasampler::designViewUnregisterActions(g_rec);
|
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("-gaccel", (void*)&g_accelToggleBankPanel);
|
||||||
g_rec->Register("-command_id",
|
g_rec->Register("-command_id",
|
||||||
(void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL"));
|
(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);
|
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/
|
// 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
|
// show-both selected tracks. Each mints its own command_id + gaccel; the single
|
||||||
// hookcommand below routes them via designViewHandleCommand. Registered before
|
// 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