From 835bddadda1f4301c916f54f1061c92e88011879 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 13:39:18 -0400 Subject: [PATCH] feat(ingest): ME import loads a new instrument on a new track Repurpose INGEST_IMPORT_MEDIA_EXPLORER (id unchanged) to stand up a fresh ReaSampler 9000 on a new named track pre-loaded with the imported sound, instead of force-selecting on the active instance. One undo point. --- src/ingest.cpp | 93 ++++++++++++++++++++++++------------- src/instrument_drop_win.cpp | 87 ++++++++++++++++++++++++++-------- src/instrument_drop_win.h | 20 ++++++++ 3 files changed, 149 insertions(+), 51 deletions(-) diff --git a/src/ingest.cpp b/src/ingest.cpp index 78d1955..1b00973 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -22,6 +22,8 @@ #include "bank_model.h" // Sample, AddResult, findByHash #include "bank_panel.h" // bankPanelRefresh #include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent +#include "instrument_drop.h" // pure buildInstrumentDropChunk (vst_chunk blob for a sampleId) +#include "instrument_drop_win.h" // shell createTrackWithInstrument (new track + pre-loaded RS9k) #include "persist.h" // ReaSamplerSession #include "wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path @@ -416,13 +418,21 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { // --- 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. +// Import the Media Explorer's current last-played/selected file into the active bank, then +// stand up a NEW ReaSampler 9000 instrument on a NEW track pre-loaded with JUST that sound +// (S8 surface 2, RS5k "load into a new sampler" parity). This is the ADD-a-new-sound workflow: +// it NEVER touches (nor force-selects on) an existing live instance — 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 creates a track + one FX instance ONLY. It NEVER inserts a +// timeline item. Persist ordering is critical — the fresh instance's setState -> reloadFromBank +// reads the bank from project ext-state, so the sample MUST be persisted (generation bumped when +// something new landed) BEFORE createTrackWithInstrument adds the FX, or the instance cannot +// resolve the sampleId. Undo-wrapped: track-create + persist + FX-add + inject = one Ctrl-Z. 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 @@ -445,39 +455,58 @@ void doImportFromMediaExplorer() { const ImportResult r = importFileIntoActiveBank(path); if (r.sampleId.empty()) { + // Import refused (unsaved project / undecodable / write failure). Report and stop — + // no track is created, so there is no orphan to clean up (createTrackWithInstrument was + // never reached). ShowConsoleMsg(("ReaSampler ingest: Media Explorer import failed -- " + r.message + ".\n").c_str()); return; } - // Persist the bank add AND the assign request inside ONE undo block so Ctrl-Z rolls - // back both keys atomically: undo restores `banks` (removing the new sample) AND - // clears the `assign_request` that named it, so no stale request can survive. - // The block is opened only when the index mutated (a dedup collapse changed nothing). - // If saveToActiveProject() no-ops (unsaved project), we close with an empty label + - // zero flag so REAPER discards the undo entry (the house pattern from actions.cpp). + // The new track's name follows the imported sound (RS5k parity). The source file stem is + // what importFileIntoActiveBank uses for the Sample's displayName, so it names the track + // consistently for both a fresh import and a dedup hit. + const std::string trackName = + std::filesystem::path(path).stem().string(); + + // Build the pre-loaded instrument blob 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::string chunk = buildInstrumentDropChunk(r.sampleId); + + // One undo point for the whole gesture. Persist happens INSIDE the block and BEFORE the + // track/FX so the new instance's setState -> reloadFromBank 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) { - Undo_BeginBlock2(nullptr); - // S9: an ingest import adds a sample to the active bank -> bump inside the block so - // the stamped generation refreshes the assigned instance hands-free (and undo rolls - // the generation back with the banks/assign_request keys). + // 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(); - const bool persisted = g_session->saveToActiveProject(); - // Assign request inside the same block: undo rolls back both keys together. - ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId); - if (persisted) - Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer", - UNDO_STATE_MISCCFG); - else - Undo_EndBlock2(nullptr, "", 0); - } else { - // Dedup collapse: index unchanged, no undo point. Assign request still written - // (the user explicitly re-imported; they want the instance updated). - ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId); + persisted = g_session->saveToActiveProject(); } + MediaTrack* newTrack = createTrackWithInstrument(trackName, chunk); + if (newTrack && persisted) + Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer into new instrument", + UNDO_STATE_MISCCFG); + else + // Either the instrument-side add/inject failed (createTrackWithInstrument already rolled + // its own track 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(); - ShowConsoleMsg(("ReaSampler ingest: " + r.message + " (assigned to the active " - "instance).\n").c_str()); + if (newTrack) + ShowConsoleMsg(("ReaSampler ingest: " + r.message + + " (loaded into a new instrument on a new track).\n").c_str()); + else + ShowConsoleMsg(("ReaSampler ingest: imported to the bank (" + r.message + + ") but could not create the new instrument track.\n").c_str()); } } // namespace @@ -572,7 +601,7 @@ void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* 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 bank + assign"); + g_labelImportStr = channelActionName("import Media Explorer file into new instrument"); g_accelImportMediaExplorer.accel.cmd = g_cmdImportMediaExplorer; g_accelImportMediaExplorer.desc = g_labelImportStr.c_str(); rec->Register("gaccel", (void*)&g_accelImportMediaExplorer); diff --git a/src/instrument_drop_win.cpp b/src/instrument_drop_win.cpp index a0245c1..07a0011 100644 --- a/src/instrument_drop_win.cpp +++ b/src/instrument_drop_win.cpp @@ -7,6 +7,7 @@ #include #include +#include #include "app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing) @@ -19,6 +20,12 @@ #define REAPERAPI_WANT_TrackFX_SetNamedConfigParm #define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_EndBlock2 +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_InsertTrackInProject +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_DeleteTrack +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#define REAPERAPI_WANT_TrackList_AdjustWindows #include "reaper_plugin_functions.h" namespace reasampler { @@ -50,7 +57,7 @@ FxDropTarget resolveFxDropTarget(int screenX, int screenY) { return out; } -bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64) { +bool loadInstrumentOntoTrack(MediaTrack* track, const std::string& chunkBase64) { if (!track || chunkBase64.empty()) return false; // The CHANNEL-correct FX name: "VST3:ReaSampler 9000" on stable, "VST3:ReaSampler 9000 @@ -59,32 +66,74 @@ bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64) { // drops the beta VST, a stable extension the stable VST — with no literal to drift. const std::string fxName = "VST3:" + vstPluginName(); - // One undo point for the whole gesture (mirrors the bank-verb undo discipline). Both the - // FX add and the state write are REAPER-undoable, so Ctrl-Z removes the instance cleanly. - Undo_BeginBlock2(nullptr); - // Negative `instantiate` => always create a NEW instance (verified in the header). 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 = false; - if (fxIndex >= 0) { - // Inject the instrument's OWN component-state blob (the dragged capture pre-selected) - // via the documented vst_chunk write-parm. The blob was built by the shared writer - // (instrument_drop::buildInstrumentDropChunk -> sample_map::serializeComponentState), - // so these bytes are exactly what ReaSampler 9000's setState accepts. - ok = TrackFX_SetNamedConfigParm(track, fxIndex, "vst_chunk", chunkBase64.c_str()); - if (!ok) { - // All-or-nothing: if the chunk write fails, remove the empty FX instance we just - // added so the track is left exactly as it was. TrackFX_Delete signature (verified - // in reaper_plugin_functions.h:7236): bool TrackFX_Delete(MediaTrack*, int fx). - TrackFX_Delete(track, fxIndex); - } - } + if (fxIndex < 0) return false; + // Inject the instrument's OWN component-state blob (the dragged capture pre-selected) + // via the documented vst_chunk write-parm. The blob was built by the shared writer + // (instrument_drop::buildInstrumentDropChunk -> sample_map::serializeComponentState), + // so these bytes are exactly what ReaSampler 9000's setState accepts. + const bool ok = + TrackFX_SetNamedConfigParm(track, fxIndex, "vst_chunk", chunkBase64.c_str()); + if (!ok) { + // All-or-nothing: if the chunk write fails, remove the empty FX instance we just + // added so the track is left exactly as it was. TrackFX_Delete signature (verified + // in reaper_plugin_functions.h:7236): bool TrackFX_Delete(MediaTrack*, int fx). + TrackFX_Delete(track, fxIndex); + } + return ok; +} + +bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64) { + if (!track || chunkBase64.empty()) return false; + + // One undo point for the whole gesture (mirrors the bank-verb undo discipline). Both the + // FX add and the state write are REAPER-undoable, so Ctrl-Z removes the instance cleanly. + Undo_BeginBlock2(nullptr); + const bool ok = loadInstrumentOntoTrack(track, chunkBase64); // The undo label reflects the placement-of-the-player framing (not a capture, not an insert). Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1); return ok; } +MediaTrack* createTrackWithInstrument(const std::string& trackName, + const std::string& chunkBase64) { + if (chunkBase64.empty()) return nullptr; + + // Append a new track at the end of the active project (proj=0). flags=0: no default FX/ + // envelopes — we add exactly the one instrument ourselves. InsertTrackInProject returns + // void, so re-fetch the appended track by its (now-last) zero-based index. The index BEFORE + // insertion equals the new track's index AFTER insertion (append at the tail). + const int newIndex = CountTracks(nullptr); + InsertTrackInProject(nullptr, newIndex, /*flags=*/0); + MediaTrack* track = GetTrack(nullptr, newIndex); + if (!track) return nullptr; // insertion did not yield a fetchable track — nothing to clean up + + // Name the track after the imported sound (RS5k parity). P_NAME writes are propagated to + // the panels by TrackList_AdjustWindows below. GetSetMediaTrackInfo_String takes a non-const + // buffer even when setting, so copy into a mutable vector. + if (!trackName.empty()) { + std::vector nameBuf(trackName.begin(), trackName.end()); + nameBuf.push_back('\0'); + GetSetMediaTrackInfo_String(track, "P_NAME", nameBuf.data(), /*setNewValue=*/true); + } + + // Add + inject the pre-loaded instrument. No inner undo block — the caller owns the grouping + // so track-create + persist + FX collapse to one Ctrl-Z. All-or-nothing on failure. + if (!loadInstrumentOntoTrack(track, chunkBase64)) { + // Roll back the track we just created so the project is left exactly as it was (no + // orphaned empty track). DeleteTrack removes the track and everything on it. + DeleteTrack(track); + TrackList_AdjustWindows(false); + return nullptr; + } + + // Repaint the TCP/MCP so the new named track + its FX appear immediately. + TrackList_AdjustWindows(false); + return track; +} + } // namespace reasampler diff --git a/src/instrument_drop_win.h b/src/instrument_drop_win.h index fba0cce..74dacf1 100644 --- a/src/instrument_drop_win.h +++ b/src/instrument_drop_win.h @@ -54,4 +54,24 @@ FxDropTarget resolveFxDropTarget(int screenX, int screenY); // undoable. bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64); +// Add a fresh ReaSampler 9000 instance to `track` and inject `chunkBase64` as its component +// state. Same all-or-nothing add+inject contract as performInstrumentDrop (rolls the FX back +// via TrackFX_Delete on inject failure), but does NOT open its own undo block — the caller owns +// the undo grouping so the whole gesture (track-create + persist + FX-add + inject) collapses to +// one Ctrl-Z. This is the shared inner half performInstrumentDrop wraps in its own block. +// Returns true on success, false on any failure. NEVER inserts a timeline item. +bool loadInstrumentOntoTrack(MediaTrack* track, const std::string& chunkBase64); + +// Create a NEW track at the end of the active project's track list, name it `trackName`, add a +// fresh ReaSampler 9000 instance pre-loaded with `chunkBase64` (RS5k-parity "load into a new +// sampler"), and return the created track. Does NOT open an undo block — the caller owns the +// undo grouping (track-create + the caller's bank persist collapse to one Ctrl-Z). All-or- +// nothing on the instrument side: if the FX add or chunk inject fails, the freshly-created +// track is deleted before returning nullptr, leaving the project exactly as it was (no orphaned +// empty track). NEVER inserts a timeline item; the ONLY mutations are one track + one FX +// instance + that instance's state, all REAPER-undoable. Returns the new track on success, +// nullptr on any failure. +MediaTrack* createTrackWithInstrument(const std::string& trackName, + const std::string& chunkBase64); + } // namespace reasampler