From 172d5902c8ecef8cb4b61c8155399d66aadddab4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 05:23:37 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20M6=20insert=20=E2=80=94=20place?= =?UTF-8?q?=20selected=20bank=20sample=20at=20edit=20cursor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CMakeLists.txt | 17 +++++- src/bank_panel.cpp | 16 +++++ src/bank_panel.h | 18 +++++- src/insert.cpp | 120 +++++++++++++++++++++++++++++++++++++ src/insert.h | 59 ++++++++++++++++++ src/insert_plan.cpp | 49 +++++++++++++++ src/insert_plan.h | 75 +++++++++++++++++++++++ src/main.cpp | 88 ++++++++++++++++++++++++++- tests/test_insert_plan.cpp | 120 +++++++++++++++++++++++++++++++++++++ 9 files changed, 557 insertions(+), 5 deletions(-) create mode 100644 src/insert.cpp create mode 100644 src/insert.h create mode 100644 src/insert_plan.cpp create mode 100644 src/insert_plan.h create mode 100644 tests/test_insert_plan.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 214eaec..be892eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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") diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index d5a6c86..f87bcd7 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -778,6 +778,22 @@ bool bankPanelIsOpen() { return g_panel.open; } +std::vector bankPanelSelectedSampleIds() { + std::vector ids; + const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr; + if (!bank) return ids; + const std::vector& samples = bank->all(); + const int count = static_cast(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(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 diff --git a/src/bank_panel.h b/src/bank_panel.h index ed69ce9..782658f 100644 --- a/src/bank_panel.h +++ b/src/bank_panel.h @@ -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 +#include + 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 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. diff --git a/src/insert.cpp b/src/insert.cpp new file mode 100644 index 0000000..34b2843 --- /dev/null +++ b/src/insert.cpp @@ -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 +#include +#include + +#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 buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(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 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 diff --git a/src/insert.h b/src/insert.h new file mode 100644 index 0000000..a9e2c7d --- /dev/null +++ b/src/insert.h @@ -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 diff --git a/src/insert_plan.cpp b/src/insert_plan.cpp new file mode 100644 index 0000000..9c3983e --- /dev/null +++ b/src/insert_plan.cpp @@ -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 diff --git a/src/insert_plan.h b/src/insert_plan.h new file mode 100644 index 0000000..8f589e6 --- /dev/null +++ b/src/insert_plan.h @@ -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 + +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 diff --git a/src/main.cpp b/src/main.cpp index 0f0648f..3096aee 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 diff --git a/tests/test_insert_plan.cpp b/tests/test_insert_plan.cpp new file mode 100644 index 0000000..8f883c2 --- /dev/null +++ b/tests/test_insert_plan.cpp @@ -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 +#include + +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; +}