Fix drag-out losing audio and FX-container drops losing the capture; re-home ingest under shell/actions

This commit is contained in:
2026-07-29 23:49:53 -04:00
parent a689fb75eb
commit 0800760833
16 changed files with 269 additions and 45 deletions
+5 -7
View File
@@ -37,13 +37,11 @@ is owned by other directories and only skinned here.
## Gotchas
- **Structural wart, not yet fixed:** `ingest.cpp` / `ingest.h`, plus `ext_keys.h`
and `resource.h`, physically live at `src/` root rather than under
`shell/actions/` — Phase Q's reorg did not re-home these files into
`core/`/`shell/`/`app/`. `ingest` is documented here as its nearest sibling by
role, but the files themselves are not in this directory. This is a code
organization issue, not a documentation one — see Open questions in the
originating dispatch report.
- **Structural wart, partly closed:** `ingest.cpp` / `ingest.h` now live in this
directory. `ext_keys.h` and `resource.h` still sit at `src/` root: `ext_keys.h`
is consumed mostly from `shell/instrument/`, so it is not this directory's to
claim, and `resource.h` is a build input paired with `src/resource.rc` (the SWELL
resgen step) rather than a shell module.
- Media-Explorer import is single-file, pull-on-action (`OpenMediaExplorer` +
`MediaExplorerGetLastPlayedFileInfo`) — there is no enumerate-selected-files or
register-a-drop-handler API on the Media Explorer surface.
+32 -11
View File
@@ -62,6 +62,29 @@ HGLOBAL buildHDrop(const std::vector<std::string>& paths) {
return h;
}
// COM reference counts MUST be interlocked. A CF_HDROP target is free to marshal the data
// object into another apartment and finish the copy on a background thread AFTER DoDragDrop
// has returned (Explorer's async file copy does exactly this). A plain ++/-- there races the
// source thread's post-DoDragDrop Release: one lost increment destroys the object — and with
// it the source HGLOBAL — before the target reads it, and the drop lands with no file. That
// race is intermittent and a retry usually wins it; do not "simplify" these back.
inline ULONG comAddRef(volatile LONG& refs) {
return static_cast<ULONG>(InterlockedIncrement(&refs));
}
// DoDragDrop requires the calling thread to be OLE-initialized — CoInitialize alone is not
// enough, and an uninitialized thread fails the call outright, so the drag never starts.
// Relying on REAPER having done it is a first-use hazard: whether it has depends on what else
// ran first in the session. OleInitialize is per-thread refcounted, so this is additive to
// whatever the host did; we deliberately never OleUninitialize — the extension lives for the
// process, and unbalancing a REAPER-owned apartment is the hazard worth avoiding, not this.
// RPC_E_CHANGED_MODE means the thread joined an MTA, where OLE drag-drop is unavailable.
bool ensureOleForThisThread() {
static thread_local int state = 0; // 0 untried, 1 ready, -1 unavailable
if (state == 0) state = SUCCEEDED(OleInitialize(nullptr)) ? 1 : -1;
return state > 0;
}
// Minimal IDropSource: continue until the (left) button releases or Escape cancels; always
// request the copy cursor. This is the standard textbook drop source — no custom feedback.
class DropSource final : public IDropSource {
@@ -76,11 +99,11 @@ public:
*ppv = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
ULONG STDMETHODCALLTYPE AddRef() override { return comAddRef(refs_); }
ULONG STDMETHODCALLTYPE Release() override {
const ULONG r = --refs_;
const LONG r = InterlockedDecrement(&refs_);
if (r == 0) delete this;
return r;
return static_cast<ULONG>(r);
}
// IDropSource
HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escapePressed, DWORD keyState) override {
@@ -92,7 +115,7 @@ public:
return DRAGDROP_S_USEDEFAULTCURSORS; // let OLE draw the standard copy cursor
}
private:
ULONG refs_ = 1;
volatile LONG refs_ = 1;
};
// Minimal IDataObject exposing exactly one format (CF_HDROP / TYMED_HGLOBAL). The HDROP is
@@ -112,11 +135,11 @@ public:
*ppv = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
ULONG STDMETHODCALLTYPE AddRef() override { return comAddRef(refs_); }
ULONG STDMETHODCALLTYPE Release() override {
const ULONG r = --refs_;
const LONG r = InterlockedDecrement(&refs_);
if (r == 0) delete this;
return r;
return static_cast<ULONG>(r);
}
// IDataObject — the two that matter for a drag source.
@@ -184,7 +207,7 @@ private:
(fe.tymed & TYMED_HGLOBAL) &&
fe.dwAspect == DVASPECT_CONTENT;
}
ULONG refs_ = 1;
volatile LONG refs_ = 1;
HGLOBAL hdrop_ = nullptr;
};
@@ -192,10 +215,8 @@ private:
bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths) {
if (absolutePaths.empty()) return false;
if (!ensureOleForThisThread()) return false;
// REAPER's main thread is already OLE-initialized (it hosts OLE drag targets); we
// deliberately do NOT call OleInitialize — pairing OleUninitialize across a
// REAPER-owned apartment is the kind of thing that bites.
HGLOBAL hdrop = buildHDrop(absolutePaths);
if (!hdrop) return false;
+485
View File
@@ -0,0 +1,485 @@
// ingest.cpp — see ingest.h. main.cpp owns the API pointers; this TU gets them extern.
// REAPER-facing, DAW-verified; the pure serialization it drives (assignment_request)
// is CTest-tested.
#include "shell/actions/ingest.h"
#include <cstdint>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include "core/version/app_version.h" // channelCommandId / channelActionName
#include "core/wire/assignment_request.h" // pure (bankId, sampleId, generation) encode
#include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex
#include "core/model/bank_model.h" // Sample, AddResult, findByHash
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp
#include "core/util/file_bytes.h" // shared whole-file loader
#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
#include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
#include "shell/persist/session.h" // ReaSamplerSession
#include "core/capture/wav_codec.h" // parseWavLayout (32f fast-path validator), buildFloat32Wav, hashWavContent
#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
#define REAPERAPI_WANT_GetSelectedTrack
#include "reaper_plugin_functions.h"
namespace reasampler {
using capture::BankPaths;
using capture::buildFloat32Wav;
using capture::deriveBankPaths;
using capture::hashWavContent;
using capture::parseWavLayout;
using capture::projectDirOfRpp;
using capture::WavLayout;
using util::readFileBytes;
using version::channelActionName;
using version::channelCommandId;
using wire::AssignmentRequest;
using wire::buildInstrumentDropPreset;
using wire::encodeAssignmentRequest;
namespace {
// Not owned here (main.cpp owns g_session).
ReaSamplerSession* g_session = nullptr;
// FOREVER-STABLE suffix — NEVER change after ship. Only the Media-Explorer import
// registers here — the arrange capture+assign action lives in the capture family in
// main.cpp, 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{};
// c_str() pointers are handed to REAPER at register and re-presented at unregister,
// so these strings must not be mutated after registration.
std::string g_idImportStr;
std::string g_labelImportStr;
// Forward-slashed, no trailing slash. Empty for an unsaved/no-active project, which
// makes the import refuse to place a file (relative-paths invariant, no fallback).
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return projectDirOfRpp(std::string(buf.data()));
}
// 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();
}
// buildFloat32Wav (wav_codec) takes the interleaved ReaSample (double) frames
// decoded below and yields the canonical bank-format bytes — the double->float
// narrowing is the intentional bank contract.
// Returns empty on a zero-length or silent source. The caller has already queried
// channelCount/sampleRate from the same source (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));
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;
}
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 (the bank contract — a verbatim copy of anything else would be
// unplayable), write to the project-relative bank folder, index-add, hash-dedup.
//
// DEDUP ORDERING: the content hash is taken from the CONVERTED bytes AFTER building
// the buffer but BEFORE writing to disk, so a re-import of the same source (or of a
// WAV matching a captured file's content) collapses without a redundant disk write.
// Hashing the raw source bytes instead would miss this for non-WAV sources, since
// their bytes differ from the converted WAV bytes.
//
// NON-DESTRUCTIVE: the source file is only read. 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;
}
const std::vector<std::uint8_t> srcBytes = readFileBytes(absoluteSourcePath);
if (srcBytes.empty()) {
out.message = "file is empty or unreadable: " + absoluteSourcePath;
return out;
}
// 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
}
// parseWavLayout validates 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;
std::vector<std::uint8_t> bankBytes;
if (isFloat32Wav) {
bankBytes = srcBytes;
if (srcHandle) PCM_Source_Destroy(srcHandle);
} else {
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()) {
// e.g. a MIDI file, zero-length audio, or an unsupported format. Fail
// loudly rather than 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);
}
// WAV-aware hash so a re-import deduplicates against a previously-captured or
// previously-imported sample with identical audio content, even if non-audio
// RIFF chunks differ. Empty (unhashable) is "not dedupable" — copies + adds
// rather than silently collapsing onto an unrelated entry.
const std::string contentHash = hashWavContent(bankBytes);
BankBook& book = g_session->book();
// Dedup-before-disk: skip the write entirely if the active bank already holds
// this content.
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;
}
}
// 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);
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;
}
// Import is NOT a capture — capture-only fields stay at defaults. rootNote/loop
// stay empty: an imported file is not a single played note, so we do not guess.
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 as owned regardless of outcome — the tool WROTE the file, so prune must
// attribute it even in the narrow Collapsed race below.
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: {
// A race against the pre-write dedup check (or an empty-hash edge).
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:
// Unreachable in practice (deriveBankPaths always yields a relative path
// and non-empty id) — reported honestly rather than silently.
out.message = "index rejected the import (internal path/id error)";
break;
}
return out;
}
// Imports the Media Explorer's last-played/selected file into the active bank, then
// adds a ReaSampler 9000 instrument to the FIRST SELECTED TRACK pre-loaded with that
// sound — no new track, no routing changes. No assignment_request write.
//
// Single-file, pull-on-action (MediaExplorerGetLastPlayedFileInfo — no
// enumerate-selected API). The selection RANGE it reports is deliberately IGNORED:
// an import brings the whole file in ([0,1] fraction fields are a preview hint, not
// seconds); a sub-range user captures via the arrange path instead.
//
// LOAD-BEARING: NEVER inserts a timeline item, NEVER creates a track. Persist
// ordering is critical — the fresh instance's setState -> reloadInstrument reads the
// bank from project ext-state, so the sample MUST be persisted BEFORE
// loadInstrumentOntoTrack adds the FX, or the instance cannot resolve the sampleId.
void doImportFromMediaExplorer() {
// Only the filename is used; selstart/selend are [0,1] fractions (a preview
// hint, not a bank-relevant range), 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;
}
// GetSelectedTrack ignores the master; null means nothing selected — existing
// track only, never alter the graph.
MediaTrack* target = GetSelectedTrack(nullptr, 0);
if (!target) {
// Bank import is kept (sound is in the bank browser); generation is bumped
// so any open VST3 browser instances refresh to show the new sound.
if (r.added) {
Undo_BeginBlock2(nullptr);
g_session->bumpBankGeneration();
const bool persisted = g_session->saveToActiveProject();
if (persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import Media Explorer file into bank",
UNDO_STATE_MISCCFG);
else
Undo_EndBlock2(nullptr, "", 0);
bankPanelRefresh();
}
ShowConsoleMsg(("ReaSampler ingest: " + r.message +
" -- select a track first, then import into it "
"(sound is in the bank but no instrument was placed because "
"no track was selected).\n").c_str());
return;
}
// Valid for BOTH the fresh import and the dedup case (added == false but a real
// sampleId is sufficient to pre-select the sound).
const std::vector<std::uint8_t> preset = buildInstrumentDropPreset(r.sampleId);
// Persist happens INSIDE the block and BEFORE the FX add so the new instance's
// setState -> reloadInstrument sees the just-persisted sample.
Undo_BeginBlock2(nullptr);
bool persisted = true; // true when nothing needed persisting (dedup)
if (r.added) {
g_session->bumpBankGeneration();
persisted = g_session->saveToActiveProject();
}
const bool placed = loadInstrumentOntoTrack(target, preset);
if (placed && persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer into selected track",
UNDO_STATE_MISCCFG);
else
// Either the FX add/inject failed (already rolled back, no orphan) or the
// project was unsaved (persist no-op): discard so no empty point is recorded.
Undo_EndBlock2(nullptr, "", 0);
bankPanelRefresh();
if (placed)
ShowConsoleMsg(("ReaSampler ingest: " + r.message +
" (loaded into a new instrument on the selected track).\n").c_str());
else
ShowConsoleMsg(("ReaSampler ingest: imported to the bank (" + r.message +
") but could not add the instrument to the selected track.\n").c_str());
}
} // namespace
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 wall-clock stamp so the reader tells a fresh assign (even
// re-assigning the SAME id) from a stale value — self-contained to the request,
// not the bank-generation counter.
req.generation = static_cast<std::int64_t>(std::time(nullptr));
g_session->writeAssignmentRequest(encodeAssignmentRequest(req));
}
// Bank-fill only; no assignment_request is written (the drop has no effect on what
// any live instance plays). Batch the persist + undo point: many imports are ONE
// undo entry.
void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
if (!g_session || absolutePaths.empty()) return;
int importedNew = 0;
int importedTotal = 0;
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;
}
// One undo point for the whole drop, opened only if a NEW index entry was created.
if (importedNew > 0) {
Undo_BeginBlock2(nullptr);
g_session->bumpBankGeneration();
const bool persisted = g_session->saveToActiveProject();
if (persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import dropped file(s)", UNDO_STATE_MISCCFG);
else
Undo_EndBlock2(nullptr, "", 0);
bankPanelRefresh();
const std::string msg =
"ReaSampler ingest: imported " + std::to_string(importedTotal) +
(importedTotal == 1 ? " file" : " files") + " into the bank.\n";
ShowConsoleMsg(msg.c_str());
} else if (importedTotal > 0) {
bankPanelRefresh();
ShowConsoleMsg("ReaSampler ingest: all dropped files already in the bank.\n");
} else {
ShowConsoleMsg(("ReaSampler ingest: nothing imported from the drop -- " +
(lastFailure.empty() ? std::string("no usable files") : lastFailure) +
".\n").c_str());
}
}
void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
g_session = session;
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 selected track");
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) {
// '-command_id' re-presents the SAME interned 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
+50
View File
@@ -0,0 +1,50 @@
#pragma once
// ingest — the "ingest through the bank" shell (EXTENSION side). REAPER-facing
// (PCM_Source metadata reads, Media-Explorer query, ext-state assignment write,
// action registration), so DAW-verified, not unit-tested; the pure serialization it
// drives lives in assignment_request (CTest).
//
// 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 (arrange
// access, Media-Explorer access, drop-target surface); the instrument stays a
// READ-ONLY bank consumer. Three surfaces: (1) arrange capture -> bank -> assign,
// (2) Media-Explorer import -> bank -> assign (single-file, pull-on-action), (3)
// drop-onto-panel -> bank -> assign (multi-file: import all, assign the first).
//
// LOAD-BEARING: ingest NEVER inserts a timeline item — capture writes a file + index
// entry, import copies a file + adds an index entry, assignment is a bank-index +
// instance-selection act, not a placement. Any InsertMedia call here is a bug.
//
// Import is a FILE COPY into the project-relative bank folder + an index add
// (relative-paths-only, hash-dedup). If the active bank already holds the content
// (by hash), the import collapses onto the existing sample instead of duplicating.
#include <string>
#include <vector>
// Forward declarations keep this header REAPER-free (the .cpp pulls the SDK).
struct reaper_plugin_info_t;
namespace reasampler {
class ReaSamplerSession;
// `session` is 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);
bool ingestHandleCommand(int command);
void ingestUnregisterActions(reaper_plugin_info_t* rec);
// "The active sampler instance should now play (bankId, sampleId)." Called by EVERY
// ingest surface after the sample lands in the bank. No-op-safe: an unsaved/no-active
// project silently drops the write; `sampleId` empty -> no write.
void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId);
// Called by the bank_panel's WM_DROPFILES handler. Imports EVERY file into the
// active bank (hash-dedup) and assigns the FIRST successfully-imported sample to the
// active instance. No-op on an empty list or an unsaved/no-active project.
void ingestDroppedFiles(const std::vector<std::string>& absolutePaths);
} // namespace reasampler
+23 -12
View File
@@ -20,6 +20,7 @@
#define REAPERAPI_WANT_GetThingFromPoint
#define REAPERAPI_WANT_TrackFX_AddByName
#define REAPERAPI_WANT_TrackFX_Delete
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_SetPreset
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
@@ -29,6 +30,9 @@ namespace reasampler {
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
using version::vstPluginName;
using wire::decideDropOutcome;
using wire::DropAttempt;
using wire::DropOutcome;
using wire::infoNamesFxHotspot;
namespace {
@@ -99,25 +103,32 @@ bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>&
// (beta extension <-> beta VST) has no literal to drift.
const std::string fxName = "VST3:" + vstPluginName();
// Negative `instantiate` => always create a NEW instance. recFX = false: a
// normal track FX chain instance, not a record/monitoring FX.
const int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
/*instantiate=*/-1);
bool ok = fxIndex >= 0;
// An EXPLICIT top-level insertion position (instantiate <= -1000 IS the position, -1000
// = first in chain), not the bare -1. Both always create a new instance; the bare form
// additionally leaves placement to REAPER's ambient FX-chain insert point, which a drop
// onto an FX container/chain-window moves — so the index handed to TrackFX_SetPreset and
// the instance just created stop denoting the same FX and the capture never lands. The
// bare-form retry keeps the reference path alive if the positional form is ever refused.
// recFX = false: a normal track FX chain instance, not a record/monitoring FX.
const int insertPos = TrackFX_GetCount(track);
int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
/*instantiate=*/-1000 - insertPos);
if (fxIndex < 0)
fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false, /*instantiate=*/-1);
// u8string() gives UTF-8 bytes on MSVC (not ACP-converted), so a temp dir under
// an accented or CJK user-name is handled correctly by REAPER's path APIs.
if (ok) ok = TrackFX_SetPreset(track, fxIndex, presetPath.u8string().c_str());
DropAttempt attempt;
attempt.addedFxIndex = fxIndex;
if (fxIndex >= 0)
attempt.presetApplied = TrackFX_SetPreset(track, fxIndex, presetPath.u8string().c_str());
std::error_code ec;
std::filesystem::remove(presetPath, ec); // transient regardless of outcome
// All-or-nothing: if the preset apply fails, remove the FX instance we just
// added so the track is left exactly as it was.
if (!ok && fxIndex >= 0) {
TrackFX_Delete(track, fxIndex);
}
return ok;
const DropOutcome outcome = decideDropOutcome(attempt);
if (outcome.rollbackFxIndex >= 0) TrackFX_Delete(track, outcome.rollbackFxIndex);
return outcome.loaded;
}
bool performInstrumentDrop(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes) {
+1 -1
View File
@@ -12,7 +12,7 @@
#include "shell/panel/panel_input.h" // bankPanelTailSetting / bankPanelRefresh
#include "core/capture/tail_control.h" // TailSetting
#include "core/model/provenance.h" // model::Provenance
#include "ingest.h" // ingestAssignActiveInstance
#include "shell/actions/ingest.h" // ingestAssignActiveInstance
#include "shell/persist/session.h" // ReaSamplerSession
#include "shell/capture/insert.h" // runInsert / InsertRequest
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
+27 -10
View File
@@ -298,8 +298,13 @@ void onMouseMove(int x, int y) {
if (gesture == DragGesture::OsDrag) {
// Resolve the payload to existing on-disk paths BEFORE tearing down internal
// drag state (the resolver reads dragSourceBankId / dragSampleIds).
// drag state (the resolver reads dragSourceBankId / dragSampleIds), then let the
// pure rule couple the two side effects: an unresolvable payload must leave the
// internal drag intact rather than wind it down for a hand-off that never runs —
// a half-torn-down drag reads to the user as "the drag did nothing, try again".
const std::vector<std::string> paths = resolveDragPathsForOs();
const ui::OsHandoff handoff = ui::decideOsHandoff(paths);
if (!handoff.releaseInternalDrag) return;
// DoDragDrop runs its own modal loop and takes over mouse capture, so the internal
// drag must be fully wound down first.
@@ -313,8 +318,7 @@ void onMouseMove(int x, int y) {
g_panel.dragPrimaryId.clear();
invalidatePanel();
// Empty path list -> nothing draggable (all stale/missing); do not start a drag.
if (!paths.empty())
if (handoff.startOsDrag)
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
return;
}
@@ -377,14 +381,27 @@ void resetDragState() {
// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove.
void onLBtnUp(int x, int y) {
if (g_panel.dragging) {
// Drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a
// ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move,
// NOT an OS drag, NEVER a timeline insert. Takes priority over the in-grid / cross-bank
// drop. Single-capture only, so dragSampleIds.front() is the capture.
if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) {
// Drop-and-load: a release over a valid FX hotspot instantiates a ReaSampler 9000 on
// that track preloaded with the dragged capture — NOT a bank move, NOT an OS drag,
// NEVER a timeline insert. Takes priority over the in-grid / cross-bank drop.
// Single-capture only, so dragSampleIds.front() is the capture.
//
// Re-resolve at the RELEASE point rather than trusting only the hover-tracked target:
// WM_MOUSEMOVE is coalesced, so a fast drag onto a dense surface (an FX chain row, a
// container) can release over a hotspot no processed move ever reported. Strictly
// additive — a release-point miss falls back to the tracked target, so the
// hover-then-release-on-the-FX-button path is untouched.
const bool singleCapture = g_panel.dragSampleIds.size() == 1;
MediaTrack* dropTrack = g_panel.instrumentDropTrack;
if (singleCapture) {
POINT sp{x, y};
ClientToScreen(g_panel.hwnd, &sp);
const FxDropTarget fx = resolveFxDropTarget(sp.x, sp.y);
if (fx.valid()) dropTrack = fx.track;
}
if (dropTrack && singleCapture) {
const std::string sampleId = g_panel.dragSampleIds.front();
performInstrumentDrop(g_panel.instrumentDropTrack,
buildInstrumentDropPreset(sampleId));
performInstrumentDrop(dropTrack, buildInstrumentDropPreset(sampleId));
// Read-only over the bank + arrange: the only mutations are the new FX instance +
// its state (both undoable in performInstrumentDrop).
} else {
+1 -1
View File
@@ -14,7 +14,7 @@
#include "shell/panel/panel_window.h"
#include "shell/panel/draw_kit.h"
#include "ingest.h"
#include "shell/actions/ingest.h"
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)