#include "core/namespaces.h" // 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 "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 / hashWavContent #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #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 "persist.h" // ReaSamplerSession #include "core/capture/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 buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(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& 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, 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 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()) { // 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 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::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 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