Merge ps-w7-ingest: S8 ingest through the bank (extension side)
This commit is contained in:
+19
-1
@@ -301,6 +301,19 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge
|
||||
add_library(provenance STATIC src/provenance.cpp)
|
||||
target_include_directories(provenance PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2j') Pure assignment_request library — NO REAPER, NO SWELL, NO VST3. The S8 ingest
|
||||
# assignment-request wire format: the (bankId, sampleId, generation) value the
|
||||
# EXTENSION writes to "reasampler" ext-state after an ingest-with-assign, decoded by
|
||||
# the VST3 instrument in a later dispatch. Only the wire (build/parse round-trip)
|
||||
# lives here — writing it is the persist shell's job, reading it the instrument's.
|
||||
# Split out (mirror of provenance / owned_manifest) so the format both artifacts
|
||||
# depend on is unit-tested outside the DAW; the reader lands in a separate artifact,
|
||||
# so the round-trip test is the contract guard. No dependency — plain strings + int64.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(assignment_request STATIC src/assignment_request.cpp)
|
||||
target_include_directories(assignment_request PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2k) Pure action_buttons library — NO REAPER, NO SWELL. The Milestone 11
|
||||
# action-trigger button strip: strip rect + N buttons at a minimum width ->
|
||||
@@ -600,6 +613,10 @@ add_executable(card_drag_tests tests/test_card_drag.cpp)
|
||||
target_link_libraries(card_drag_tests PRIVATE card_drag)
|
||||
add_test(NAME card_drag_tests COMMAND card_drag_tests)
|
||||
|
||||
add_executable(assignment_request_tests tests/test_assignment_request.cpp)
|
||||
target_link_libraries(assignment_request_tests PRIVATE assignment_request)
|
||||
add_test(NAME assignment_request_tests COMMAND assignment_request_tests)
|
||||
|
||||
# sampler_core: the S3 heart. Links ONLY sampler_core (+ its peaks dep) — NEITHER the
|
||||
# VST3 SDK nor the REAPER SDK — which is the structural proof of the plain-data
|
||||
# boundary (a VST3/REAPER type in the core would fail to compile/link here).
|
||||
@@ -741,6 +758,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/lane_keys.cpp
|
||||
src/item_read.cpp
|
||||
src/actions.cpp
|
||||
src/ingest.cpp
|
||||
src/bank_book.cpp
|
||||
src/owned_manifest.cpp
|
||||
src/drag_out_win.cpp
|
||||
@@ -752,7 +770,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/card_meta.cpp
|
||||
src/card_drag.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
|
||||
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// assignment_request.cpp — see assignment_request.h. Pure: standard library only.
|
||||
|
||||
#include "assignment_request.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kMagic = "rsassign1";
|
||||
|
||||
// Append one length-prefixed field: <decimal-len> ':' <bytes>. Mirror of
|
||||
// provenance's putField so the two seams share one wire idiom.
|
||||
void putField(std::string& out, const std::string& field) {
|
||||
out += std::to_string(field.size());
|
||||
out += ':';
|
||||
out += field;
|
||||
}
|
||||
|
||||
// Cursor over the encoded string. All reads are bounds-checked; a short read fails
|
||||
// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the
|
||||
// three field kinds this record needs.
|
||||
class Cursor {
|
||||
public:
|
||||
explicit Cursor(const std::string& s) : s_(s) {}
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
bool atEnd() const { return pos_ >= s_.size(); }
|
||||
|
||||
// Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or
|
||||
// non-numeric length, a length that overflows SIZE_MAX, or a length that runs past
|
||||
// the end. The digit count is capped at 20 (the decimal width of SIZE_MAX on a
|
||||
// 64-bit host) so a crafted 200-digit length cannot accumulate past SIZE_MAX via
|
||||
// repeated multiply. "never UB" promise from the header is upheld here.
|
||||
bool field(std::string& out) {
|
||||
if (!ok_) return false;
|
||||
const std::size_t colon = s_.find(':', pos_);
|
||||
if (colon == std::string::npos) return fail();
|
||||
if (colon == pos_) return fail(); // empty length token
|
||||
// Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus.
|
||||
if (colon - pos_ > 20u) return fail();
|
||||
std::size_t len = 0;
|
||||
for (std::size_t i = pos_; i < colon; ++i) {
|
||||
const char c = s_[i];
|
||||
if (c < '0' || c > '9') return fail();
|
||||
const std::size_t digit = static_cast<std::size_t>(c - '0');
|
||||
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
|
||||
if (len > (std::numeric_limits<std::size_t>::max() - digit) / 10u)
|
||||
return fail();
|
||||
len = len * 10u + digit;
|
||||
}
|
||||
const std::size_t start = colon + 1;
|
||||
// Guard: start may equal s_.size() (empty remainder), in which case only len==0
|
||||
// is valid; start > s_.size() cannot happen (colon < s_.size() by find()).
|
||||
// Use subtraction-first form to avoid start+len wrapping on a huge len.
|
||||
if (start > s_.size() || len > s_.size() - start) return fail();
|
||||
out.assign(s_, start, len);
|
||||
pos_ = start + len;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reads a length-prefixed field and parses it as a signed 64-bit decimal (an
|
||||
// optional leading '-'). Fails on empty, non-digit, trailing bytes, or a value
|
||||
// that would overflow INT64_MAX / underflow INT64_MIN. The digit count is capped
|
||||
// at 19 (the decimal width of INT64_MAX, plus 1 for the optional sign = 20
|
||||
// characters maximum) so a crafted 21-digit field cannot accumulate UB. "never UB"
|
||||
// promise from the header is upheld: all arithmetic is done on positive digits
|
||||
// and capped before applying the sign.
|
||||
bool fieldInt64(std::int64_t& out) {
|
||||
std::string f;
|
||||
if (!field(f)) return false;
|
||||
if (f.empty()) return fail();
|
||||
std::size_t i = 0;
|
||||
bool neg = false;
|
||||
if (f[0] == '-') {
|
||||
neg = true;
|
||||
i = 1;
|
||||
if (f.size() == 1) return fail(); // bare "-"
|
||||
}
|
||||
// Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit
|
||||
// positive value would overflow INT64_MAX; a 20-digit negative might be valid
|
||||
// (INT64_MIN = -9223372036854775808) but we conservatively reject it too: the
|
||||
// generation field is a unix timestamp, never near INT64 limits in practice.
|
||||
if (f.size() - i > 19u) return fail();
|
||||
std::int64_t v = 0;
|
||||
for (; i < f.size(); ++i) {
|
||||
const char c = f[i];
|
||||
if (c < '0' || c > '9') return fail();
|
||||
const std::int64_t digit = static_cast<std::int64_t>(c - '0');
|
||||
// Overflow guard: v * 10 + digit must not exceed INT64_MAX.
|
||||
if (v > (std::numeric_limits<std::int64_t>::max() - digit) / 10)
|
||||
return fail();
|
||||
v = v * 10 + digit;
|
||||
}
|
||||
out = neg ? -v : v;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Consumes an exact literal at the cursor (the magic tag). Fails if absent.
|
||||
bool literal(const char* lit) {
|
||||
if (!ok_) return false;
|
||||
std::size_t i = 0;
|
||||
for (; lit[i] != '\0'; ++i) {
|
||||
if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail();
|
||||
}
|
||||
pos_ += i;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
bool fail() {
|
||||
ok_ = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string& s_;
|
||||
std::size_t pos_ = 0;
|
||||
bool ok_ = true;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string encodeAssignmentRequest(const AssignmentRequest& req) {
|
||||
std::string out = kMagic;
|
||||
putField(out, req.bankId);
|
||||
putField(out, req.sampleId);
|
||||
putField(out, std::to_string(req.generation));
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<AssignmentRequest> decodeAssignmentRequest(const std::string& wire) {
|
||||
Cursor cur(wire);
|
||||
if (!cur.literal(kMagic)) return std::nullopt;
|
||||
|
||||
AssignmentRequest req;
|
||||
if (!cur.field(req.bankId)) return std::nullopt;
|
||||
if (!cur.field(req.sampleId)) return std::nullopt;
|
||||
if (!cur.fieldInt64(req.generation)) return std::nullopt;
|
||||
|
||||
// Reject trailing garbage: a well-formed value ends exactly at the last field.
|
||||
if (!cur.ok() || !cur.atEnd()) return std::nullopt;
|
||||
return req;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
// assignment_request — the pure core of the S8 ingest assignment-request seam.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3,
|
||||
// NO vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
|
||||
// "small pure type + length-prefixed round-trip" pattern as provenance / owned_manifest.
|
||||
//
|
||||
// -- What it is --------------------------------------------------------------
|
||||
//
|
||||
// When the EXTENSION ingests a sample (S8: arrange capture / Media-Explorer import /
|
||||
// drop-onto-panel) it writes an ASSIGNMENT REQUEST to its own "reasampler" ext-state
|
||||
// namespace: "the active sampler instance should now play THIS sample." The value
|
||||
// names the ingested sample by (bankId, sampleId) plus a monotonic `generation` the
|
||||
// reader compares to decide the request is NEW (a fresh ingest, even of the same id).
|
||||
//
|
||||
// This module owns ONLY the value's WIRE FORMAT — build/parse round-trip. Writing it
|
||||
// to ext-state is the persist shell's job; READING it is the instrument's job in a
|
||||
// LATER dispatch (S8 instrument-side follow-up, after S10 merges). This is why the
|
||||
// format is documented here in the header, not just in code: the reader lands elsewhere
|
||||
// and must decode exactly what this writer produced.
|
||||
//
|
||||
// -- The data-ownership boundary (load-bearing) ------------------------------
|
||||
//
|
||||
// The EXTENSION writes this; the instrument only READS it. That does not violate the
|
||||
// instrument's read-only-over-the-bank rule: the assignment request is the extension
|
||||
// writing its OWN namespace (a request FROM the extension TO the instrument), never the
|
||||
// instrument writing back into the bank. The instrument, on reading a new generation,
|
||||
// updates its OWN component-state selection (the same selection S4 persists) and reloads.
|
||||
//
|
||||
// -- Why `generation` -------------------------------------------------------
|
||||
//
|
||||
// Instances reference sample IDs, so re-assigning the SAME id (e.g. a recapture, or a
|
||||
// re-drop of the same file) would be indistinguishable from a stale value without a
|
||||
// changing field. `generation` is a monotonic disambiguator (the ingest writer supplies
|
||||
// a wall-clock unix-epoch stamp today — see the writer shell) so the reader can tell
|
||||
// "assigned again just now" from "already saw this." It is DELIBERATELY the same shape
|
||||
// the S9 bank-generation counter will use, but it is NOT that counter — S9 is a separate
|
||||
// point; this field is self-contained to the request and does not depend on S9 landing.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// One assignment request: the ingested sample's identity + a monotonic disambiguator.
|
||||
// bankId — the bank the sample was ingested into (the active/target bank).
|
||||
// sampleId — the ingested Sample's stable id (BankIndex key).
|
||||
// generation — a monotonic value the reader compares to detect a NEW request. The
|
||||
// writer supplies a unix-epoch-seconds stamp; the reader treats it as an
|
||||
// opaque "did this change?" token, not a wall-clock it interprets.
|
||||
struct AssignmentRequest {
|
||||
std::string bankId;
|
||||
std::string sampleId;
|
||||
std::int64_t generation = 0;
|
||||
|
||||
bool operator==(const AssignmentRequest& o) const {
|
||||
return bankId == o.bankId && sampleId == o.sampleId &&
|
||||
generation == o.generation;
|
||||
}
|
||||
bool operator!=(const AssignmentRequest& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
// Encode an assignment request to the wire string. Length-prefixed fields behind a
|
||||
// magic+version tag ("rsassign1"), so arbitrary bytes in an id (a GUID, a display-
|
||||
// derived id) round-trip whole with no escaping ambiguity — the same idiom provenance
|
||||
// uses. Deterministic: the same request always yields the same string.
|
||||
//
|
||||
// FORMAT (documented for the LATER instrument-side reader):
|
||||
// "rsassign1" <len>':'<bankId> <len>':'<sampleId> <len>':'<generation-decimal>
|
||||
// where each <len> is the decimal byte length of the field that follows the ':'.
|
||||
std::string encodeAssignmentRequest(const AssignmentRequest& req);
|
||||
|
||||
// Parse a wire string produced by encodeAssignmentRequest. std::nullopt on any
|
||||
// malformed / truncated / trailing-garbage input (never UB, never a partial value) —
|
||||
// the reader shell treats absence/malformed as "no pending request." Round-trips:
|
||||
// decodeAssignmentRequest(encodeAssignmentRequest(x)) == x.
|
||||
//
|
||||
// READER REQUIREMENT (instrument-side, S8 follow-up dispatch): after successfully
|
||||
// decoding a request, the reader MUST verify that (bankId, sampleId) resolves to an
|
||||
// existing sample before acting on it. An undo on the extension side rolls back the
|
||||
// `banks` ext-state key (removing the sample) but cannot atomically clear the
|
||||
// `assign_request` key if the write happened outside the undo block. Even with the
|
||||
// undo-grouping fix (Major 2), the reader must guard against this: treat an
|
||||
// unresolvable (bankId, sampleId) pair as a stale/no-op request and discard it
|
||||
// silently, never crashing or selecting a nonexistent entry.
|
||||
std::optional<AssignmentRequest> decodeAssignmentRequest(const std::string& wire);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -61,6 +61,7 @@
|
||||
#include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1)
|
||||
#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4)
|
||||
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
|
||||
#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest
|
||||
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
|
||||
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
|
||||
#include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5)
|
||||
@@ -82,6 +83,7 @@
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
||||
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest
|
||||
#else
|
||||
#include <pthread.h>
|
||||
#endif
|
||||
@@ -3075,8 +3077,35 @@ void handleRightClick(int x, int y) {
|
||||
|
||||
// --- Dialog proc + docking ----------------------------------------------------
|
||||
|
||||
// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands
|
||||
// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all and assigns
|
||||
// the first. Always DragFinish's the HDROP (frees the shell-allocated drop buffer) on every
|
||||
// path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; then each path is
|
||||
// queried by index. Both Win32 and SWELL expose DragQueryFile/DragFinish with this contract.
|
||||
void handleDropFiles(HDROP hDrop) {
|
||||
std::vector<std::string> paths;
|
||||
const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0);
|
||||
paths.reserve(count);
|
||||
for (UINT i = 0; i < count; ++i) {
|
||||
// Query the required length first (excludes the NUL), then read into a sized buffer.
|
||||
const UINT len = DragQueryFile(hDrop, i, nullptr, 0);
|
||||
if (len == 0) continue;
|
||||
std::vector<char> buf(static_cast<std::size_t>(len) + 1, '\0');
|
||||
DragQueryFile(hDrop, i, buf.data(), static_cast<UINT>(buf.size()));
|
||||
std::string p(buf.data());
|
||||
if (!p.empty()) paths.push_back(std::move(p));
|
||||
}
|
||||
DragFinish(hDrop);
|
||||
if (!paths.empty()) ingestDroppedFiles(paths);
|
||||
}
|
||||
|
||||
WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
switch (msg) {
|
||||
case WM_DROPFILES:
|
||||
// S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import
|
||||
// into the active bank + assign the first. wParam is the HDROP.
|
||||
handleDropFiles(reinterpret_cast<HDROP>(wParam));
|
||||
return 0;
|
||||
case WM_PAINT: {
|
||||
PAINTSTRUCT ps;
|
||||
HDC hdc = BeginPaint(hwnd, &ps);
|
||||
@@ -3172,6 +3201,17 @@ void openPanel() {
|
||||
DockWindowActivate(g_panel.hwnd);
|
||||
g_panel.open = true;
|
||||
|
||||
// S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles).
|
||||
// DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it,
|
||||
// so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3
|
||||
// instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration
|
||||
// surface is out of scope for this dispatch. WM_DROPFILES handling itself uses
|
||||
// DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means
|
||||
// would still ingest — only the accept opt-in is gated.
|
||||
#ifdef _WIN32
|
||||
DragAcceptFiles(g_panel.hwnd, TRUE);
|
||||
#endif
|
||||
|
||||
registerAccel();
|
||||
|
||||
reconcileShownBank();
|
||||
|
||||
@@ -45,4 +45,15 @@ inline constexpr const char* kProjExtTailKey = "tail_setting";
|
||||
// The per-project minted-GUID identity key.
|
||||
inline constexpr const char* kProjExtGuidKey = "project_guid";
|
||||
|
||||
// The S8 ingest ASSIGNMENT-REQUEST key. The EXTENSION writes an assignment request here
|
||||
// after an ingest-with-assign (arrange capture / Media-Explorer import / drop-onto-panel):
|
||||
// "the active sampler instance should now play THIS sample." The value is the pure
|
||||
// assignment_request wire format ("rsassign1" + bankId + sampleId + generation) — see
|
||||
// assignment_request.h for the exact grammar. WIRE-SHARED because the VST3 instrument
|
||||
// READS it (in a later dispatch, S8 instrument-side follow-up) to update its own selection
|
||||
// and reload; the instrument never WRITES it (the extension writing its own namespace does
|
||||
// not violate the instrument's read-only-over-the-bank rule). FOREVER-STABLE once shipped:
|
||||
// changing this spelling strands any pending request an already-shipped instrument watches.
|
||||
inline constexpr const char* kProjExtAssignKey = "assign_request";
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
// ingest.cpp — the S8 "ingest through the bank" shell (extension side). See ingest.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). REAPER-facing, DAW-verified; the pure serialization it drives
|
||||
// (assignment_request) is CTest-tested.
|
||||
|
||||
#include "ingest.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path)
|
||||
#include "app_version.h" // channelCommandId / channelActionName
|
||||
#include "assignment_request.h" // pure (bankId, sampleId, generation) encode
|
||||
#include "bank_book.h" // BankBook, Bank, activeBankId / activeIndex
|
||||
#include "bank_model.h" // Sample, AddResult, findByHash
|
||||
#include "bank_panel.h" // bankPanelRefresh
|
||||
#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent
|
||||
#include "persist.h" // ReaSamplerSession
|
||||
|
||||
#include "wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path
|
||||
|
||||
#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs)
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_MediaExplorerGetLastPlayedFileInfo
|
||||
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
|
||||
#define REAPERAPI_WANT_PCM_Source_Destroy
|
||||
#define REAPERAPI_WANT_GetMediaSourceNumChannels
|
||||
#define REAPERAPI_WANT_GetMediaSourceSampleRate
|
||||
#define REAPERAPI_WANT_GetMediaSourceLength
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// The live session the ingest paths mutate. Set once by ingestRegisterActions and read by
|
||||
// every ingest body. Not owned here (main.cpp owns g_session).
|
||||
ReaSamplerSession* g_session = nullptr;
|
||||
|
||||
// FOREVER-STABLE ingest action-id SUFFIX (Phase V, V4). The channel prefix is prepended at
|
||||
// register via channelCommandId; NEVER change a shipped suffix. Only the Media-Explorer
|
||||
// import registers here — the arrange capture+assign action lives in the capture family in
|
||||
// main.cpp (it reuses the capture render machinery there), and the drop path is a panel
|
||||
// callback (ingestDroppedFiles), not a bindable action.
|
||||
constexpr const char* kIdImportMediaExplorer = "INGEST_IMPORT_MEDIA_EXPLORER";
|
||||
|
||||
int g_cmdImportMediaExplorer = 0;
|
||||
gaccel_register_t g_accelImportMediaExplorer{};
|
||||
|
||||
// Durable store of the composed, channel-qualified command-id + label strings. Two scalar
|
||||
// std::string globals (one action); their c_str() pointers are handed to REAPER at register
|
||||
// and re-presented at unregister, so these strings must not be mutated after registration.
|
||||
// Populated once by ingestRegisterActions; stable for the extension lifetime.
|
||||
std::string g_idImportStr;
|
||||
std::string g_labelImportStr;
|
||||
|
||||
// --- Project directory --------------------------------------------------------
|
||||
|
||||
// The current project's directory (parent of its .rpp), forward-slashed, no trailing
|
||||
// slash — the M4 convention (projectDirOfRpp). Empty for an unsaved/no-active project,
|
||||
// which makes the import refuse to place a file (no default-location fallback — the
|
||||
// relative-paths invariant). Read-only.
|
||||
std::string currentProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror
|
||||
// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy.
|
||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return {};
|
||||
const std::streamsize n = f.tellg();
|
||||
if (n <= 0) return {};
|
||||
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(n));
|
||||
f.seekg(0);
|
||||
f.read(reinterpret_cast<char*>(bytes.data()), n);
|
||||
if (!f) return {};
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Writes a byte buffer to a file. Returns true on success. The caller is responsible for
|
||||
// ensuring the directory exists before calling.
|
||||
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
if (!f) return false;
|
||||
f.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
return f.good();
|
||||
}
|
||||
|
||||
// Builds a minimal 32-bit-float RIFF/WAVE byte buffer from interleaved double samples.
|
||||
// The output is a canonical WAV the bank and wav_trim can read:
|
||||
// RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, 16-byte body),
|
||||
// data chunk (interleaved little-endian float32, one float per sample per channel).
|
||||
// `nch` channels, `rate` Hz sample rate, `frameCount` frames (total samples = frameCount*nch).
|
||||
// Each ReaSample (double) is narrowed to float by assignment — the instrument expects
|
||||
// 32-bit float; the reduction is intentional and matches how the bank contract is defined
|
||||
// (capture.cpp kRenderFormatWavFloat32; wav_trim.h FORMAT ASSUMPTION).
|
||||
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
std::size_t frameCount,
|
||||
const std::vector<ReaSample>& interleaved) {
|
||||
const std::size_t sampleCount = frameCount * static_cast<std::size_t>(nch);
|
||||
const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32
|
||||
|
||||
// The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload.
|
||||
// Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount.
|
||||
const std::uint32_t riffSize =
|
||||
static_cast<std::uint32_t>(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data
|
||||
|
||||
std::vector<std::uint8_t> out;
|
||||
out.reserve(44u + dataBytesCount);
|
||||
|
||||
auto putU16 = [&](std::uint16_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
};
|
||||
auto putU32 = [&](std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||
};
|
||||
auto putTag = [&](const char* t) {
|
||||
for (int i = 0; i < 4; ++i)
|
||||
out.push_back(static_cast<std::uint8_t>(t[i]));
|
||||
};
|
||||
auto putF32 = [&](float f) {
|
||||
std::uint8_t tmp[4];
|
||||
std::memcpy(tmp, &f, 4);
|
||||
for (int i = 0; i < 4; ++i) out.push_back(tmp[i]);
|
||||
};
|
||||
|
||||
// RIFF header
|
||||
putTag("RIFF");
|
||||
putU32(riffSize);
|
||||
putTag("WAVE");
|
||||
|
||||
// fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003)
|
||||
putTag("fmt ");
|
||||
putU32(16u); // chunk body size
|
||||
putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT
|
||||
putU16(static_cast<std::uint16_t>(nch));
|
||||
putU32(rate);
|
||||
putU32(rate * static_cast<std::uint32_t>(nch) * 4u); // avgBytesPerSec
|
||||
putU16(static_cast<std::uint16_t>(nch * 4)); // blockAlign
|
||||
putU16(32u); // bitsPerSample
|
||||
|
||||
// data chunk
|
||||
putTag("data");
|
||||
putU32(static_cast<std::uint32_t>(dataBytesCount));
|
||||
for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
|
||||
putF32(static_cast<float>(interleaved[i]));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Decodes ALL samples from `src` into interleaved double-precision frames.
|
||||
// Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0).
|
||||
// Uses GetSamples in blocks; advances time_s monotonically. The caller has already
|
||||
// queried channelCount and sampleRate from the same source; those values are passed in
|
||||
// to avoid re-querying after GetSamples mutates decoder state.
|
||||
std::vector<ReaSample> decodePcmSource(PCM_source* src, int nch, double sampleRate,
|
||||
double lengthSeconds) {
|
||||
if (!src || nch <= 0 || sampleRate < 1.0 || lengthSeconds <= 0.0) return {};
|
||||
|
||||
const std::size_t totalFrames =
|
||||
static_cast<std::size_t>(lengthSeconds * sampleRate + 0.5);
|
||||
if (totalFrames == 0) return {};
|
||||
|
||||
std::vector<ReaSample> out;
|
||||
out.reserve(totalFrames * static_cast<std::size_t>(nch));
|
||||
|
||||
// Pull samples in blocks of ~4096 frames; loop until source is exhausted.
|
||||
constexpr int kBlockFrames = 4096;
|
||||
std::vector<ReaSample> block(static_cast<std::size_t>(kBlockFrames * nch));
|
||||
|
||||
PCM_source_transfer_t t{};
|
||||
t.samplerate = sampleRate;
|
||||
t.nch = nch;
|
||||
t.time_s = 0.0;
|
||||
t.midi_events = nullptr;
|
||||
|
||||
while (true) {
|
||||
t.samples = block.data();
|
||||
t.length = kBlockFrames;
|
||||
t.samples_out = 0;
|
||||
src->GetSamples(&t);
|
||||
if (t.samples_out <= 0) break;
|
||||
const std::size_t got = static_cast<std::size_t>(t.samples_out) *
|
||||
static_cast<std::size_t>(nch);
|
||||
out.insert(out.end(), block.data(), block.data() + got);
|
||||
t.time_s += static_cast<double>(t.samples_out) / sampleRate;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The result of an import-into-bank: the sample id to assign (the existing id on a
|
||||
// hash-dedup collapse, the new id otherwise) and whether anything was added to the index
|
||||
// (so the caller opens an undo point only for a real mutation).
|
||||
struct ImportResult {
|
||||
std::string sampleId; // "" on failure (nothing to assign)
|
||||
bool added = false; // true iff a NEW index entry was created (not a collapse)
|
||||
std::string message; // human-readable outcome for the console
|
||||
};
|
||||
|
||||
// Imports one OS-native source file into the ACTIVE bank: convert-if-needed to 32-bit-
|
||||
// float WAV, write to the project-relative bank folder, index-add, hash-dedup applied.
|
||||
//
|
||||
// BANK CONTRACT: the instrument (wav_trim) expects every bank file to be a canonical
|
||||
// 32-bit-float WAV (WAVE_FORMAT_IEEE_FLOAT, 32 bits). A verbatim copy of a non-WAV (or
|
||||
// an integer-PCM or double-float WAV) would be unplayable. This function therefore:
|
||||
// 1. Checks whether the source IS already a valid 32f WAV (parseWavLayout fast path).
|
||||
// 2. If yes: copies it verbatim — one I/O, content unchanged.
|
||||
// 3. If no: decodes via PCM_source::GetSamples and writes a fresh 32f WAV, preserving
|
||||
// the source's channel count and sample rate.
|
||||
//
|
||||
// DEDUP ORDERING: the content hash is taken from the CONVERTED (bank-format) bytes AFTER
|
||||
// building the file buffer but BEFORE writing to disk. This means:
|
||||
// * Re-importing the same source file yields the same converted bytes → same hash →
|
||||
// dedup fires → no redundant disk write (matching the DEDUP-BEFORE-DISK design).
|
||||
// * An imported WAV whose audio-content hash matches a captured WAV also deduplicates
|
||||
// correctly (hashWavContent is chunk-aware for both).
|
||||
// * The pre-conversion hash shortcut (hash the raw source bytes) is not used: a non-WAV
|
||||
// source's bytes would produce a different hash from the converted WAV bytes, so two
|
||||
// imports of the same mp3 would NOT dedup — which is wrong. Hashing post-conversion
|
||||
// is correct.
|
||||
//
|
||||
// NON-DESTRUCTIVE: the source file is never modified or moved — only read.
|
||||
// Records the written file in the owned-file manifest (Phase B B-cap) so Phase R prune can
|
||||
// attribute it. Does NOT persist or open an undo point — the caller batches that (a
|
||||
// multi-file drop is one undo point, one persist).
|
||||
ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
|
||||
ImportResult out;
|
||||
|
||||
if (absoluteSourcePath.empty()) {
|
||||
out.message = "empty file path";
|
||||
return out;
|
||||
}
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
if (!fs::exists(absoluteSourcePath, ec) || ec) {
|
||||
out.message = "file not found: " + absoluteSourcePath;
|
||||
return out;
|
||||
}
|
||||
|
||||
const std::string projectDir = currentProjectDir();
|
||||
if (projectDir.empty()) {
|
||||
out.message = "no saved project, so the bank has no location -- save the "
|
||||
"project first";
|
||||
return out;
|
||||
}
|
||||
|
||||
// Read source bytes; needed to check whether it is already a 32f WAV.
|
||||
const std::vector<std::uint8_t> srcBytes = readFileBytes(absoluteSourcePath);
|
||||
if (srcBytes.empty()) {
|
||||
out.message = "file is empty or unreadable: " + absoluteSourcePath;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Probe the source's audio geometry via PCM_source. Needed for conversion AND for
|
||||
// populating the Sample's metadata. A file REAPER cannot open leaves geometry at
|
||||
// zero — the sample still imports if the WAV-fast-path succeeds; the geometry
|
||||
// is simply unknown, the honest default.
|
||||
int channelCount = 0;
|
||||
int sampleRate = 0;
|
||||
double lengthSeconds = 0.0;
|
||||
PCM_source* srcHandle = PCM_Source_CreateFromFile(absoluteSourcePath.c_str());
|
||||
if (srcHandle) {
|
||||
channelCount = GetMediaSourceNumChannels(srcHandle);
|
||||
sampleRate = GetMediaSourceSampleRate(srcHandle);
|
||||
bool isQN = false;
|
||||
lengthSeconds = GetMediaSourceLength(srcHandle, &isQN);
|
||||
if (isQN) lengthSeconds = 0.0; // QN-length source has no seconds length to store
|
||||
}
|
||||
|
||||
// Determine whether a verbatim copy suffices (fast path) or a conversion is needed.
|
||||
// parseWavLayout validates that the source is a canonical 32-bit-float RIFF/WAVE; any
|
||||
// other format (mp3, aiff, integer PCM, 16-bit WAV, etc.) takes the decode+rewrite path.
|
||||
const WavLayout layout = parseWavLayout(srcBytes);
|
||||
const bool isFloat32Wav = layout.valid;
|
||||
|
||||
// Build the bank-format bytes in memory (the "converted" bytes), which we hash for dedup
|
||||
// BEFORE writing to disk so a re-import of the same source skips the disk write.
|
||||
std::vector<std::uint8_t> bankBytes;
|
||||
if (isFloat32Wav) {
|
||||
// Fast path: already canonical — bank bytes ARE the source bytes.
|
||||
bankBytes = srcBytes;
|
||||
if (srcHandle) PCM_Source_Destroy(srcHandle);
|
||||
} else {
|
||||
// Conversion path: decode all samples then write a fresh 32f WAV.
|
||||
// PCM_source is opened on the source path (not a copy); we already have srcHandle.
|
||||
std::vector<ReaSample> decoded;
|
||||
if (srcHandle && channelCount > 0 && sampleRate > 0 && lengthSeconds > 0.0) {
|
||||
decoded = decodePcmSource(srcHandle, channelCount,
|
||||
static_cast<double>(sampleRate), lengthSeconds);
|
||||
}
|
||||
if (srcHandle) PCM_Source_Destroy(srcHandle);
|
||||
|
||||
if (decoded.empty()) {
|
||||
// No decodable audio. The source is on disk (valid path, REAPER could open it)
|
||||
// but yielded no samples — e.g. a MIDI file, a zero-length audio file, or a
|
||||
// format REAPER does not support. Fail loudly: we must not write a silent WAV
|
||||
// and pretend the import succeeded.
|
||||
out.message = "could not decode audio samples from: " +
|
||||
fs::path(absoluteSourcePath).filename().string() +
|
||||
" (unsupported format or no audio data)";
|
||||
return out;
|
||||
}
|
||||
|
||||
const std::size_t frameCount =
|
||||
decoded.size() / static_cast<std::size_t>(channelCount > 0 ? channelCount : 1);
|
||||
bankBytes = buildFloat32Wav(channelCount,
|
||||
static_cast<std::uint32_t>(sampleRate),
|
||||
frameCount, decoded);
|
||||
}
|
||||
// srcHandle is destroyed above in both branches.
|
||||
|
||||
// Hash the converted (bank-format) bytes for dedup. WAV-aware hash (hashWavContent)
|
||||
// so a re-import of the same source deduplicates against a previously-captured or
|
||||
// previously-imported sample with identical audio content, even if non-audio RIFF
|
||||
// chunks differ. Empty hash (unhashable) is treated as "not dedupable" (safe direction:
|
||||
// copies + adds rather than silently collapsing onto an unrelated entry).
|
||||
const std::string contentHash = hashWavContent(bankBytes);
|
||||
|
||||
BankBook& book = g_session->book();
|
||||
|
||||
// Dedup-before-disk: if the active bank already holds this audio content, assign the
|
||||
// existing sample's id and skip the disk write (no redundant on-disk duplicate).
|
||||
// Empty hashes never match (findByHash treats "" as non-participating).
|
||||
if (!contentHash.empty()) {
|
||||
if (const Sample* existing = book.activeIndex().findByHash(contentHash)) {
|
||||
out.sampleId = existing->id;
|
||||
out.added = false; // already present — no index mutation, no undo point
|
||||
out.message = "already in the active bank (assigned existing sample)";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive the destination path. The stem comes from the source file name; a timestamp
|
||||
// uniqueTag avoids collision with a prior import of a same-named file.
|
||||
const std::string sourceStem = fs::path(absoluteSourcePath).stem().string();
|
||||
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
|
||||
const std::string uniqueTag = std::to_string(nowSec);
|
||||
const BankPaths paths = deriveBankPaths(projectDir, sourceStem, uniqueTag);
|
||||
|
||||
// Ensure the bank folder exists, then write the (converted) bank bytes.
|
||||
fs::create_directories(paths.absoluteDir, ec); // idempotent; ec ignored (write reports)
|
||||
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
|
||||
if (!writeFileBytes(destPath, bankBytes)) {
|
||||
out.message = "could not write converted file to the bank folder";
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build the Sample. Import is NOT a capture — sourceMode/range/tail do not apply; we
|
||||
// record what we know (path, hash, geometry, name) and leave capture-only fields at
|
||||
// their defaults. rootNote/loop stay empty: an imported file is not a single played
|
||||
// note, so we do not guess a root note.
|
||||
Sample s;
|
||||
s.id = "imp-" + uniqueTag + "-" + paths.fileName;
|
||||
s.displayName = sourceStem.empty() ? std::string("import") : sourceStem;
|
||||
s.relativePath = paths.relativePath; // project-relative (invariant)
|
||||
s.channelCount = channelCount;
|
||||
s.sampleRate = sampleRate;
|
||||
s.lengthSeconds = lengthSeconds;
|
||||
s.tier = Tier::Scratch; // imports land in scratch, like captures
|
||||
s.contentHash = contentHash;
|
||||
s.createdTimestamp = nowSec;
|
||||
|
||||
const AddResult r = book.activeIndex().add(s);
|
||||
// Record the written file as owned regardless of the add outcome — the tool WROTE it, so
|
||||
// Phase R prune must attribute it. (A Collapsed result here would mean another sample in
|
||||
// the active bank matched the hash after we passed the pre-write dedup check — a narrow
|
||||
// race window. Record + handle both honestly.)
|
||||
g_session->owned().add(paths.relativePath);
|
||||
|
||||
switch (r) {
|
||||
case AddResult::Added:
|
||||
out.sampleId = s.id;
|
||||
out.added = true;
|
||||
out.message = (isFloat32Wav ? "imported -> " : "converted + imported -> ") +
|
||||
paths.relativePath;
|
||||
break;
|
||||
case AddResult::Collapsed: {
|
||||
// The hash matched an existing entry (a race against our pre-write dedup check,
|
||||
// or an empty-hash edge). Assign the existing entry's id.
|
||||
const Sample* existing =
|
||||
contentHash.empty() ? nullptr : book.activeIndex().findByHash(contentHash);
|
||||
out.sampleId = existing ? existing->id : std::string{};
|
||||
out.added = false;
|
||||
out.message = "collapsed onto an existing bank sample";
|
||||
break;
|
||||
}
|
||||
case AddResult::RejectedAbsolutePath:
|
||||
case AddResult::RejectedEmptyId:
|
||||
// deriveBankPaths always yields a relative path and a non-empty id above, so
|
||||
// these are unreachable in practice — reported honestly rather than silently.
|
||||
out.message = "index rejected the import (internal path/id error)";
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Media-Explorer import action --------------------------------------------
|
||||
|
||||
// Import the Media Explorer's current last-played/selected file into the active bank and
|
||||
// assign it to the active instance (S8 surface 2). Single-file, pull-on-action:
|
||||
// MediaExplorerGetLastPlayedFileInfo returns the ONE last-played file (the whole ME
|
||||
// contract — no enumerate-selected API). The selection RANGE it reports is deliberately
|
||||
// IGNORED here: an import brings the whole file into the bank (the range is a preview
|
||||
// hint, and the fields are [0,1] fractions, not seconds — see the DAW-verify note); a
|
||||
// user wanting a sub-range captures it via the arrange path instead. Undo-wrapped.
|
||||
void doImportFromMediaExplorer() {
|
||||
// filemode/sel/pitch/vol/rate/bpm/extrainfo are read but only the filename is used for
|
||||
// the import. selstart/selend are [0,1] fractions (SDK header) — a preview hint, not a
|
||||
// bank-relevant range; left unused. extrainfo is documented "currently unused".
|
||||
std::vector<char> nameBuf(4096, '\0');
|
||||
int filemode = 0;
|
||||
double selStart = 0.0, selEnd = 0.0;
|
||||
double pitch = 0.0, vol = 0.0, rate = 0.0, srcbpm = 0.0;
|
||||
std::vector<char> extra(256, '\0'); // documented unused; sized generously to be safe
|
||||
const bool ok = MediaExplorerGetLastPlayedFileInfo(
|
||||
nameBuf.data(), static_cast<int>(nameBuf.size()), &filemode, &selStart, &selEnd,
|
||||
&pitch, &vol, &rate, &srcbpm, extra.data(), static_cast<int>(extra.size()));
|
||||
|
||||
const std::string path(nameBuf.data());
|
||||
if (!ok || path.empty()) {
|
||||
ShowConsoleMsg("ReaSampler ingest: no Media Explorer file to import -- open the "
|
||||
"Media Explorer and select (or preview) a file first.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const ImportResult r = importFileIntoActiveBank(path);
|
||||
if (r.sampleId.empty()) {
|
||||
ShowConsoleMsg(("ReaSampler ingest: Media Explorer import failed -- " + r.message +
|
||||
".\n").c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the bank add AND the assign request inside ONE undo block so Ctrl-Z rolls
|
||||
// back both keys atomically: undo restores `banks` (removing the new sample) AND
|
||||
// clears the `assign_request` that named it, so no stale request can survive.
|
||||
// The block is opened only when the index mutated (a dedup collapse changed nothing).
|
||||
// If saveToActiveProject() no-ops (unsaved project), we close with an empty label +
|
||||
// zero flag so REAPER discards the undo entry (the house pattern from actions.cpp).
|
||||
if (r.added) {
|
||||
Undo_BeginBlock2(nullptr);
|
||||
const bool persisted = g_session->saveToActiveProject();
|
||||
// Assign request inside the same block: undo rolls back both keys together.
|
||||
ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId);
|
||||
if (persisted)
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer",
|
||||
UNDO_STATE_MISCCFG);
|
||||
else
|
||||
Undo_EndBlock2(nullptr, "", 0);
|
||||
} else {
|
||||
// Dedup collapse: index unchanged, no undo point. Assign request still written
|
||||
// (the user explicitly re-imported; they want the instance updated).
|
||||
ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId);
|
||||
}
|
||||
bankPanelRefresh();
|
||||
ShowConsoleMsg(("ReaSampler ingest: " + r.message + " (assigned to the active "
|
||||
"instance).\n").c_str());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- Assignment-request write ------------------------------------------------
|
||||
|
||||
void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId) {
|
||||
if (!g_session || sampleId.empty()) return; // nothing to assign
|
||||
|
||||
AssignmentRequest req;
|
||||
req.bankId = bankId;
|
||||
req.sampleId = sampleId;
|
||||
// Monotonic disambiguator: a wall-clock unix-epoch stamp so the reader tells a fresh
|
||||
// assign (even re-assigning the SAME id) from a stale value. NOT the S9 bank-generation
|
||||
// counter (a separate point) — this field is self-contained to the request.
|
||||
req.generation = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
g_session->writeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
}
|
||||
|
||||
// --- Drop-onto-panel ingest --------------------------------------------------
|
||||
|
||||
void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
|
||||
if (!g_session || absolutePaths.empty()) return;
|
||||
|
||||
// Import ALL dropped files; assign the FIRST successfully-imported one (documented
|
||||
// multi-file policy). Batch the persist + undo point: many imports are ONE undo entry.
|
||||
std::string firstAssignId;
|
||||
std::string firstAssignBank;
|
||||
int importedNew = 0;
|
||||
int importedTotal = 0; // includes dedup collapses that still yielded an id to assign
|
||||
std::string lastFailure;
|
||||
|
||||
for (const std::string& path : absolutePaths) {
|
||||
const ImportResult r = importFileIntoActiveBank(path);
|
||||
if (r.sampleId.empty()) {
|
||||
lastFailure = r.message;
|
||||
continue;
|
||||
}
|
||||
++importedTotal;
|
||||
if (r.added) ++importedNew;
|
||||
if (firstAssignId.empty()) {
|
||||
firstAssignId = r.sampleId;
|
||||
firstAssignBank = g_session->book().activeBankId();
|
||||
}
|
||||
}
|
||||
|
||||
// One undo point for the whole drop, opened only if a NEW index entry was created (a
|
||||
// drop that only re-hit existing content mutated nothing on the index). The assign
|
||||
// request is written INSIDE the same block so Ctrl-Z rolls back both keys together:
|
||||
// undo restores `banks` (removing the new samples) AND clears the `assign_request` that
|
||||
// named one of them, so no stale request survives pointing to a removed sample.
|
||||
// If saveToActiveProject() no-ops (unsaved project), we close with an empty label + zero
|
||||
// flag so REAPER discards the undo entry (house pattern from actions.cpp).
|
||||
if (!firstAssignId.empty()) {
|
||||
if (importedNew > 0) {
|
||||
Undo_BeginBlock2(nullptr);
|
||||
const bool persisted = g_session->saveToActiveProject();
|
||||
// Assign inside the block: undo restores both keys atomically.
|
||||
ingestAssignActiveInstance(firstAssignBank, firstAssignId);
|
||||
if (persisted)
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: import dropped file(s)",
|
||||
UNDO_STATE_MISCCFG);
|
||||
else
|
||||
Undo_EndBlock2(nullptr, "", 0);
|
||||
} else {
|
||||
// All dropped files deduplicated: index unchanged, no undo point needed. Still
|
||||
// assign so the user sees the sample is already in the bank.
|
||||
ingestAssignActiveInstance(firstAssignBank, firstAssignId);
|
||||
}
|
||||
bankPanelRefresh();
|
||||
const std::string msg =
|
||||
"ReaSampler ingest: imported " + std::to_string(importedTotal) +
|
||||
(importedTotal == 1 ? " file" : " files") +
|
||||
" and assigned the first to the active instance.\n";
|
||||
ShowConsoleMsg(msg.c_str());
|
||||
} else {
|
||||
ShowConsoleMsg(("ReaSampler ingest: nothing imported from the drop -- " +
|
||||
(lastFailure.empty() ? std::string("no usable files") : lastFailure) +
|
||||
".\n").c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Action registration ------------------------------------------------------
|
||||
|
||||
void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
|
||||
g_session = session; // shared with the capture / bank / Design-View families
|
||||
|
||||
g_idImportStr = channelCommandId(kIdImportMediaExplorer);
|
||||
g_cmdImportMediaExplorer = rec->Register("command_id", (void*)g_idImportStr.c_str());
|
||||
if (g_cmdImportMediaExplorer) {
|
||||
g_labelImportStr = channelActionName("import Media Explorer file into bank + assign");
|
||||
g_accelImportMediaExplorer.accel.cmd = g_cmdImportMediaExplorer;
|
||||
g_accelImportMediaExplorer.desc = g_labelImportStr.c_str();
|
||||
rec->Register("gaccel", (void*)&g_accelImportMediaExplorer);
|
||||
}
|
||||
}
|
||||
|
||||
bool ingestHandleCommand(int command) {
|
||||
if (command == 0 || !g_session) return false;
|
||||
if (command == g_cmdImportMediaExplorer) { doImportFromMediaExplorer(); return true; }
|
||||
return false; // not ours — caller's hookcommand keeps looking
|
||||
}
|
||||
|
||||
void ingestUnregisterActions(reaper_plugin_info_t* rec) {
|
||||
// Mirror-unregister with '-'-prefixed strings; the '-command_id' re-presents the SAME
|
||||
// interned channel-qualified id used at register (g_idImportStr).
|
||||
rec->Register("-gaccel", (void*)&g_accelImportMediaExplorer);
|
||||
rec->Register("-command_id", (void*)g_idImportStr.c_str());
|
||||
g_session = nullptr;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
// ingest — the S8 "ingest through the bank" shell (EXTENSION side).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. REAPER-facing (PCM_Source metadata reads,
|
||||
// Media-Explorer query, ext-state assignment write, action registration), so it is
|
||||
// DAW-verified, not unit-tested; the pure serialization it drives lives in
|
||||
// assignment_request (tested in CTest).
|
||||
//
|
||||
// -- The one gesture (CONTEXT.md §Ingest through the bank) --------------------
|
||||
//
|
||||
// Loading a sample into the sampler is ONE gesture: capture/import-into-bank AND
|
||||
// auto-assign to the active sampler instance. The EXTENSION owns ingest (it has arrange
|
||||
// access, Media-Explorer access, and the drop-target surface on its own panels); the
|
||||
// instrument stays a READ-ONLY bank consumer. Three ingest surfaces:
|
||||
//
|
||||
// 1. Arrange capture -> bank -> assign (a bindable action; reuses the capture path).
|
||||
// 2. Media-Explorer import -> bank -> assign (a bindable action; single-file, pull-on-
|
||||
// action via MediaExplorerGetLastPlayedFileInfo).
|
||||
// 3. Drop-onto-panel -> bank -> assign (an OS file drop on the docked bank_panel HWND;
|
||||
// multi-file: import all, assign the first).
|
||||
//
|
||||
// -- The load-bearing principle (restated) -----------------------------------
|
||||
//
|
||||
// Ingest NEVER inserts a timeline item. Capture writes a file + an index entry; import
|
||||
// copies a file + adds an index entry; assignment is a bank-index + instance-selection
|
||||
// act, not a placement. Any path here that calls InsertMedia would be a bug.
|
||||
//
|
||||
// -- Import semantics ---------------------------------------------------------
|
||||
//
|
||||
// A Media-Explorer/drop import is a FILE COPY into the project-relative bank folder +
|
||||
// an index add, mirroring how a capture lands (relative-paths-only, hash-dedup). If the
|
||||
// active bank already holds the imported content (by content hash), the import collapses
|
||||
// onto the existing sample and assigns THAT sample's id — no redundant on-disk copy.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Forward declarations keep this header REAPER-free at its own boundary (the .cpp pulls
|
||||
// the SDK). reaper_plugin_info_t is REAPER's dispatch struct; ReaSamplerSession owns the
|
||||
// book + persist bridge the ingest paths mutate.
|
||||
struct reaper_plugin_info_t;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// Registers the S8 ingest action family (command_id/gaccel per the house contract),
|
||||
// mirror of bankRegisterActions. `session` is the live session the ingest paths mutate
|
||||
// (shared with the capture / bank / Design-View families). The single hookcommand in
|
||||
// main.cpp routes fired ids here via ingestHandleCommand.
|
||||
void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
|
||||
|
||||
// Routes a fired command id to its ingest action. Returns true iff it was one of ours
|
||||
// (claim-only, per the hookcommand contract); false otherwise so the hook keeps looking.
|
||||
bool ingestHandleCommand(int command);
|
||||
|
||||
// Mirror-unregisters the ingest action family on unload (the '-'-prefixed strings).
|
||||
void ingestUnregisterActions(reaper_plugin_info_t* rec);
|
||||
|
||||
// Write the S8 assignment request for a just-ingested sample: "the active sampler
|
||||
// instance should now play (bankId, sampleId)." Encodes the pure assignment_request value
|
||||
// (with a fresh monotonic generation stamp) and routes it to ext state via the session.
|
||||
// Called by EVERY ingest surface after the sample lands in the bank — the arrange
|
||||
// capture+assign action (main.cpp, alongside the capture machinery it reuses), the ME
|
||||
// import action, and the drop path. A no-op-safe write: if there is no saved/active
|
||||
// project the request is silently dropped (nothing to signal into), matching the
|
||||
// book/manifest quiet-persist idiom. `sampleId` empty -> no write (nothing to assign).
|
||||
void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId);
|
||||
|
||||
// Ingest OS-dropped files onto a ReaSampler surface (S8 drop path). Called by the
|
||||
// bank_panel's WM_DROPFILES handler with the dropped file paths (absolute, OS-native).
|
||||
// Imports EVERY file into the active bank (copy + index add, hash-dedup) and assigns the
|
||||
// FIRST successfully-imported sample to the active instance. A no-op on an empty list or
|
||||
// an unsaved/no-active project (nothing to import into). Reports outcomes to the console.
|
||||
void ingestDroppedFiles(const std::vector<std::string>& absolutePaths);
|
||||
|
||||
} // namespace reasampler
|
||||
+120
-5
@@ -32,6 +32,7 @@
|
||||
#include "bank_panel.h"
|
||||
#include "batch_capture.h"
|
||||
#include "capture.h"
|
||||
#include "ingest.h"
|
||||
#include "insert.h"
|
||||
#include "persist.h"
|
||||
#include "provenance.h"
|
||||
@@ -151,6 +152,16 @@ static int g_cmdCaptureTrackRealtime = 0;
|
||||
// explicit action, allowed by the console policy).
|
||||
static int g_cmdRecaptureFromSource = 0;
|
||||
|
||||
// Command id for the S8 "capture selected item / time-selection into bank + assign"
|
||||
// action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline
|
||||
// Item-scope capture path (RunCapture) verbatim — same razor-else-time range, same
|
||||
// FX-scope neutralize, same bank/persist landing — then writes an S8 assignment request
|
||||
// so the active sampler instance plays the just-captured sample on its next reload. NEVER
|
||||
// inserts a timeline item (the capture/placement separation holds; assign is a bank-index
|
||||
// + instance-selection act). Lives in the capture family (not the ingest family) because
|
||||
// it leans on main.cpp's capture render machinery, which is not exposed cross-module.
|
||||
static int g_cmdCaptureItemAssign = 0;
|
||||
|
||||
// Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string.
|
||||
// Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user
|
||||
// who started a long capture can bail without waiting for the range end or hunting for
|
||||
@@ -738,6 +749,11 @@ static reasampler::CaptureResult renderOffline(
|
||||
// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the
|
||||
// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard),
|
||||
// and the backend restores every RENDER_* setting.
|
||||
//
|
||||
// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id
|
||||
// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8
|
||||
// capture+assign path can target the sample actually in the bank. Batch callers ignore
|
||||
// it; the plain capture actions are unaffected.
|
||||
static reasampler::CaptureResult captureAndIndexOne(
|
||||
reasampler::CaptureScope scope,
|
||||
const ResolvedSource& src,
|
||||
@@ -780,13 +796,26 @@ static reasampler::CaptureResult captureAndIndexOne(
|
||||
// resample-from-sample; otherwise the optional stays empty, per M1's contract).
|
||||
res.sample.provenance = prov;
|
||||
|
||||
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
|
||||
g_session.bank().add(res.sample);
|
||||
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). The
|
||||
// AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can
|
||||
// target the sample actually in the bank (the existing entry on a collapse).
|
||||
const reasampler::AddResult addResult = g_session.bank().add(res.sample);
|
||||
// B-cap: record the created file in the owned-file manifest, at the same point the
|
||||
// Sample is added. Recorded regardless of the index AddResult — even a hash-collapse
|
||||
// still WROTE a file the tool owns, and the manifest dedups a repeat path itself
|
||||
// (Phase R prune reconciles manifest vs index later).
|
||||
g_session.owned().add(res.sample.relativePath);
|
||||
|
||||
// Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new
|
||||
// id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a
|
||||
// Collapsed (the file we just rendered deduped onto an already-present sample — assign
|
||||
// THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged.
|
||||
if (addResult == reasampler::AddResult::Collapsed && !res.sample.contentHash.empty())
|
||||
{
|
||||
if (const reasampler::Sample* existing =
|
||||
g_session.bank().findByHash(res.sample.contentHash))
|
||||
res.sample.id = existing->id;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -794,14 +823,19 @@ static reasampler::CaptureResult captureAndIndexOne(
|
||||
// record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle
|
||||
// holds structurally — this path writes a file + a bank index entry ONLY; it never
|
||||
// calls InsertMedia or touches the arrange/timeline.
|
||||
static void RunCapture(const reasampler::CaptureActionDef& def)
|
||||
// Returns the bank-index id of the sample the capture landed on: the newly-added id on a
|
||||
// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign
|
||||
// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange
|
||||
// capture+assign path reads this to write an assignment request; the plain capture actions
|
||||
// ignore it (their behaviour is unchanged — capture still writes a file + index entry only).
|
||||
static std::string RunCapture(const reasampler::CaptureActionDef& def)
|
||||
{
|
||||
ResolvedSource src;
|
||||
std::string why;
|
||||
if (!ResolveScopeSource(def.scope, src, why))
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
|
||||
reasampler::CaptureResult res =
|
||||
@@ -809,14 +843,61 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
|
||||
if (res.status != reasampler::CaptureStatus::Ok)
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str());
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
|
||||
// captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE
|
||||
// bank, and recorded the created file in the owned-file manifest (WITHOUT persisting).
|
||||
// Persist the updated book AND manifest into the active project's ext state (the
|
||||
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
|
||||
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
|
||||
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
|
||||
g_session.saveToActiveProject();
|
||||
|
||||
// Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne
|
||||
// resolved res.sample.id to the fresh id on a new add or the existing entry's id on a
|
||||
// hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above).
|
||||
return res.sample.id;
|
||||
}
|
||||
|
||||
// S8 arrange ingest: capture the selected item / time-selection into the active bank
|
||||
// (reusing the Item-scope capture path verbatim) and, on success, write an assignment
|
||||
// request so the active sampler instance plays the new sample on its next reload. The
|
||||
// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER
|
||||
// inserts a timeline item (load-bearing principle); the only addition here is the
|
||||
// bank-index-id -> assignment-request write after the sample lands. If the capture
|
||||
// failed / no-op'd (empty id), no assignment is written (nothing to assign).
|
||||
//
|
||||
// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the
|
||||
// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are
|
||||
// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically.
|
||||
// An undo that removes the captured sample also clears the assign_request that named it,
|
||||
// preventing a stale request from pointing at a removed sample. The block uses the house
|
||||
// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero
|
||||
// flag) matching the bank-op family in actions.cpp.
|
||||
static void RunCaptureItemAssign()
|
||||
{
|
||||
// Reuse the Item-scope def from the capture table (index 0) — same range logic, same
|
||||
// FX-scope neutralize, same bank/persist landing as the plain "capture item" action.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
|
||||
const std::string sampleId =
|
||||
RunCapture(reasampler::captureActionTable()[0]);
|
||||
if (sampleId.empty())
|
||||
{
|
||||
// Capture failed or no-op'd — RunCapture already reported. Discard the empty point.
|
||||
Undo_EndBlock2(nullptr, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Assign inside the same block so undo clears both keys together.
|
||||
reasampler::ingestAssignActiveInstance(g_session.book().activeBankId(), sampleId);
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance",
|
||||
UNDO_STATE_MISCCFG);
|
||||
|
||||
reasampler::bankPanelRefresh();
|
||||
ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active "
|
||||
"instance.\n");
|
||||
}
|
||||
|
||||
// --- M11: batch capture (per selected item / per razor area) ----------------
|
||||
@@ -1422,6 +1503,7 @@ static bool OnHookCommand(int command, int /*flag*/)
|
||||
return true;
|
||||
}
|
||||
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
|
||||
if (command == g_cmdCaptureItemAssign) { RunCaptureItemAssign(); return true; }
|
||||
if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; }
|
||||
if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; }
|
||||
if (command == g_cmdCaptureBatchItems) { RunBatchCaptureItems(); return true; }
|
||||
@@ -1440,6 +1522,8 @@ static bool OnHookCommand(int command, int /*flag*/)
|
||||
if (reasampler::designViewHandleCommand(command)) return true;
|
||||
// Multi-bank action family (B3). Same contract: claims only its own ids.
|
||||
if (reasampler::bankHandleCommand(command)) return true;
|
||||
// S8 ingest action family (Media-Explorer import). Same contract.
|
||||
if (reasampler::ingestHandleCommand(command)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1455,6 +1539,7 @@ static int OnToggleAction(int command)
|
||||
// gaccel storage must outlive registration — REAPER holds the pointer.
|
||||
// (The capture family's accels live in g_captureAccels, sized to the table.)
|
||||
static gaccel_register_t g_accelToggleBankPanel{};
|
||||
static gaccel_register_t g_accelCaptureItemAssign{};
|
||||
static gaccel_register_t g_accelInsertSelected{};
|
||||
static gaccel_register_t g_accelInsertSelectedConform{};
|
||||
static gaccel_register_t g_accelCaptureBatchItems{};
|
||||
@@ -1468,6 +1553,7 @@ static gaccel_register_t g_accelShowVersion{};
|
||||
// (channelActionName) so it cannot be a string literal; REAPER holds the gaccel's `desc`
|
||||
// pointer, so each label lives here for the module lifetime. Composed once at registration.
|
||||
static std::string g_descToggleBankPanel;
|
||||
static std::string g_descCaptureItemAssign;
|
||||
static std::string g_descInsertSelected;
|
||||
static std::string g_descInsertSelectedConform;
|
||||
static std::string g_descCaptureBatchItems;
|
||||
@@ -1480,6 +1566,7 @@ static std::string g_descShowVersion;
|
||||
// Composed command-id strings (channel-qualified), interned so register and the mirroring
|
||||
// '-command_id' unregister pass the SAME pointer. Set during registration; read on unload.
|
||||
static const char* g_idToggleBankPanel = nullptr;
|
||||
static const char* g_idCaptureItemAssign = nullptr;
|
||||
static const char* g_idInsertSelected = nullptr;
|
||||
static const char* g_idInsertSelectedConform = nullptr;
|
||||
static const char* g_idCaptureBatchItems = nullptr;
|
||||
@@ -1519,6 +1606,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
reasampler::designViewUnregisterActions(g_rec);
|
||||
// Tear down the multi-bank action family (B3) — same mirror-unregister.
|
||||
reasampler::bankUnregisterActions(g_rec);
|
||||
// Tear down the S8 ingest action family — same mirror-unregister.
|
||||
reasampler::ingestUnregisterActions(g_rec);
|
||||
// Each '-command_id' re-presents the SAME interned, channel-qualified pointer
|
||||
// used at register (g_id*), so the mirror-unregister matches exactly.
|
||||
g_rec->Register("-gaccel", (void*)&g_accelShowVersion);
|
||||
@@ -1537,6 +1626,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
g_rec->Register("-command_id", (void*)g_idInsertSelectedConform);
|
||||
g_rec->Register("-gaccel", (void*)&g_accelInsertSelected);
|
||||
g_rec->Register("-command_id", (void*)g_idInsertSelected);
|
||||
g_rec->Register("-gaccel", (void*)&g_accelCaptureItemAssign);
|
||||
g_rec->Register("-command_id", (void*)g_idCaptureItemAssign);
|
||||
g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel);
|
||||
g_rec->Register("-command_id", (void*)g_idToggleBankPanel);
|
||||
// Mirror-unregister the capture family: gaccel + command_id per row, with
|
||||
@@ -1627,6 +1718,24 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
rec->Register("toggleaction", (void*)&OnToggleAction);
|
||||
}
|
||||
|
||||
// Register the S8 "capture selected item / time-selection into bank + assign" action
|
||||
// (command_id -> gaccel -> hookcommand). Reuses the Item-scope offline capture path and
|
||||
// writes an assignment request so the active instance plays the new sample. Channel-
|
||||
// qualified FOREVER-STABLE id (suffix CAPTURE_ITEM_ASSIGN). MIDI-bindable like every
|
||||
// capture action. Registered in the capture family (main.cpp) because it leans on the
|
||||
// capture render machinery here; the other two ingest surfaces live in the ingest family
|
||||
// (Media-Explorer import) and the panel drop callback.
|
||||
g_idCaptureItemAssign = internCmdId("CAPTURE_ITEM_ASSIGN");
|
||||
g_cmdCaptureItemAssign = rec->Register("command_id", (void*)g_idCaptureItemAssign);
|
||||
if (g_cmdCaptureItemAssign)
|
||||
{
|
||||
g_descCaptureItemAssign = reasampler::channelActionName(
|
||||
"capture selected item into bank + assign to active instance");
|
||||
g_accelCaptureItemAssign.accel.cmd = g_cmdCaptureItemAssign;
|
||||
g_accelCaptureItemAssign.desc = g_descCaptureItemAssign.c_str();
|
||||
rec->Register("gaccel", (void*)&g_accelCaptureItemAssign);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1745,6 +1854,12 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
// by the same hookcommand via bankHandleCommand. Registered before the hook.
|
||||
reasampler::bankRegisterActions(rec, &g_session);
|
||||
|
||||
// Register the S8 ingest action family: the Media-Explorer import-into-bank+assign
|
||||
// action. Shares g_session with the other families; routed by the same hookcommand via
|
||||
// ingestHandleCommand. (The arrange capture+assign action is registered in the capture
|
||||
// family above; the drop path is a bank_panel callback, not a bindable action.)
|
||||
reasampler::ingestRegisterActions(rec, &g_session);
|
||||
|
||||
// One hookcommand routes every ReaSampler action (spike + toggle + Design View).
|
||||
// Registered once, after all command ids are minted.
|
||||
rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||
|
||||
@@ -256,6 +256,23 @@ bool ReaSamplerSession::saveToActiveProject() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj) return false; // no active project — nothing to signal
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
|
||||
// One-shot write of the ingest assignment request under its own key (S8). Independent
|
||||
// of the book/view/tail blobs — this is a transient signal to the instrument, not
|
||||
// session state that must ride every save. Uses the channel-derived namespace
|
||||
// (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta
|
||||
// instrument reads only a beta extension's assignment requests.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtAssignKey, wire.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always
|
||||
|
||||
@@ -207,6 +207,21 @@ public:
|
||||
// shown the confirm; this method does NOT prompt.
|
||||
PruneDeletionResult pruneReclaim(const std::vector<std::string>& confirmed) const;
|
||||
|
||||
// Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the
|
||||
// `assign_request` key, namespace "reasampler"): the extension telling the active
|
||||
// sampler instance "play THIS sample now." `wire` is the pure assignment_request
|
||||
// encoding (assignment_request.h); this method only routes the already-encoded value
|
||||
// to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and
|
||||
// the encode live in the ingest shell (the pure module) so persist stays a thin bridge.
|
||||
//
|
||||
// A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an
|
||||
// assignment request is a transient "just assigned" signal the instrument reads and
|
||||
// acts on, so it rides its own key and is written only at ingest time, never on every
|
||||
// book save. Returns true iff written (an active, SAVED project existed); false on a
|
||||
// no-active / unsaved project (nothing to write into — the assign is dropped, matching
|
||||
// the book/manifest quiet-persist idiom the ingest add-path already tolerates).
|
||||
bool writeAssignmentRequest(const std::string& wire);
|
||||
|
||||
// Poll the active project. Detects a project load (active project changed)
|
||||
// and a Save-As (active project's .rpp path changed) and reacts accordingly.
|
||||
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Standalone tests for reasampler::AssignmentRequest — no REAPER, no framework.
|
||||
// The S8 ingest assignment-request seam: the (bankId, sampleId, generation) value the
|
||||
// extension writes to ext-state after an ingest-with-assign, decoded by the instrument
|
||||
// in a later dispatch. Only the wire format lives in this module; test it hard because
|
||||
// the reader (a different artifact) must decode exactly what this writer produces.
|
||||
//
|
||||
// Covers: encode/decode round-trip, ids carrying arbitrary bytes (GUIDs, separators),
|
||||
// the generation field including zero and negative-guard, and malformed/truncated/
|
||||
// trailing-garbage input -> nullopt (the reader's "no pending request" fallback hinges
|
||||
// on it).
|
||||
|
||||
#include "../src/assignment_request.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
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)
|
||||
|
||||
// --- round-trip --------------------------------------------------------------
|
||||
|
||||
static void testRoundTrip() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "{12345678-1234-1234-1234-1234567890AB}";
|
||||
req.sampleId = "cap-1700000000-kick.wav";
|
||||
req.generation = 1700000123;
|
||||
|
||||
const std::string wire = encodeAssignmentRequest(req);
|
||||
auto back = decodeAssignmentRequest(wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == req);
|
||||
// Re-encoding the decoded value is byte-stable (deterministic encoder).
|
||||
CHECK(encodeAssignmentRequest(*back) == wire);
|
||||
}
|
||||
|
||||
// The pool bank id and an empty-ish generation must round-trip too (generation 0 is the
|
||||
// documented pre-S9 default; an assign still carries a real stamp, but 0 must be legal).
|
||||
static void testRoundTripPoolAndZeroGeneration() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "pool";
|
||||
req.sampleId = "s1";
|
||||
req.generation = 0;
|
||||
|
||||
auto back = decodeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->bankId == "pool");
|
||||
CHECK(back->sampleId == "s1");
|
||||
CHECK(back->generation == 0);
|
||||
}
|
||||
|
||||
// Ids carrying the wire's own metacharacters (':' the length delimiter, digits that
|
||||
// could be misread as a length, the magic-tag bytes) must survive whole — the whole
|
||||
// reason for length-prefixing over a delimiter-split format.
|
||||
static void testRoundTripAdversarialIds() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "12:34:has-colons"; // ':' is the length delimiter
|
||||
req.sampleId = "rsassign1-lookalike-99"; // embeds the magic tag
|
||||
req.generation = -42; // negative is representable
|
||||
|
||||
auto back = decodeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == req);
|
||||
CHECK(back->bankId == "12:34:has-colons");
|
||||
CHECK(back->sampleId == "rsassign1-lookalike-99");
|
||||
CHECK(back->generation == -42);
|
||||
}
|
||||
|
||||
// Empty ids are structurally valid on the wire (length 0) and must round-trip — the
|
||||
// decoder must not conflate an empty field with a parse failure.
|
||||
static void testRoundTripEmptyFields() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "";
|
||||
req.sampleId = "";
|
||||
req.generation = 7;
|
||||
|
||||
auto back = decodeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == req);
|
||||
}
|
||||
|
||||
// A large generation (past 32-bit) must not truncate — the field is int64.
|
||||
static void testLargeGeneration() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "b";
|
||||
req.sampleId = "s";
|
||||
req.generation = 9007199254740993LL; // > 2^53, > INT32_MAX
|
||||
|
||||
auto back = decodeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->generation == 9007199254740993LL);
|
||||
}
|
||||
|
||||
// --- malformed / tolerant parse ----------------------------------------------
|
||||
|
||||
static void testMalformedParse() {
|
||||
// Absence / total garbage — the reader maps these to "no pending request".
|
||||
CHECK(!decodeAssignmentRequest("").has_value());
|
||||
CHECK(!decodeAssignmentRequest("not a request").has_value());
|
||||
// Wrong magic tag.
|
||||
CHECK(!decodeAssignmentRequest("rsprov1" "1:b1:s1:7").has_value());
|
||||
// Magic only, no fields.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1").has_value());
|
||||
// Truncated mid-record (missing the generation field).
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s1").has_value());
|
||||
// A length that runs past the end.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "99:short").has_value());
|
||||
// Non-numeric length token.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "x:pool2:s11:7").has_value());
|
||||
// A non-numeric generation field.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s13:abc").has_value());
|
||||
// A bare "-" generation.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s11:-").has_value());
|
||||
}
|
||||
|
||||
// Trailing garbage after a well-formed record must be rejected — a partial/padded blob
|
||||
// is not a valid request, and the reader must never accept the prefix and ignore the rest.
|
||||
static void testTrailingGarbageRejected() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "pool";
|
||||
req.sampleId = "s1";
|
||||
req.generation = 7;
|
||||
const std::string wire = encodeAssignmentRequest(req);
|
||||
|
||||
// The clean value parses.
|
||||
CHECK(decodeAssignmentRequest(wire).has_value());
|
||||
// The same value with any trailing byte does not.
|
||||
CHECK(!decodeAssignmentRequest(wire + "X").has_value());
|
||||
CHECK(!decodeAssignmentRequest(wire + "0:").has_value());
|
||||
}
|
||||
|
||||
// --- overflow / adversarial integer inputs ------------------------------------
|
||||
|
||||
// A 21-digit length field overflows SIZE_MAX and must be rejected safely (no UB,
|
||||
// no wrap-around that could make a huge length appear small and pass the bounds check).
|
||||
static void testOverflowFieldLength() {
|
||||
// Craft a wire where the bankId length token is 21 digits that exceed SIZE_MAX.
|
||||
// The decoder must fail cleanly, not access memory out of bounds.
|
||||
// "rsassign1" + "999999999999999999999:" (21 nines) + junk: rejects before OOB.
|
||||
const std::string wire = std::string("rsassign1") + "999999999999999999999:junk";
|
||||
CHECK(!decodeAssignmentRequest(wire).has_value());
|
||||
}
|
||||
|
||||
// A 20-digit generation (exceeds the 19-digit cap) must be rejected safely.
|
||||
static void testOverflowFieldInt64() {
|
||||
// Encode a valid record then manually substitute the generation with a 20-digit value.
|
||||
// We cannot use encode (it would produce a correct 19-digit generation), so we
|
||||
// build the wire manually. Generation "99999999999999999999" (20 nines) exceeds cap.
|
||||
// bankId = "pool" (4 bytes), sampleId = "s1" (2 bytes).
|
||||
const std::string wire = std::string("rsassign1")
|
||||
+ "4:pool"
|
||||
+ "2:s1"
|
||||
+ "20:99999999999999999999";
|
||||
CHECK(!decodeAssignmentRequest(wire).has_value());
|
||||
}
|
||||
|
||||
// SIZE_MAX as a length field (20 digits, within the digit-count cap) must not UB or
|
||||
// wrap. The overflow-guard in field() caps the multiplication; even if the value itself
|
||||
// does not trigger the multiply guard (SIZE_MAX accumulates cleanly digit by digit),
|
||||
// the subsequent "len > s_.size() - start" bounds check catches it because the actual
|
||||
// string is tiny — no OOB access, no wraparound, clean rejection.
|
||||
static void testOverflowExactSizeMax() {
|
||||
// 18446744073709551615 = SIZE_MAX on 64-bit. 20 digits: within the digit cap, but the
|
||||
// trailing bounds check rejects it because the wire string is far smaller than SIZE_MAX.
|
||||
const std::string wire = std::string("rsassign1") + "18446744073709551615:X";
|
||||
CHECK(!decodeAssignmentRequest(wire).has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testRoundTrip();
|
||||
testRoundTripPoolAndZeroGeneration();
|
||||
testRoundTripAdversarialIds();
|
||||
testRoundTripEmptyFields();
|
||||
testLargeGeneration();
|
||||
testMalformedParse();
|
||||
testTrailingGarbageRejected();
|
||||
testOverflowFieldLength();
|
||||
testOverflowFieldInt64();
|
||||
testOverflowExactSizeMax();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user