diff --git a/CMakeLists.txt b/CMakeLists.txt index e0c7bbe..a0328a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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' diff --git a/src/assignment_request.cpp b/src/assignment_request.cpp new file mode 100644 index 0000000..077aeee --- /dev/null +++ b/src/assignment_request.cpp @@ -0,0 +1,147 @@ +// assignment_request.cpp — see assignment_request.h. Pure: standard library only. + +#include "assignment_request.h" + +#include +#include + +namespace reasampler { + +namespace { + +constexpr const char* kMagic = "rsassign1"; + +// Append one length-prefixed field: ':' . 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(c - '0'); + // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. + if (len > (std::numeric_limits::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(c - '0'); + // Overflow guard: v * 10 + digit must not exceed INT64_MAX. + if (v > (std::numeric_limits::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 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 diff --git a/src/assignment_request.h b/src/assignment_request.h new file mode 100644 index 0000000..46c1e03 --- /dev/null +++ b/src/assignment_request.h @@ -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 +#include +#include + +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" ':' ':' ':' +// where each 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 decodeAssignmentRequest(const std::string& wire); + +} // namespace reasampler diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 6037411..7eef9ba 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -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 #include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) +#include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest #else #include #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 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 buf(static_cast(len) + 1, '\0'); + DragQueryFile(hDrop, i, buf.data(), static_cast(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(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(); diff --git a/src/ext_keys.h b/src/ext_keys.h index 9461094..3db31ef 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -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 diff --git a/src/ingest.cpp b/src/ingest.cpp new file mode 100644 index 0000000..6211bb3 --- /dev/null +++ b/src/ingest.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +#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 buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(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 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 bytes(static_cast(n)); + f.seekg(0); + f.read(reinterpret_cast(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& bytes) { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.write(reinterpret_cast(bytes.data()), + static_cast(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 buildFloat32Wav(int nch, std::uint32_t rate, + std::size_t frameCount, + const std::vector& interleaved) { + const std::size_t sampleCount = frameCount * static_cast(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(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data + + std::vector out; + out.reserve(44u + dataBytesCount); + + auto putU16 = [&](std::uint16_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + }; + auto putU32 = [&](std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); + }; + auto putTag = [&](const char* t) { + for (int i = 0; i < 4; ++i) + out.push_back(static_cast(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(nch)); + putU32(rate); + putU32(rate * static_cast(nch) * 4u); // avgBytesPerSec + putU16(static_cast(nch * 4)); // blockAlign + putU16(32u); // bitsPerSample + + // data chunk + putTag("data"); + putU32(static_cast(dataBytesCount)); + for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i) + putF32(static_cast(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 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(lengthSeconds * sampleRate + 0.5); + if (totalFrames == 0) return {}; + + std::vector out; + out.reserve(totalFrames * static_cast(nch)); + + // Pull samples in blocks of ~4096 frames; loop until source is exhausted. + constexpr int kBlockFrames = 4096; + std::vector block(static_cast(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(t.samples_out) * + static_cast(nch); + out.insert(out.end(), block.data(), block.data() + got); + t.time_s += static_cast(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 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 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 decoded; + if (srcHandle && channelCount > 0 && sampleRate > 0 && lengthSeconds > 0.0) { + decoded = decodePcmSource(srcHandle, channelCount, + static_cast(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(channelCount > 0 ? channelCount : 1); + bankBytes = buildFloat32Wav(channelCount, + static_cast(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::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 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 extra(256, '\0'); // documented unused; sized generously to be safe + const bool ok = MediaExplorerGetLastPlayedFileInfo( + nameBuf.data(), static_cast(nameBuf.size()), &filemode, &selStart, &selEnd, + &pitch, &vol, &rate, &srcbpm, extra.data(), static_cast(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::time(nullptr)); + + g_session->writeAssignmentRequest(encodeAssignmentRequest(req)); +} + +// --- Drop-onto-panel ingest -------------------------------------------------- + +void ingestDroppedFiles(const std::vector& 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 diff --git a/src/ingest.h b/src/ingest.h new file mode 100644 index 0000000..a6b2203 --- /dev/null +++ b/src/ingest.h @@ -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 +#include + +// 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& absolutePaths); + +} // namespace reasampler diff --git a/src/main.cpp b/src/main.cpp index a8e5b8c..eeecdb3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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); diff --git a/src/persist.cpp b/src/persist.cpp index 9c8bf3a..74fe848 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -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(proj), projExtNamespace(), + kProjExtAssignKey, wire.c_str()); + MarkProjectDirty(static_cast(proj)); + return true; +} + namespace { // The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always diff --git a/src/persist.h b/src/persist.h index 9acd066..723e483 100644 --- a/src/persist.h +++ b/src/persist.h @@ -207,6 +207,21 @@ public: // shown the confirm; this method does NOT prompt. PruneDeletionResult pruneReclaim(const std::vector& 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. diff --git a/tests/test_assignment_request.cpp b/tests/test_assignment_request.cpp new file mode 100644 index 0000000..9dfe12c --- /dev/null +++ b/tests/test_assignment_request.cpp @@ -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 +#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) + +// --- 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; +}