// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers. // // This file is the entire contract between REAPER and the extension: // * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and // dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry // (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro). // * REAPER calls it, handing over `rec` — a small dispatch struct. // - rec->GetFunc(name) resolves any REAPER API function to a pointer // - rec->Register(what,ptr) plugs OUR callbacks into REAPER // * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and // fills in every global function pointer (ShowConsoleMsg, InsertMedia...). // // Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates // storage for those global pointers. Every other .cpp includes // reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. #define REAPERAPI_IMPLEMENT #include "reaper_plugin.h" #include "reaper_plugin_functions.h" #include #include #include "actions.h" #include "bank_model.h" #include "bank_panel.h" #include "capture.h" #include "insert.h" #include "persist.h" #include "render_settings.h" #include "track_guid.h" #include "view.h" // Persistent action-id prefix for the ReaSampler action family. // Every bindable action (capture / insert / slot / verify) mints its command id // from a string beginning with this prefix, e.g. "CEREBELLUM_REASAMPLER_CAPTURE_MASTER". // FOREVER-STABLE once shipped: user keybindings key off these strings, so the // prefix and any minted id must never change after release. #define REASAMPLER_ACTION_PREFIX "CEREBELLUM_REASAMPLER_" // Globals other files reference via `extern`. REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct // ---- Capture action family (three FX scopes) ------------------------------- // Three bindable SCOPE actions from captureActionTable() (render_settings, pure): // capture item / track / master. Each infers its range (razor-else-time) and // enforces the FX-scope invariant via FX-bypass-around-render (FxBypassGuard): // Item -> take/item FX only (bypass the item's track + ancestors + master). // Track -> item FX + track's own FX (bypass ancestors + master). // Master -> whole chain (bypass nothing). // This REPLACES the retired M7 four-mode family (master / tracks / items / razor). // The retired CAPTURE_TRACKS_WET / CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are // mirror-unregistered on unload so old keybindings clear cleanly; CAPTURE_MASTER's // id string is preserved. // // The minted command ids parallel the table rows 1:1 (same index). gaccel storage // must outlive registration (REAPER holds each pointer), so both vectors are file- // scope and sized to the table. FOREVER-STABLE id strings live in the table. static std::vector g_captureCmdIds; static std::vector g_captureAccels; // Retired capture-action command-id strings (M7 four-mode family). Kept ONLY to // mirror-unregister them on unload so a user's stale keybindings are cleaned up. // Never re-register these. CAPTURE_MASTER is NOT here — its id string carries over // to the new master scope action unchanged. static const char* const kRetiredCaptureCmdStrings[] = { "CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET", "CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET", "CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET", }; // Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string. // The docked grid window is display-only this wave (Wave A) — the action just // shows/hides it; it never captures, inserts, or mutates the bank. static int g_cmdToggleBankPanel = 0; // Command ids for the M6 insert actions. FOREVER-STABLE strings. Two variants that // differ ONLY in the InsertOptions they build: the default inserts at native length // (no stretch, no conform); the "conform" variant is the EXPLICIT opt-in to REAPER's // try-to-match-project-tempo path (CONTEXT.md §insert: conform is opt-in, never // silent). Both read the bank panel's current selection and place at the edit cursor. static int g_cmdInsertSelected = 0; static int g_cmdInsertSelectedConform = 0; // The persistence session (M4): owns the in-memory BankIndex and bridges it to // project ext state. A timer tick drives g_session.poll() to detect project // load / Save-As; capture adds Samples to g_session.bank(); after a capture we // serialize the bank back into the active project's ext state so it travels with // the .rpp. Replaces the M3 session-only g_bank. static reasampler::ReaSamplerSession g_session; // The timer callback REAPER runs periodically (registered via "timer"). It only // forwards to the session poll — cheap per tick (reads the active project id and // its .rpp path, acts only on a change). static void OnTimer() { g_session.poll(); // D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view // model but deliberately does NOT apply visibility — that would couple persist // to the view shell). Instead poll() raises a one-shot load signal; here — the // integration layer that already drives both persist and the view shell — we // drain it and reapply the SAVED active mode's visibility/processing so opening a // project saved in Design mode parks the Arrange tracks automatically, no manual // toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks // skip it. proj = nullptr -> REAPER's active project (the one poll just loaded). if (g_session.consumeLoadSignal()) reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); // Reflect a live bank change (capture / project load) in the docked grid. // Cheap when the bank is unchanged (a fingerprint compare); repaints only on // an actual change. No-op when the panel is closed. reasampler::bankPanelRefresh(); } // --- Scope-action source resolution ----------------------------------------- // The three scope actions (item / track / master) each resolve to (1) an exact // render range in project seconds — razor-else-time, inferred here — and (2) the // set of source TRACKS whose ancestor chains drive the FX-bypass plan. All reads // are non-destructive: selection, razor, and time selection are read, never // mutated. Returning false means "nothing to capture" (empty selection / no // range); the caller reports it and writes nothing. // The resolved source: exact bounds + the source tracks (for FX-bypass + Sample // provenance GUIDs). `sourceTracks` is empty for Master scope. struct ResolvedSource { double startSeconds = 0.0; double endSeconds = 0.0; std::vector sourceTracks; // item's/selected tracks; empty for master std::vector trackGuids; // canonical GUIDs of sourceTracks }; // Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false, // isLoop=false) reads the current time selection. static bool resolveTimeSelection(double& start, double& end) { start = 0.0; end = 0.0; GetSet_LoopTimeRange(false, false, &start, &end, false); return end > start; } // Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of // start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits), // and returns the union bound. Reads only — never clears the razor selection. // Returns false when no track-audio razor area exists on any track. static bool resolveRazorRange(double& start, double& end) { std::vector allRanges; const int n = CountTracks(nullptr); for (int i = 0; i < n; ++i) { MediaTrack* tr = GetTrack(nullptr, i); if (!tr) continue; std::vector buf(8192, '\0'); if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) continue; std::vector ranges = reasampler::parseRazorEdits(std::string(buf.data())); for (auto& r : ranges) allRanges.push_back(r); } if (allRanges.empty()) return false; reasampler::RazorRange u = reasampler::razorUnionBounds(allRanges); start = u.startSeconds; end = u.endSeconds; return end > start; } // Infers the render RANGE for any scope: razor union when a razor area is present, // else the time selection (pure inferRangeSource decides which). Orthogonal to // scope. Returns false (with a reason) when neither yields a non-empty range. static bool resolveRange(double& start, double& end, std::string& why) { double rzStart = 0.0, rzEnd = 0.0; const bool hasRazor = resolveRazorRange(rzStart, rzEnd); if (reasampler::inferRangeSource(hasRazor) == reasampler::RangeSource::Razor) { start = rzStart; end = rzEnd; return true; // resolveRazorRange already verified end > start } if (resolveTimeSelection(start, end)) return true; why = "make a razor area or a time selection first"; return false; } // Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. static bool collectSelectedTracks(ResolvedSource& out) { const int n = CountSelectedTracks(nullptr); // nullptr = active project if (n <= 0) return false; for (int i = 0; i < n; ++i) { MediaTrack* tr = GetSelectedTrack(nullptr, i); if (!tr) continue; out.sourceTracks.push_back(tr); std::string g = reasampler::guidString(tr); if (!g.empty()) out.trackGuids.push_back(std::move(g)); } return !out.sourceTracks.empty(); } // Collects the tracks that own the selected items (Item scope) into // out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an // item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning // track (SDK header, verify). GUIDs recorded for provenance. static bool collectSelectedItemTracks(ResolvedSource& out) { const int n = CountSelectedMediaItems(nullptr); if (n <= 0) return false; for (int i = 0; i < n; ++i) { MediaItem* it = GetSelectedMediaItem(nullptr, i); if (!it) continue; MediaTrack* tr = GetMediaItem_Track(it); if (!tr) continue; // Dedup: several selected items can share a track. bool seen = false; for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; } if (seen) continue; out.sourceTracks.push_back(tr); std::string g = reasampler::guidString(tr); if (!g.empty()) out.trackGuids.push_back(std::move(g)); } return !out.sourceTracks.empty(); } // Resolves the source for a scope: the selection tracks (item/track) or none // (master), plus the inferred range. Returns false with a reason on nothing to do. static bool ResolveScopeSource(reasampler::CaptureScope scope, ResolvedSource& out, std::string& why) { using reasampler::CaptureScope; switch (scope) { case CaptureScope::Item: if (!collectSelectedItemTracks(out)) { why = "select at least one media item"; return false; } break; case CaptureScope::Track: if (!collectSelectedTracks(out)) { why = "select at least one track"; return false; } break; case CaptureScope::Master: break; // whole chain — no source-track collection } return resolveRange(out.startSeconds, out.endSeconds, why); } // --- FX-bypass-around-render (RAII, non-destructive) ------------------------ // Snapshots and clears I_FXEN on the tracks a scope must NOT hear the FX of, then // restores every snapshotted value on EVERY exit path (including the render's). // I_FXEN bypasses a track's FX plugins only — NOT its volume/pan/routing (so a // Track capture rendered via master still carries parent/master GAIN; documented // boundary, DAW-confirm). Structurally non-destructive: no takes, no items, no // project restructuring — only a transient FX-enable toggle, always restored. class FxBypassGuard { public: // scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose // ancestor chains (walked via GetParentTrack) + the master are bypassed per the // plan. proj is the active project (for GetMasterTrack). FxBypassGuard(reasampler::CaptureScope scope, const std::vector& sourceTracks, ReaProject* proj) { const reasampler::FxBypassPlan plan = reasampler::fxBypassPlanFor(scope); for (MediaTrack* tr : sourceTracks) { if (!tr) continue; if (plan.bypassSelfFx) bypass(tr); if (plan.bypassAncestorFx) { // Walk parents to the top: GetParentTrack returns the immediate // parent (folder) track, nullptr at the outermost level (SDK // header ~2407). The master is NOT returned here — handled below. for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p)) bypass(p); } } if (plan.bypassMaster) { // GetMasterTrack(proj) -> the master track (SDK header ~1925); I_FXEN // on it bypasses the master FX chain, leaving master gain/routing live. if (MediaTrack* master = GetMasterTrack(proj)) bypass(master); } } ~FxBypassGuard() { // Restore in reverse for symmetry (order is not load-bearing — each track // appears once, snapshots are independent). for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it) SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen); } FxBypassGuard(const FxBypassGuard&) = delete; FxBypassGuard& operator=(const FxBypassGuard&) = delete; private: struct Snap { MediaTrack* track; double fxen; }; std::vector snapshots_; // Snapshot I_FXEN once per track (dedup: an ancestor shared by two selected // tracks must be restored to its ORIGINAL value, not a re-snapshot of the // already-bypassed 0), then clear it. void bypass(MediaTrack* tr) { for (const Snap& s : snapshots_) if (s.track == tr) return; // already done const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN"); snapshots_.push_back({tr, fxen}); SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194) } }; // Runs one capture-action-table row: resolve its scope source + range, snapshot & // clear the out-of-scope FX (RAII), render via the offline backend, add the Sample // to the bank, persist + mark dirty. The load-bearing principle holds structurally // — this path writes a file + a bank index entry ONLY; it never calls InsertMedia // or touches the arrange/timeline. Non-destructive: FX-enable is fully restored on // every path by FxBypassGuard, and the backend restores every RENDER_* setting. static void RunCapture(const reasampler::CaptureActionDef& def) { ResolvedSource src; std::string why; if (!ResolveScopeSource(def.scope, src, why)) { ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str()); return; } reasampler::CaptureRequest req; req.sourceMode = reasampler::sourceModeForScope(def.scope); req.startSeconds = src.startSeconds; // exact bounds — no rounding req.endSeconds = src.endSeconds; req.wetDry = 1.0; // wet post the FX left enabled by the scope req.renderTail = false; // exact bounds, no tail (default) req.tailMs = 0.0; req.sampleRate = 0; // follow project rate req.channelCount = 2; req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither req.baseName = def.baseName; req.trackGuids = src.trackGuids; // recorded on the Sample (provenance) // Bypass the out-of-scope FX for the duration of the render. Restored on EVERY // exit path below (RAII), including backend failures. proj = active project. ReaProject* proj = EnumProjects(-1, nullptr, 0); FxBypassGuard fxGuard(def.scope, src.sourceTracks, proj); reasampler::OfflineRenderBackend backend; reasampler::CaptureResult res = backend.capture(req); if (res.status != reasampler::CaptureStatus::Ok) { ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str()); return; } reasampler::AddResult added = g_session.bank().add(res.sample); // Persist the updated bank into the active project's ext state so the capture // survives Save / close+reopen (M4) and travels with the .rpp. saveToActiveProject // also calls MarkProjectDirty. Non-destructive: writes only our own ext-state key. g_session.saveToActiveProject(); std::string log = "ReaSampler: " + res.message + "\n"; log += " bank size now " + std::to_string(g_session.bank().size()) + (added == reasampler::AddResult::Added ? " (added)\n" : added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n" : " (rejected)\n"); ShowConsoleMsg(log.c_str()); } // Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor // via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match // variant (never silent — it fires only from the distinct "conform" action). This // is the INTENDED placement path: it adds items to the arrange on purpose // (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action. static void RunInsertSelected(bool conform) { reasampler::InsertRequest req; // target defaults to CurrentTrack (InsertOptions::target) — inserts onto the // user's currently-selected track(s) at the edit cursor. req.options.conform = conform ? reasampler::TempoConform::Ratio1x : reasampler::TempoConform::None; // preservePitch stays true: a tempo conform matches tempo without varispeeding // pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.) reasampler::InsertResult res = reasampler::runInsert(&g_session, req); std::string msg; switch (res.status) { case reasampler::InsertStatus::Ok: msg = "ReaSampler: inserted onto " + std::to_string(res.inserted) + (res.inserted == 1 ? " track" : " tracks") + (conform ? " (conformed to tempo)" : " (native length)") + "\n"; break; case reasampler::InsertStatus::NoSelection: // "select a track first" is printed by runInsert when no track is // selected; this branch covers the no-panel-selection case. msg = "ReaSampler insert: nothing selected in the bank panel.\n"; break; case reasampler::InsertStatus::NoProject: msg = "ReaSampler insert: no saved project, so the bank has no location.\n"; break; case reasampler::InsertStatus::NothingResolved: msg = "ReaSampler insert: selected sample(s) could not be resolved to a file.\n"; break; } ShowConsoleMsg(msg.c_str()); } // REAPER calls this for EVERY action fired anywhere; claim only our own id, // return false otherwise so REAPER keeps looking. static bool OnHookCommand(int command, int /*flag*/) { if (command == 0) return false; // Three-scope capture family: command ids parallel captureActionTable() 1:1 by index. // Claim the fired id if it is one of ours and route to its table row. for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i) if (command == g_captureCmdIds[i]) { RunCapture(reasampler::captureActionTable()[i]); return true; } if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; } if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; } if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } // Design View action family (D4). Claims only its own ids; returns false for the // rest so this hook keeps looking (per the contract). if (reasampler::designViewHandleCommand(command)) return true; return false; } // REAPER polls this to render each of OUR actions' checked state in menus/toolbars. // Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract). static int OnToggleAction(int command) { if (command == g_cmdToggleBankPanel) return reasampler::bankPanelIsOpen() ? 1 : 0; return -1; // not ours / non-toggling } // gaccel storage must outlive registration — REAPER holds the pointer. // (The capture family's accels live in g_captureAccels, sized to the table.) static gaccel_register_t g_accelToggleBankPanel{}; static gaccel_register_t g_accelInsertSelected{}; static gaccel_register_t g_accelInsertSelectedConform{}; extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec) { if (!rec) { // rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every // callback with the same strings prefixed '-' (per the contract). if (g_rec) { g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-toggleaction", (void*)&OnToggleAction); g_rec->Register("-hookcommand", (void*)&OnHookCommand); // Tear down the Design View action family (D4) — mirror-unregisters each // gaccel + command_id with '-'-prefixed strings. After the hook is gone. reasampler::designViewUnregisterActions(g_rec); g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform); g_rec->Register("-command_id", (void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM")); g_rec->Register("-gaccel", (void*)&g_accelInsertSelected); g_rec->Register("-command_id", (void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED")); g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel); g_rec->Register("-command_id", (void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL")); // Mirror-unregister the capture family: gaccel + command_id per row, // with '-'-prefixed strings (per the contract). The FOREVER-STABLE id // strings come from the same table used to register them. { const auto& table = reasampler::captureActionTable(); for (std::size_t i = 0; i < table.size(); ++i) { if (i < g_captureAccels.size()) g_rec->Register("-gaccel", (void*)&g_captureAccels[i]); g_rec->Register("-command_id", (void*)table[i].commandString); } } // Retire the removed M7 command ids (command_id only — we never held a // gaccel for them this session). Clears stale user keybindings on unload. for (const char* id : kRetiredCaptureCmdStrings) g_rec->Register("-command_id", (void*)id); } // Destroy the docked window and release cached thumbnails before we drop // the API pointers (DockWindowRemove/DestroyWindow need them live). reasampler::bankPanelShutdown(); g_rec = nullptr; return 0; } // ABI guard: the struct layout we compiled against must match this REAPER. if (rec->caller_version != REAPER_PLUGIN_VERSION) return 0; // Resolve every REAPER API function pointer. Returns the number that FAILED // to load; 0 == success. Non-zero usually means REAPER is older than our SDK. if (REAPERAPI_LoadAPI(rec->GetFunc) != 0) return 0; g_hInst = hInstance; g_rec = rec; // Register the three-scope capture action family (command_id -> gaccel per table row). // The single hookcommand below routes every fired id back to its row by index. // g_captureAccels must be sized BEFORE the loop and never reallocated after — // REAPER holds a pointer to each element until we mirror-unregister it. { const auto& table = reasampler::captureActionTable(); g_captureCmdIds.assign(table.size(), 0); g_captureAccels.assign(table.size(), gaccel_register_t{}); for (std::size_t i = 0; i < table.size(); ++i) { const int cmd = rec->Register("command_id", (void*)table[i].commandString); g_captureCmdIds[i] = cmd; if (cmd) { g_captureAccels[i].accel.cmd = cmd; g_captureAccels[i].desc = table[i].description; rec->Register("gaccel", (void*)&g_captureAccels[i]); } } } // Point the bank panel at the live session BEFORE registering its action, so // a toggle firing immediately has a session to read (M5). Does not open the // window — only stores the session pointer. reasampler::bankPanelInit(&g_session); // Register the M5 "toggle bank panel" action (command_id -> gaccel -> // hookcommand + toggleaction for the checked state). g_cmdToggleBankPanel = rec->Register( "command_id", (void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL")); if (g_cmdToggleBankPanel) { g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel; g_accelToggleBankPanel.desc = "ReaSampler: toggle bank panel"; rec->Register("gaccel", (void*)&g_accelToggleBankPanel); rec->Register("toggleaction", (void*)&OnToggleAction); } // Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two // variants: native-length (default, no stretch) and the EXPLICIT conform-to- // tempo opt-in. Both read the bank panel selection and place at the edit cursor. g_cmdInsertSelected = rec->Register( "command_id", (void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED")); if (g_cmdInsertSelected) { g_accelInsertSelected.accel.cmd = g_cmdInsertSelected; g_accelInsertSelected.desc = "ReaSampler: insert selected sample at edit cursor"; rec->Register("gaccel", (void*)&g_accelInsertSelected); } g_cmdInsertSelectedConform = rec->Register( "command_id", (void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM")); if (g_cmdInsertSelectedConform) { g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform; g_accelInsertSelectedConform.desc = "ReaSampler: insert selected sample at edit cursor (conform to tempo)"; rec->Register("gaccel", (void*)&g_accelInsertSelectedConform); } // Register the Design View action family (D4): toggle/activate mode, tag/untag/ // show-both selected tracks. Each mints its own command_id + gaccel; the single // hookcommand below routes them via designViewHandleCommand. Registered before // the hook so every id is minted first. reasampler::designViewRegisterActions(rec, &g_session); // One hookcommand routes every ReaSampler action (spike + toggle + Design View). // Registered once, after all command ids are minted. rec->Register("hookcommand", (void*)&OnHookCommand); // Drive project-load / Save-As detection (M4 persist). The timer polls the // active project each tick; on a project load it reloads the bank from ext // state, on a Save-As it relocates the bank folder under the new .rpp. rec->Register("timer", (void*)&OnTimer); ShowConsoleMsg("ReaSampler loaded.\n"); return 1; // success — REAPER keeps us loaded }