feat(S8): ingest through the bank — capture/import/drop into bank + assign to active instance
This commit is contained in:
+384
@@ -0,0 +1,384 @@
|
||||
// ingest.cpp — the S8 "ingest through the bank" shell (extension side). See ingest.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are extern
|
||||
// (CLAUDE.md §contract). REAPER-facing, DAW-verified; the pure serialization it drives
|
||||
// (assignment_request) is CTest-tested.
|
||||
|
||||
#include "ingest.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path)
|
||||
#include "app_version.h" // channelCommandId / channelActionName
|
||||
#include "assignment_request.h" // pure (bankId, sampleId, generation) encode
|
||||
#include "bank_book.h" // BankBook, Bank, activeBankId / activeIndex
|
||||
#include "bank_model.h" // Sample, AddResult, findByHash
|
||||
#include "bank_panel.h" // bankPanelRefresh
|
||||
#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent
|
||||
#include "persist.h" // ReaSamplerSession
|
||||
|
||||
#include "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
|
||||
#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. A vector of
|
||||
// two entries suffices here (one action); pointers into it are handed to REAPER at register
|
||||
// and re-presented at unregister, so it must not be resized after registration. Reserved up
|
||||
// front so the register-time c_str() stays valid for the unload path.
|
||||
std::string g_idImportStr;
|
||||
std::string g_labelImportStr;
|
||||
|
||||
// --- Project directory --------------------------------------------------------
|
||||
|
||||
// The current project's directory (parent of its .rpp), forward-slashed, no trailing
|
||||
// slash — the M4 convention (projectDirOfRpp). Empty for an unsaved/no-active project,
|
||||
// which makes the import refuse to place a file (no default-location fallback — the
|
||||
// relative-paths invariant). Read-only.
|
||||
std::string currentProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror
|
||||
// of capture.cpp's readFileBytes — used to hash an import source for dedup + a copied file
|
||||
// for the Sample's content hash.
|
||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return {};
|
||||
const std::streamsize n = f.tellg();
|
||||
if (n <= 0) return {};
|
||||
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(n));
|
||||
f.seekg(0);
|
||||
f.read(reinterpret_cast<char*>(bytes.data()), n);
|
||||
if (!f) return {};
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// 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: copy into the project-relative
|
||||
// bank folder + index add, hash-dedup applied. Mirrors capture's landing semantics
|
||||
// (relative-paths-only, content-hash dedup) for a file the user brought in rather than
|
||||
// captured. Populates the Sample's metadata by probing the file via PCM_Source.
|
||||
//
|
||||
// DEDUP-BEFORE-COPY: hash the SOURCE first and consult the active bank; if the content is
|
||||
// already held, assign the existing sample's id and DO NOT copy a redundant file to disk.
|
||||
// Only a genuinely new file is copied + added. This is stricter than capture (which renders
|
||||
// then adds, leaving a collapsed render orphaned) because an import can cheaply check first.
|
||||
//
|
||||
// NON-DESTRUCTIVE: the source file is never modified or moved — only read + copied.
|
||||
// Records the copied 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;
|
||||
}
|
||||
|
||||
// Hash the SOURCE content up front (WAV-aware, same hasher captures use so an imported
|
||||
// WAV dedups against a captured one byte-for-byte on audio content). An unreadable
|
||||
// source yields an empty hash — treated as "not dedupable" (the safe direction: it
|
||||
// will be copied + added rather than silently collapsed onto an unrelated entry).
|
||||
const std::vector<std::uint8_t> srcBytes = readFileBytes(absoluteSourcePath);
|
||||
if (srcBytes.empty()) {
|
||||
out.message = "file is empty or unreadable: " + absoluteSourcePath;
|
||||
return out;
|
||||
}
|
||||
const std::string contentHash = hashWavContent(srcBytes);
|
||||
|
||||
BankBook& book = g_session->book();
|
||||
const std::string activeBankId = book.activeBankId();
|
||||
|
||||
// Dedup-before-copy: if the active bank already holds this content, assign the existing
|
||||
// id and skip the copy (no redundant on-disk duplicate). Empty hashes never match
|
||||
// (findByHash treats "" as non-participating), so an unhashable file always imports fresh.
|
||||
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 (project-relative index value + absolute copy target). The
|
||||
// stem comes from the source file name; a timestamp uniqueTag avoids collision with a
|
||||
// prior import of a same-named file. (fs::path::stem strips the extension and any dir.)
|
||||
const std::string sourceStem = fs::path(absoluteSourcePath).stem().string();
|
||||
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
|
||||
const std::string uniqueTag = std::to_string(nowSec);
|
||||
const BankPaths paths = deriveBankPaths(projectDir, sourceStem, uniqueTag);
|
||||
|
||||
// Ensure the bank folder exists, then copy the source in (never move — the source is the
|
||||
// user's file and must be left intact; non-destructive). copy_file with overwrite off:
|
||||
// the uniqueTag makes a collision astronomically unlikely, and if one occurs we fail
|
||||
// loudly rather than clobber.
|
||||
fs::create_directories(paths.absoluteDir, ec); // idempotent; ec ignored (copy reports)
|
||||
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
|
||||
fs::copy_file(absoluteSourcePath, destPath, fs::copy_options::none, ec);
|
||||
if (ec) {
|
||||
out.message = "could not copy into the bank folder (" + ec.message() + ")";
|
||||
return out;
|
||||
}
|
||||
|
||||
// Probe the copied file's audio geometry via PCM_Source (channels / rate / length) so
|
||||
// the Sample carries real metadata the panel + instrument read. A file REAPER cannot
|
||||
// open leaves these at their zero-values — the sample still imports (the file is on
|
||||
// disk, the hash is set); the geometry is simply unknown, the honest default.
|
||||
int channelCount = 0;
|
||||
int sampleRate = 0;
|
||||
double lengthSeconds = 0.0;
|
||||
if (PCM_source* src = PCM_Source_CreateFromFile(destPath.c_str())) {
|
||||
channelCount = GetMediaSourceNumChannels(src);
|
||||
sampleRate = GetMediaSourceSampleRate(src);
|
||||
bool isQN = false;
|
||||
lengthSeconds = GetMediaSourceLength(src, &isQN);
|
||||
if (isQN) lengthSeconds = 0.0; // a QN-length source has no seconds length to store
|
||||
PCM_Source_Destroy(src);
|
||||
}
|
||||
|
||||
// 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 (D-B): 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 copied 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; we pre-checked findByHash above, so in practice
|
||||
// Added is the outcome. But record + handle both honestly.)
|
||||
g_session->owned().add(paths.relativePath);
|
||||
|
||||
switch (r) {
|
||||
case AddResult::Added:
|
||||
out.sampleId = s.id;
|
||||
out.added = true;
|
||||
out.message = "imported -> " + paths.relativePath;
|
||||
break;
|
||||
case AddResult::Collapsed: {
|
||||
// The hash matched an existing entry (a race against our pre-check, or an empty-
|
||||
// hash edge). Assign the existing entry's id, not the rejected new one.
|
||||
const Sample* existing =
|
||||
contentHash.empty() ? nullptr : book.activeIndex().findByHash(contentHash);
|
||||
out.sampleId = existing ? existing->id : std::string{};
|
||||
out.added = false;
|
||||
out.message = "collapsed onto an existing bank sample";
|
||||
break;
|
||||
}
|
||||
case AddResult::RejectedAbsolutePath:
|
||||
case AddResult::RejectedEmptyId:
|
||||
// deriveBankPaths always yields a relative path and a non-empty id above, so
|
||||
// these are unreachable in practice — reported honestly rather than silently.
|
||||
out.message = "index rejected the import (internal path/id error)";
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Media-Explorer import action --------------------------------------------
|
||||
|
||||
// Import the Media Explorer's current last-played/selected file into the active bank and
|
||||
// assign it to the active instance (S8 surface 2). Single-file, pull-on-action:
|
||||
// MediaExplorerGetLastPlayedFileInfo returns the ONE last-played file (the whole ME
|
||||
// contract — no enumerate-selected API). The selection RANGE it reports is deliberately
|
||||
// IGNORED here: an import brings the whole file into the bank (the range is a preview
|
||||
// hint, and the fields are [0,1] fractions, not seconds — see the DAW-verify note); a
|
||||
// user wanting a sub-range captures it via the arrange path instead. Undo-wrapped.
|
||||
void doImportFromMediaExplorer() {
|
||||
// filemode/sel/pitch/vol/rate/bpm/extrainfo are read but only the filename is used for
|
||||
// the import. selstart/selend are [0,1] fractions (SDK header) — a preview hint, not a
|
||||
// bank-relevant range; left unused. extrainfo is documented "currently unused".
|
||||
std::vector<char> nameBuf(4096, '\0');
|
||||
int filemode = 0;
|
||||
double selStart = 0.0, selEnd = 0.0;
|
||||
double pitch = 0.0, vol = 0.0, rate = 0.0, srcbpm = 0.0;
|
||||
std::vector<char> extra(256, '\0'); // documented unused; sized generously to be safe
|
||||
const bool ok = MediaExplorerGetLastPlayedFileInfo(
|
||||
nameBuf.data(), static_cast<int>(nameBuf.size()), &filemode, &selStart, &selEnd,
|
||||
&pitch, &vol, &rate, &srcbpm, extra.data(), static_cast<int>(extra.size()));
|
||||
|
||||
const std::string path(nameBuf.data());
|
||||
if (!ok || path.empty()) {
|
||||
ShowConsoleMsg("ReaSampler ingest: no Media Explorer file to import -- open the "
|
||||
"Media Explorer and select (or preview) a file first.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const ImportResult r = importFileIntoActiveBank(path);
|
||||
if (r.sampleId.empty()) {
|
||||
ShowConsoleMsg(("ReaSampler ingest: Media Explorer import failed -- " + r.message +
|
||||
".\n").c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the bank add as one undo point ONLY when the index actually mutated (a
|
||||
// dedup collapse changed nothing on the index). Then assign + refresh the panel.
|
||||
if (r.added) persistBankOp("ReaSampler: import from Media Explorer");
|
||||
ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId);
|
||||
bankPanelRefresh();
|
||||
ShowConsoleMsg(("ReaSampler ingest: " + r.message + " (assigned to the active "
|
||||
"instance).\n").c_str());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- Assignment-request write ------------------------------------------------
|
||||
|
||||
void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId) {
|
||||
if (!g_session || sampleId.empty()) return; // nothing to assign
|
||||
|
||||
AssignmentRequest req;
|
||||
req.bankId = bankId;
|
||||
req.sampleId = sampleId;
|
||||
// Monotonic disambiguator: a wall-clock unix-epoch stamp so the reader tells a fresh
|
||||
// assign (even re-assigning the SAME id) from a stale value. NOT the S9 bank-generation
|
||||
// counter (a separate point) — this field is self-contained to the request.
|
||||
req.generation = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
g_session->writeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
}
|
||||
|
||||
// --- Drop-onto-panel ingest --------------------------------------------------
|
||||
|
||||
void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
|
||||
if (!g_session || absolutePaths.empty()) return;
|
||||
|
||||
// Import ALL dropped files; assign the FIRST successfully-imported one (documented
|
||||
// multi-file policy). Batch the persist + undo point: many imports are ONE undo entry.
|
||||
std::string firstAssignId;
|
||||
std::string firstAssignBank;
|
||||
int importedNew = 0;
|
||||
int importedTotal = 0; // includes dedup collapses that still yielded an id to assign
|
||||
std::string lastFailure;
|
||||
|
||||
for (const std::string& path : absolutePaths) {
|
||||
const ImportResult r = importFileIntoActiveBank(path);
|
||||
if (r.sampleId.empty()) {
|
||||
lastFailure = r.message;
|
||||
continue;
|
||||
}
|
||||
++importedTotal;
|
||||
if (r.added) ++importedNew;
|
||||
if (firstAssignId.empty()) {
|
||||
firstAssignId = r.sampleId;
|
||||
firstAssignBank = g_session->book().activeBankId();
|
||||
}
|
||||
}
|
||||
|
||||
// One undo point for the whole drop, opened only if a NEW index entry was created (a
|
||||
// drop that only re-hit existing content mutated nothing on the index). persistBankOp
|
||||
// itself no-ops the block on an unsaved project.
|
||||
if (importedNew > 0) persistBankOp("ReaSampler: import dropped file(s)");
|
||||
|
||||
if (!firstAssignId.empty()) {
|
||||
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
|
||||
Reference in New Issue
Block a user