627 lines
31 KiB
C++
627 lines
31 KiB
C++
// ingest.cpp — the S8 "ingest through the bank" shell (extension side). See ingest.h.
|
|
//
|
|
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT
|
|
// REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are extern
|
|
// (CLAUDE.md §contract). REAPER-facing, DAW-verified; the pure serialization it drives
|
|
// (assignment_request) is CTest-tested.
|
|
|
|
#include "ingest.h"
|
|
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path)
|
|
#include "app_version.h" // channelCommandId / channelActionName
|
|
#include "assignment_request.h" // pure (bankId, sampleId, generation) encode
|
|
#include "bank_book.h" // BankBook, Bank, activeBankId / activeIndex
|
|
#include "bank_model.h" // Sample, AddResult, findByHash
|
|
#include "bank_panel.h" // bankPanelRefresh
|
|
#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent
|
|
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
|
#include "instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
|
|
#include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
|
|
#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
|
|
#define REAPERAPI_WANT_GetSelectedTrack
|
|
#include "reaper_plugin_functions.h"
|
|
|
|
namespace reasampler {
|
|
|
|
namespace {
|
|
|
|
// The live session the ingest paths mutate. Set once by ingestRegisterActions and read by
|
|
// every ingest body. Not owned here (main.cpp owns g_session).
|
|
ReaSamplerSession* g_session = nullptr;
|
|
|
|
// FOREVER-STABLE ingest action-id SUFFIX (Phase V, V4). The channel prefix is prepended at
|
|
// register via channelCommandId; NEVER change a shipped suffix. Only the Media-Explorer
|
|
// import registers here — the arrange capture+assign action lives in the capture family in
|
|
// main.cpp (it reuses the capture render machinery there), and the drop path is a panel
|
|
// callback (ingestDroppedFiles), not a bindable action.
|
|
constexpr const char* kIdImportMediaExplorer = "INGEST_IMPORT_MEDIA_EXPLORER";
|
|
|
|
int g_cmdImportMediaExplorer = 0;
|
|
gaccel_register_t g_accelImportMediaExplorer{};
|
|
|
|
// Durable store of the composed, channel-qualified command-id + label strings. Two scalar
|
|
// std::string globals (one action); their c_str() pointers are handed to REAPER at register
|
|
// and re-presented at unregister, so these strings must not be mutated after registration.
|
|
// Populated once by ingestRegisterActions; stable for the extension lifetime.
|
|
std::string g_idImportStr;
|
|
std::string g_labelImportStr;
|
|
|
|
// --- Project directory --------------------------------------------------------
|
|
|
|
// The current project's directory (parent of its .rpp), forward-slashed, no trailing
|
|
// slash — the M4 convention (projectDirOfRpp). Empty for an unsaved/no-active project,
|
|
// which makes the import refuse to place a file (no default-location fallback — the
|
|
// relative-paths invariant). Read-only.
|
|
std::string currentProjectDir() {
|
|
std::vector<char> buf(4096, '\0');
|
|
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
|
return projectDirOfRpp(std::string(buf.data()));
|
|
}
|
|
|
|
// Whole-file reads (source read + bank-copy validate/hash) go through the shared
|
|
// core/util readFileBytes (Q-W1, T2-03): empty on any failure (missing / unreadable).
|
|
|
|
// Writes a byte buffer to a file. Returns true on success. The caller is responsible for
|
|
// ensuring the directory exists before calling.
|
|
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
|
|
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
|
if (!f) return false;
|
|
f.write(reinterpret_cast<const char*>(bytes.data()),
|
|
static_cast<std::streamsize>(bytes.size()));
|
|
return f.good();
|
|
}
|
|
|
|
// Builds a minimal 32-bit-float RIFF/WAVE byte buffer from interleaved double samples.
|
|
// The output is a canonical WAV the bank and wav_trim can read:
|
|
// RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, 16-byte body),
|
|
// data chunk (interleaved little-endian float32, one float per sample per channel).
|
|
// `nch` channels, `rate` Hz sample rate, `frameCount` frames (total samples = frameCount*nch).
|
|
// Each ReaSample (double) is narrowed to float by assignment — the instrument expects
|
|
// 32-bit float; the reduction is intentional and matches how the bank contract is defined
|
|
// (capture.cpp kRenderFormatWavFloat32; wav_trim.h FORMAT ASSUMPTION).
|
|
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
|
std::size_t frameCount,
|
|
const std::vector<ReaSample>& interleaved) {
|
|
const std::size_t sampleCount = frameCount * static_cast<std::size_t>(nch);
|
|
const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32
|
|
|
|
// The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload.
|
|
// Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount.
|
|
const std::uint32_t riffSize =
|
|
static_cast<std::uint32_t>(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data
|
|
|
|
std::vector<std::uint8_t> out;
|
|
out.reserve(44u + dataBytesCount);
|
|
|
|
auto putU16 = [&](std::uint16_t v) {
|
|
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
|
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
|
};
|
|
auto putU32 = [&](std::uint32_t v) {
|
|
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
|
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
|
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
|
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
|
};
|
|
auto putTag = [&](const char* t) {
|
|
for (int i = 0; i < 4; ++i)
|
|
out.push_back(static_cast<std::uint8_t>(t[i]));
|
|
};
|
|
auto putF32 = [&](float f) {
|
|
std::uint8_t tmp[4];
|
|
std::memcpy(tmp, &f, 4);
|
|
for (int i = 0; i < 4; ++i) out.push_back(tmp[i]);
|
|
};
|
|
|
|
// RIFF header
|
|
putTag("RIFF");
|
|
putU32(riffSize);
|
|
putTag("WAVE");
|
|
|
|
// fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003)
|
|
putTag("fmt ");
|
|
putU32(16u); // chunk body size
|
|
putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT
|
|
putU16(static_cast<std::uint16_t>(nch));
|
|
putU32(rate);
|
|
putU32(rate * static_cast<std::uint32_t>(nch) * 4u); // avgBytesPerSec
|
|
putU16(static_cast<std::uint16_t>(nch * 4)); // blockAlign
|
|
putU16(32u); // bitsPerSample
|
|
|
|
// data chunk
|
|
putTag("data");
|
|
putU32(static_cast<std::uint32_t>(dataBytesCount));
|
|
for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
|
|
putF32(static_cast<float>(interleaved[i]));
|
|
|
|
return out;
|
|
}
|
|
|
|
// Decodes ALL samples from `src` into interleaved double-precision frames.
|
|
// Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0).
|
|
// Uses GetSamples in blocks; advances time_s monotonically. The caller has already
|
|
// queried channelCount and sampleRate from the same source; those values are passed in
|
|
// to avoid re-querying after GetSamples mutates decoder state.
|
|
std::vector<ReaSample> decodePcmSource(PCM_source* src, int nch, double sampleRate,
|
|
double lengthSeconds) {
|
|
if (!src || nch <= 0 || sampleRate < 1.0 || lengthSeconds <= 0.0) return {};
|
|
|
|
const std::size_t totalFrames =
|
|
static_cast<std::size_t>(lengthSeconds * sampleRate + 0.5);
|
|
if (totalFrames == 0) return {};
|
|
|
|
std::vector<ReaSample> out;
|
|
out.reserve(totalFrames * static_cast<std::size_t>(nch));
|
|
|
|
// Pull samples in blocks of ~4096 frames; loop until source is exhausted.
|
|
constexpr int kBlockFrames = 4096;
|
|
std::vector<ReaSample> block(static_cast<std::size_t>(kBlockFrames * nch));
|
|
|
|
PCM_source_transfer_t t{};
|
|
t.samplerate = sampleRate;
|
|
t.nch = nch;
|
|
t.time_s = 0.0;
|
|
t.midi_events = nullptr;
|
|
|
|
while (true) {
|
|
t.samples = block.data();
|
|
t.length = kBlockFrames;
|
|
t.samples_out = 0;
|
|
src->GetSamples(&t);
|
|
if (t.samples_out <= 0) break;
|
|
const std::size_t got = static_cast<std::size_t>(t.samples_out) *
|
|
static_cast<std::size_t>(nch);
|
|
out.insert(out.end(), block.data(), block.data() + got);
|
|
t.time_s += static_cast<double>(t.samples_out) / sampleRate;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The result of an import-into-bank: the sample id to assign (the existing id on a
|
|
// hash-dedup collapse, the new id otherwise) and whether anything was added to the index
|
|
// (so the caller opens an undo point only for a real mutation).
|
|
struct ImportResult {
|
|
std::string sampleId; // "" on failure (nothing to assign)
|
|
bool added = false; // true iff a NEW index entry was created (not a collapse)
|
|
std::string message; // human-readable outcome for the console
|
|
};
|
|
|
|
// Imports one OS-native source file into the ACTIVE bank: convert-if-needed to 32-bit-
|
|
// float WAV, write to the project-relative bank folder, index-add, hash-dedup applied.
|
|
//
|
|
// BANK CONTRACT: the instrument (wav_trim) expects every bank file to be a canonical
|
|
// 32-bit-float WAV (WAVE_FORMAT_IEEE_FLOAT, 32 bits). A verbatim copy of a non-WAV (or
|
|
// an integer-PCM or double-float WAV) would be unplayable. This function therefore:
|
|
// 1. Checks whether the source IS already a valid 32f WAV (parseWavLayout fast path).
|
|
// 2. If yes: copies it verbatim — one I/O, content unchanged.
|
|
// 3. If no: decodes via PCM_source::GetSamples and writes a fresh 32f WAV, preserving
|
|
// the source's channel count and sample rate.
|
|
//
|
|
// DEDUP ORDERING: the content hash is taken from the CONVERTED (bank-format) bytes AFTER
|
|
// building the file buffer but BEFORE writing to disk. This means:
|
|
// * Re-importing the same source file yields the same converted bytes → same hash →
|
|
// dedup fires → no redundant disk write (matching the DEDUP-BEFORE-DISK design).
|
|
// * An imported WAV whose audio-content hash matches a captured WAV also deduplicates
|
|
// correctly (hashWavContent is chunk-aware for both).
|
|
// * The pre-conversion hash shortcut (hash the raw source bytes) is not used: a non-WAV
|
|
// source's bytes would produce a different hash from the converted WAV bytes, so two
|
|
// imports of the same mp3 would NOT dedup — which is wrong. Hashing post-conversion
|
|
// is correct.
|
|
//
|
|
// NON-DESTRUCTIVE: the source file is never modified or moved — only read.
|
|
// Records the written file in the owned-file manifest (Phase B B-cap) so Phase R prune can
|
|
// attribute it. Does NOT persist or open an undo point — the caller batches that (a
|
|
// multi-file drop is one undo point, one persist).
|
|
ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
|
|
ImportResult out;
|
|
|
|
if (absoluteSourcePath.empty()) {
|
|
out.message = "empty file path";
|
|
return out;
|
|
}
|
|
namespace fs = std::filesystem;
|
|
std::error_code ec;
|
|
if (!fs::exists(absoluteSourcePath, ec) || ec) {
|
|
out.message = "file not found: " + absoluteSourcePath;
|
|
return out;
|
|
}
|
|
|
|
const std::string projectDir = currentProjectDir();
|
|
if (projectDir.empty()) {
|
|
out.message = "no saved project, so the bank has no location -- save the "
|
|
"project first";
|
|
return out;
|
|
}
|
|
|
|
// Read source bytes; needed to check whether it is already a 32f WAV.
|
|
const std::vector<std::uint8_t> srcBytes = readFileBytes(absoluteSourcePath);
|
|
if (srcBytes.empty()) {
|
|
out.message = "file is empty or unreadable: " + absoluteSourcePath;
|
|
return out;
|
|
}
|
|
|
|
// Probe the source's audio geometry via PCM_source. Needed for conversion AND for
|
|
// populating the Sample's metadata. A file REAPER cannot open leaves geometry at
|
|
// zero — the sample still imports if the WAV-fast-path succeeds; the geometry
|
|
// is simply unknown, the honest default.
|
|
int channelCount = 0;
|
|
int sampleRate = 0;
|
|
double lengthSeconds = 0.0;
|
|
PCM_source* srcHandle = PCM_Source_CreateFromFile(absoluteSourcePath.c_str());
|
|
if (srcHandle) {
|
|
channelCount = GetMediaSourceNumChannels(srcHandle);
|
|
sampleRate = GetMediaSourceSampleRate(srcHandle);
|
|
bool isQN = false;
|
|
lengthSeconds = GetMediaSourceLength(srcHandle, &isQN);
|
|
if (isQN) lengthSeconds = 0.0; // QN-length source has no seconds length to store
|
|
}
|
|
|
|
// Determine whether a verbatim copy suffices (fast path) or a conversion is needed.
|
|
// parseWavLayout validates that the source is a canonical 32-bit-float RIFF/WAVE; any
|
|
// other format (mp3, aiff, integer PCM, 16-bit WAV, etc.) takes the decode+rewrite path.
|
|
const WavLayout layout = parseWavLayout(srcBytes);
|
|
const bool isFloat32Wav = layout.valid;
|
|
|
|
// Build the bank-format bytes in memory (the "converted" bytes), which we hash for dedup
|
|
// BEFORE writing to disk so a re-import of the same source skips the disk write.
|
|
std::vector<std::uint8_t> bankBytes;
|
|
if (isFloat32Wav) {
|
|
// Fast path: already canonical — bank bytes ARE the source bytes.
|
|
bankBytes = srcBytes;
|
|
if (srcHandle) PCM_Source_Destroy(srcHandle);
|
|
} else {
|
|
// Conversion path: decode all samples then write a fresh 32f WAV.
|
|
// PCM_source is opened on the source path (not a copy); we already have srcHandle.
|
|
std::vector<ReaSample> decoded;
|
|
if (srcHandle && channelCount > 0 && sampleRate > 0 && lengthSeconds > 0.0) {
|
|
decoded = decodePcmSource(srcHandle, channelCount,
|
|
static_cast<double>(sampleRate), lengthSeconds);
|
|
}
|
|
if (srcHandle) PCM_Source_Destroy(srcHandle);
|
|
|
|
if (decoded.empty()) {
|
|
// No decodable audio. The source is on disk (valid path, REAPER could open it)
|
|
// but yielded no samples — e.g. a MIDI file, a zero-length audio file, or a
|
|
// format REAPER does not support. Fail loudly: we must not write a silent WAV
|
|
// and pretend the import succeeded.
|
|
out.message = "could not decode audio samples from: " +
|
|
fs::path(absoluteSourcePath).filename().string() +
|
|
" (unsupported format or no audio data)";
|
|
return out;
|
|
}
|
|
|
|
const std::size_t frameCount =
|
|
decoded.size() / static_cast<std::size_t>(channelCount > 0 ? channelCount : 1);
|
|
bankBytes = buildFloat32Wav(channelCount,
|
|
static_cast<std::uint32_t>(sampleRate),
|
|
frameCount, decoded);
|
|
}
|
|
// srcHandle is destroyed above in both branches.
|
|
|
|
// Hash the converted (bank-format) bytes for dedup. WAV-aware hash (hashWavContent)
|
|
// so a re-import of the same source deduplicates against a previously-captured or
|
|
// previously-imported sample with identical audio content, even if non-audio RIFF
|
|
// chunks differ. Empty hash (unhashable) is treated as "not dedupable" (safe direction:
|
|
// copies + adds rather than silently collapsing onto an unrelated entry).
|
|
const std::string contentHash = hashWavContent(bankBytes);
|
|
|
|
BankBook& book = g_session->book();
|
|
|
|
// Dedup-before-disk: if the active bank already holds this audio content, assign the
|
|
// existing sample's id and skip the disk write (no redundant on-disk duplicate).
|
|
// Empty hashes never match (findByHash treats "" as non-participating).
|
|
if (!contentHash.empty()) {
|
|
if (const Sample* existing = book.activeIndex().findByHash(contentHash)) {
|
|
out.sampleId = existing->id;
|
|
out.added = false; // already present — no index mutation, no undo point
|
|
out.message = "already in the active bank (assigned existing sample)";
|
|
return out;
|
|
}
|
|
}
|
|
|
|
// Derive the destination path. The stem comes from the source file name; a timestamp
|
|
// uniqueTag avoids collision with a prior import of a same-named file.
|
|
const std::string sourceStem = fs::path(absoluteSourcePath).stem().string();
|
|
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
|
|
const std::string uniqueTag = std::to_string(nowSec);
|
|
const BankPaths paths = deriveBankPaths(projectDir, sourceStem, uniqueTag);
|
|
|
|
// Ensure the bank folder exists, then write the (converted) bank bytes.
|
|
fs::create_directories(paths.absoluteDir, ec); // idempotent; ec ignored (write reports)
|
|
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
|
|
if (!writeFileBytes(destPath, bankBytes)) {
|
|
out.message = "could not write converted file to the bank folder";
|
|
return out;
|
|
}
|
|
|
|
// Build the Sample. Import is NOT a capture — sourceMode/range/tail do not apply; we
|
|
// record what we know (path, hash, geometry, name) and leave capture-only fields at
|
|
// their defaults. rootNote/loop stay empty: an imported file is not a single played
|
|
// note, so we do not guess a root note.
|
|
Sample s;
|
|
s.id = "imp-" + uniqueTag + "-" + paths.fileName;
|
|
s.displayName = sourceStem.empty() ? std::string("import") : sourceStem;
|
|
s.relativePath = paths.relativePath; // project-relative (invariant)
|
|
s.channelCount = channelCount;
|
|
s.sampleRate = sampleRate;
|
|
s.lengthSeconds = lengthSeconds;
|
|
s.tier = Tier::Scratch; // imports land in scratch, like captures
|
|
s.contentHash = contentHash;
|
|
s.createdTimestamp = nowSec;
|
|
|
|
const AddResult r = book.activeIndex().add(s);
|
|
// Record the written file as owned regardless of the add outcome — the tool WROTE it, so
|
|
// Phase R prune must attribute it. (A Collapsed result here would mean another sample in
|
|
// the active bank matched the hash after we passed the pre-write dedup check — a narrow
|
|
// race window. Record + handle both honestly.)
|
|
g_session->owned().add(paths.relativePath);
|
|
|
|
switch (r) {
|
|
case AddResult::Added:
|
|
out.sampleId = s.id;
|
|
out.added = true;
|
|
out.message = (isFloat32Wav ? "imported -> " : "converted + imported -> ") +
|
|
paths.relativePath;
|
|
break;
|
|
case AddResult::Collapsed: {
|
|
// The hash matched an existing entry (a race against our pre-write dedup check,
|
|
// or an empty-hash edge). Assign the existing entry's id.
|
|
const Sample* existing =
|
|
contentHash.empty() ? nullptr : book.activeIndex().findByHash(contentHash);
|
|
out.sampleId = existing ? existing->id : std::string{};
|
|
out.added = false;
|
|
out.message = "collapsed onto an existing bank sample";
|
|
break;
|
|
}
|
|
case AddResult::RejectedAbsolutePath:
|
|
case AddResult::RejectedEmptyId:
|
|
// deriveBankPaths always yields a relative path and a non-empty id above, so
|
|
// these are unreachable in practice — reported honestly rather than silently.
|
|
out.message = "index rejected the import (internal path/id error)";
|
|
break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// --- Media-Explorer import action --------------------------------------------
|
|
|
|
// Import the Media Explorer's current last-played/selected file into the active bank, then
|
|
// add a ReaSampler 9000 instrument to the FIRST SELECTED TRACK pre-loaded with that sound.
|
|
// No new track is created; no routing changes are made — "new sound, existing track."
|
|
// No assignment_request is written on this path.
|
|
//
|
|
// 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.
|
|
//
|
|
// LOAD-BEARING (CLAUDE.md): this adds ONE FX instance to the user's existing selected track.
|
|
// It NEVER inserts a timeline item and 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 (generation bumped when something new landed) BEFORE
|
|
// loadInstrumentOntoTrack adds the FX, or the instance cannot resolve the sampleId.
|
|
// Undo-wrapped: persist + FX-add + inject = one Ctrl-Z.
|
|
//
|
|
// No selected track: the bank import still proceeds (sound is now in the bank), but no
|
|
// instrument is placed and a clear console message explains why.
|
|
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()) {
|
|
// Import refused (unsaved project / undecodable / write failure). Report and stop —
|
|
// no instrument is placed.
|
|
ShowConsoleMsg(("ReaSampler ingest: Media Explorer import failed -- " + r.message +
|
|
".\n").c_str());
|
|
return;
|
|
}
|
|
|
|
// Resolve the first selected track. GetSelectedTrack(nullptr, 0): proj=nullptr=active
|
|
// project, seltrackidx=0=first selected (ignores master). Returns null when nothing is
|
|
// selected — directive: existing track only, never alter the graph.
|
|
MediaTrack* target = GetSelectedTrack(nullptr, 0);
|
|
if (!target) {
|
|
// Sound landed in the bank; no instrument placed because there is no selected track.
|
|
// The bank import is kept (sound is available in the bank browser) and 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;
|
|
}
|
|
|
|
// Build the pre-loaded instrument payload (a .vstpreset image) for the resolved sampleId.
|
|
// Valid for BOTH the fresh import and the dedup case (added == false but a real sampleId)
|
|
// — the user asked for a player, and a valid sampleId is sufficient to pre-select the sound.
|
|
const std::vector<std::uint8_t> preset = buildInstrumentDropPreset(r.sampleId);
|
|
|
|
// One undo point for the whole gesture. Persist happens INSIDE the block and BEFORE the
|
|
// FX add so the new instance's setState -> reloadInstrument sees the just-persisted sample.
|
|
// The generation is bumped only when something NEW landed (a dedup collapse mutated nothing,
|
|
// so it needs neither a bump nor a persist to resolve — the sample is already in ext-state).
|
|
// If saveToActiveProject() no-ops (unsaved project), close with an empty label + zero flag so
|
|
// REAPER discards the undo entry (the house pattern from actions.cpp). importFileIntoActiveBank
|
|
// already refuses on an unsaved project, so in practice the persist here succeeds.
|
|
Undo_BeginBlock2(nullptr);
|
|
bool persisted = true; // true when nothing needed persisting (dedup) — governs the label path
|
|
if (r.added) {
|
|
// S9: a new sample landed in the active bank -> bump inside the block so the stamped
|
|
// generation is what the fresh instance (and any other live instances) resolve against,
|
|
// and undo rolls the generation back with the banks key.
|
|
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 (loadInstrumentOntoTrack already rolled the FX back —
|
|
// no orphan) or the project was unsaved (persist no-op): discard the undo entry 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
|
|
|
|
// --- 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 into the active bank — bank-fill only. No assignment_request
|
|
// is written on this path; the drop has no effect on what any live instance plays.
|
|
// Batch the persist + undo point: many imports are ONE undo entry.
|
|
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 (a
|
|
// drop that only re-hit existing content mutated nothing on the index).
|
|
// 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 (importedNew > 0) {
|
|
Undo_BeginBlock2(nullptr);
|
|
// S9: one coalesced generation bump for the whole drop (>=1 new sample landed) so
|
|
// open VST3 browser instances refresh to show the newly available sounds.
|
|
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) {
|
|
// All dropped files were already in the bank (deduplicated); nothing changed.
|
|
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());
|
|
}
|
|
}
|
|
|
|
// --- 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 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) {
|
|
// 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
|