diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 110e237..8e3b880 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -45,7 +45,8 @@ subdirectories: - **File seam (audio, permanent).** The sample **audio** is the on-disk 32-bit-float WAVs — project-relative, travelling with the `.rpp`. The instrument resolves those - paths the same way `persist` does (a shared convention, not a re-implementation). + paths the same way `persist` does (a shared convention, not a re-implementation), + against the project the instance lives in (`map/sample_roots`). There is no live PCM stream across the bridge, by design. - **Live-state seam (the mapping, via the bridge).** For everything that is not raw audio — the bank index, the mapping, which project is active — the instrument reads @@ -367,6 +368,7 @@ anything for a trigger shape. - `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home. - `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects. - `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer. +- `sample_roots` — where a bank-relative sample path resolves: `ownProjectDir` picks the instance's own project out of the shell's enumeration BY IDENTITY (no fallback to any other project), `sampleRootCandidates` orders the roots to try, and `readFromFirstRoot` reads the first that yields bytes. The shell half and its rationale are `shell/instrument`'s. - `trigger_seam` — the shared Trigger play-SPAN formula: how a %-length becomes the source-frame span the overlay draws over and the bake's window holds, threading `startFrame` correctly and clamping the fraction the same way `Voice::start` does (the engine evaluates the same formula inline rather than depending on `map/`). The spline fold every consumer must apply first — `effectiveLengthFraction` — is `play_params.h`'s, beside the rest of that rule family. (Its fade frames↔fraction converters retired with the fade pair itself.) ### `ui/` diff --git a/src/core/instrument/map/CMakeLists.txt b/src/core/instrument/map/CMakeLists.txt index ad5c39a..2d3b10c 100644 --- a/src/core/instrument/map/CMakeLists.txt +++ b/src/core/instrument/map/CMakeLists.txt @@ -1,6 +1,9 @@ reasampler_pure_library(bridge_marshal SOURCES bridge_marshal.cpp) reasampler_test(bridge_marshal LINK bridge_marshal) +reasampler_pure_library(sample_roots SOURCES sample_roots.cpp LINK PRIVATE capture_paths) +reasampler_test(sample_roots LINK sample_roots) + reasampler_pure_library(trigger_seam SOURCES trigger_seam.cpp) # Plain frame arithmetic over doubles: no engine, no value layer, no editor geometry. The # %-length fold it used to host lives with its siblings in play_params.h. diff --git a/src/core/instrument/map/sample_roots.cpp b/src/core/instrument/map/sample_roots.cpp new file mode 100644 index 0000000..4ab5649 --- /dev/null +++ b/src/core/instrument/map/sample_roots.cpp @@ -0,0 +1,34 @@ +// sample_roots.cpp — see sample_roots.h. + +#include "core/instrument/map/sample_roots.h" + +#include "core/capture/capture_paths.h" // projectDirOfRpp / resolveBankFile (persist's convention) + +namespace reasampler::instrument::map { + +std::string ownProjectDir(const void* own, const std::vector& open) { + if (!own) return {}; + for (const ProjectTab& tab : open) { + if (tab.project == own) return capture::projectDirOfRpp(tab.rppPath); + } + return {}; +} + +std::vector sampleRootCandidates(const std::string& projectDir) { + if (projectDir.empty()) return {}; + return {projectDir}; +} + +std::vector readFromFirstRoot(const std::vector& roots, + const std::string& relativePath, + const FileReader& read) { + for (const std::string& root : roots) { + const std::string abs = capture::resolveBankFile(root, relativePath); + if (abs.empty()) continue; + std::vector bytes = read(abs); + if (!bytes.empty()) return bytes; + } + return {}; +} + +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/sample_roots.h b/src/core/instrument/map/sample_roots.h new file mode 100644 index 0000000..a419087 --- /dev/null +++ b/src/core/instrument/map/sample_roots.h @@ -0,0 +1,41 @@ +// sample_roots — where the instrument looks for a bank-relative sample: the directory of +// the project it LIVES IN, found by identity among the open projects, never the focused +// tab's. Pure: the shell enumerates REAPER's projects and reads files; this decides which +// project, which roots, and in what order. + +#pragma once + +#include +#include +#include +#include + +namespace reasampler::instrument::map { + +// One enumerated project: its identity (an opaque ReaProject*, compared, never +// dereferenced) and its .rpp path, empty for a never-saved project. +struct ProjectTab { + const void* project = nullptr; + std::string rppPath; +}; + +// The directory of the project whose identity is `own`; empty when `own` is null, is not +// among `open`, or was never saved. Deliberately no fallback to any other project: another +// project's bank folder can hold a same-named file, and playing it is the wrong-project +// bug this exists to close. +std::string ownProjectDir(const void* own, const std::vector& open); + +// The roots a bank-relative path is tried against, in order. Only the project directory +// today — the ordered list is the seam a later root (tried after it) extends. Empty when +// there is no project directory, which every caller treats as nothing to play. +std::vector sampleRootCandidates(const std::string& projectDir); + +using FileReader = std::function(const std::string& path)>; + +// The bytes of `relativePath` resolved under the first root whose read yields any (a read +// that comes back empty falls through to the next root); empty when none does. +std::vector readFromFirstRoot(const std::vector& roots, + const std::string& relativePath, + const FileReader& read); + +} // namespace reasampler::instrument::map diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index baae132..1f03b94 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -226,7 +226,7 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT ## Modules -- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. +- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. And it owns `sampleRoots`, the ONE answer to where a bank-relative sample path resolves — see the Gotcha below. - `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. A host-rate change while inactive does NOT re-decode: every stage time in the park (or in a publish that landed while inactive) and in the live block is in that rate's frames, so the resume re-stamps those in place (`restampHostRateLocked`) and falls back to the full reload only when the new rate refuses the file's (`sourceRateSupported`). Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below. - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). @@ -313,6 +313,19 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT meter's fixed right-anchored slot, at any resizable window size — so the fast path stays live under it; `paintMeterField` reapplies the popup's own 0.50-alpha wash to the field alone so a meter-only frame doesn't flash through it at full brightness. +- **A sample resolves against the instance's OWN project, never the focused tab.** + `ReaperBridge::sampleRoots` matches `getReaperParent(3)` by identity among the projects + `EnumProjects(idx)` walks (plus the SDK's currently-rendering slot, `0x40000000`) and hands + the match's `.rpp` directory to the pure `core/instrument/map/sample_roots`, which returns an + ordered root list; every instrument-side sample read (`reloadInstrument`, `bakeSnapshot`, + the editor's `channelPcmFor`/`monoPcmFor`) goes through `readFromFirstRoot` over it. The old + `EnumProjects(-1)` lookup resolved a background tab's instance against the FOCUSED project's + folder — do not reintroduce it, not even as a fallback. A null parent, an unlisted project + or an unsaved one yields no roots, which plays nothing, exactly as an unsaved project always + did. The ordered list is one element today; a later root is added after the project + directory, in `sampleRootCandidates`. `[verify — DAW]`: that `getReaperParent(3)` is non-null + and enumerable during `setState` on project load, and that a background-tab instance and + two open projects each with an instance play their own project's sample. - The bake's availability probe runs on the SAME tick that paints the button, so the control can never be enabled on one tick and refuse on the next. The bake Hold control's applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index 7d4eaf8..1ef7ab5 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -85,7 +85,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # generated version header, so the instrument reads the SAME channel-derived ext-state # namespace the extension writes. target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sampler_core sample_map component_state_io capture_paths embed_strip app_version + sampler_core sample_map sample_roots component_state_io capture_paths embed_strip app_version capture_browser keyboard_strip sample_bands sample_chrome waveform_view loop_marks bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 1a0c806..698b6bc 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -13,8 +13,8 @@ #include #include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail) -#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution) #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames +#include "core/instrument/map/sample_roots.h" // readFromFirstRoot (own-project resolution) #include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key) #include "core/util/file_bytes.h" // shared whole-file loader #include "ext_keys.h" @@ -33,7 +33,6 @@ using audio::computeEnvelope; using capture::WavLayout; using capture::extractFloatFrames; using capture::parseWavLayout; -using capture::resolveBankFile; using instrument::ui::nameMatchesQuery; using ui::ThumbnailKey; using ui::thumbnailKeyString; @@ -425,9 +424,8 @@ const ReaSamplerEditor::ChannelPcm& ReaSamplerEditor::channelPcmFor( const std::string relativePath = samplePathFor(sampleId); if (relativePath.empty()) return channelPcm_; - const std::string projectDir = processor_->bridge().activeProjectDir(); - const std::vector bytes = - readFileBytes(resolveBankFile(projectDir, relativePath)); // empty on any failure + const std::vector bytes = readFromFirstRoot( + processor_->bridge().sampleRoots(), relativePath, readFileBytes); // empty on any failure const WavLayout layout = parseWavLayout(bytes); if (layout.valid) { channelPcm_.interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount()); @@ -446,9 +444,8 @@ const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& MonoPcm mono; const std::string relativePath = samplePathFor(sampleId); if (!relativePath.empty()) { - const std::string projectDir = processor_->bridge().activeProjectDir(); - const std::string abs = resolveBankFile(projectDir, relativePath); - const std::vector bytes = readFileBytes(abs); // empty on any failure + const std::vector bytes = readFromFirstRoot( + processor_->bridge().sampleRoots(), relativePath, readFileBytes); // empty on any failure const WavLayout layout = parseWavLayout(bytes); if (layout.valid) { std::vector interleaved = diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index 619f000..2e9e028 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -17,10 +17,10 @@ #include #include -#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution) #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse) #include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision #include "core/instrument/map/sample_map.h" // refs resolve, buildSampleData (self-contained) +#include "core/instrument/map/sample_roots.h" // readFromFirstRoot (own-project resolution) #include "core/util/file_bytes.h" // shared whole-file loader #include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse) #include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam) @@ -32,7 +32,6 @@ using namespace instrument::map; // resolution + bank-sync vocabulary this TU using namespace reasampler::wire; // assignment_request + sample_usage wire records using capture::extractFloatFrames; using capture::parseWavLayout; -using capture::resolveBankFile; using capture::WavLayout; using util::readFileBytes; @@ -58,16 +57,15 @@ std::string mintUsageInstanceGuid() { return std::string(buf); } -// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only), -// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono -// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any -// resolve/read/decode failure — the caller plays silence. -std::optional decodeRelative(const std::string& projectDir, +// Resolves a project-relative WAV path against the instance's sample roots, reads + decodes +// it (file I/O, off-thread only), and applies the cross-mode channel policy for `mode` (mono +// downmix; stereo -> dual-mono for a mono source, L/R for a stereo source — see +// decodeChannels). Returns nullopt on any resolve/read/decode failure — the caller plays +// silence. +std::optional decodeRelative(const std::vector& roots, const std::string& relativePath, ChannelMode mode) { - const std::string abs = resolveBankFile(projectDir, relativePath); - if (abs.empty()) return std::nullopt; - const std::vector bytes = readFileBytes(abs); + const std::vector bytes = readFromFirstRoot(roots, relativePath, readFileBytes); const WavLayout layout = parseWavLayout(bytes); if (!layout.valid) return std::nullopt; std::vector interleaved = @@ -82,9 +80,9 @@ std::optional decodeRelative(const std::string& projectDir, // never be built from a different fold than the one the user is hearing. BuiltCapture buildFromRef(const SelectedSample& sel, const InstrumentParams& params, - const std::string& projectDir, + const std::vector& roots, ChannelMode mode, int renderRate) { - std::optional pcm = decodeRelative(projectDir, sel.relativePath, mode); + std::optional pcm = decodeRelative(roots, sel.relativePath, mode); if (!pcm) return {}; if (!sourceRateSupported(pcm->sampleRate, renderRate)) return {std::nullopt, true}; SampleData sample = @@ -125,7 +123,7 @@ std::string ReaSamplerProcessor::reloadInstrument() { // (getState filters via retainRefs to what the instance plays). refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) } - const std::string projectDir = bridge_.activeProjectDir(); + const std::vector roots = bridge_.sampleRoots(); // Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the // capture's own channel count below, before the decode. ChannelMode mode = channelMode(); @@ -152,7 +150,7 @@ std::string ReaSamplerProcessor::reloadInstrument() { channelModeExplicit_); mode = channelMode_; } - BuiltCapture decoded = buildFromRef(*sel, params, projectDir, mode, hostRenderRate()); + BuiltCapture decoded = buildFromRef(*sel, params, roots, mode, hostRenderRate()); rateRefused = decoded.rateRefused; if (decoded.sample) { sample = std::move(*decoded.sample); @@ -220,7 +218,7 @@ BuiltCapture ReaSamplerProcessor::bakeSnapshot(int renderRate) { if (!sel) return {}; // No live block is attached: the bake renders the stored parameter set, which is what // every knob has already written, rather than whatever the audio thread is observing. - return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode(), renderRate); + return buildFromRef(*sel, params, bridge_.sampleRoots(), channelMode(), renderRate); } void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry, diff --git a/src/shell/instrument/reaper_bridge.cpp b/src/shell/instrument/reaper_bridge.cpp index feeb352..10464f4 100644 --- a/src/shell/instrument/reaper_bridge.cpp +++ b/src/shell/instrument/reaper_bridge.cpp @@ -8,7 +8,7 @@ #include "core/instrument/map/bridge_marshal.h" #include "core/wire/ext_state_read.h" // readProjExtStateGrowing (grow-loop policy) -#include "core/capture/capture_paths.h" // projectDirOfRpp (shared project-dir derivation) +#include "core/instrument/map/sample_roots.h" // ownProjectDir / sampleRootCandidates #include "ext_keys.h" // kProjExtNamespace (shared wire contract) // VST3 base types must be included before REAPER's VST3 interface header, which uses @@ -34,8 +34,10 @@ DEF_CLASS_IID(Steinberg::IReaperHostApplication) namespace reasampler::vst { -using capture::projectDirOfRpp; using instrument::map::decodeGetProjExtState; +using instrument::map::ownProjectDir; +using instrument::map::ProjectTab; +using instrument::map::sampleRootCandidates; bool ReaperBridge::connect(Steinberg::FUnknown* context) { getProjExtState_ = nullptr; @@ -63,8 +65,7 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { reaper->getReaperApi("GetProjExtState")); enumProjExtState_ = reinterpret_cast( reaper->getReaperApi("EnumProjExtState")); - // EnumProjects(-1, ...) yields the active project + its .rpp path — same convention - // the persist shell uses, so the instrument derives the project directory identically. + // Walked by sampleRoots to find this instance's own project among the open ones. enumProjects_ = reinterpret_cast( reaper->getReaperApi("EnumProjects")); // The (prefix-guarded) usage publish write + the track-identity pair it stamps. All @@ -199,13 +200,27 @@ std::string ReaperBridge::currentTrackGuid() { return std::string(buf); } -std::string ReaperBridge::activeProjectDir() { - if (!enumProjects_) return {}; - // idx=-1 is the current project tab; the out-buffer is empty for a never-saved - // project. projectDirOfRpp keeps that empty (no default-location fallback). +std::vector ReaperBridge::sampleRoots() { + if (!enumProjects_ || !hostApp_) return {}; + auto* reaper = static_cast(hostApp_); + const void* own = reaper->getReaperParent(3); + if (!own) return {}; + + std::vector open; std::vector buf(4096, '\0'); - enumProjects_(-1, buf.data(), static_cast(buf.size())); - return projectDirOfRpp(std::string(buf.data())); + const auto enumerate = [&](int idx) -> bool { + buf[0] = '\0'; // so one project's path can never read as the next one's + void* proj = enumProjects_(idx, buf.data(), static_cast(buf.size())); + if (proj) open.push_back(ProjectTab{proj, std::string(buf.data())}); + return proj != nullptr; + }; + for (int idx = 0; enumerate(idx); ++idx) {} + // The SDK's "currently rendering project" slot, appended after the tabs in case a + // render runs a project the tab walk does not list ([verify — DAW] whether one can). + // A duplicate of a listed tab is harmless: the first identity match wins. + constexpr int kRenderingProject = 0x40000000; + enumerate(kRenderingProject); + return sampleRootCandidates(ownProjectDir(own, open)); } } // namespace reasampler::vst diff --git a/src/shell/instrument/reaper_bridge.h b/src/shell/instrument/reaper_bridge.h index 52613c8..e4305fb 100644 --- a/src/shell/instrument/reaper_bridge.h +++ b/src/shell/instrument/reaper_bridge.h @@ -11,6 +11,7 @@ #include #include +#include #include "pluginterfaces/base/funknown.h" @@ -39,10 +40,11 @@ public: // the process path. std::optional readReasamplerExtState(const std::string& key); - // The active project's directory (forward-slashed, no trailing slash) — the same - // convention persist uses to place the bank alongside the .rpp. Empty for an unsaved - // project or when unconnected. Not RT-safe. - std::string activeProjectDir(); + // The ordered roots this instance's bank-relative sample paths resolve against — its OWN + // project's directory (getReaperParent(3) matched among EnumProjects' open projects), + // never the focused tab's; see core/instrument/map/sample_roots. Empty when unconnected, + // when the own project is not found, or when it was never saved. Not RT-safe. + std::vector sampleRoots(); // The instrument's TWO sanctioned ext-state write surfaces, each accepting exactly one // key prefix and refusing every other key. That structural refusal is what keeps the @@ -107,8 +109,9 @@ private: using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx, char* keyOut, int keyOut_sz, char* valOut, int valOut_sz); - // EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path. idx=-1 (current - // tab) follows the active project, same convention as the persist shell. + // EnumProjects(idx, projfnOut, sz) -> the idx'th open project + its .rpp path, null past + // the last. Walked by index only: idx=-1 (the focused tab) is the wrong-project lookup + // sampleRoots replaced. using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz); // Used ONLY by the two prefix-guarded writers — see the read-only-bank note there. using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key, diff --git a/tests/test_sample_roots.cpp b/tests/test_sample_roots.cpp new file mode 100644 index 0000000..581ef89 --- /dev/null +++ b/tests/test_sample_roots.cpp @@ -0,0 +1,156 @@ +// Standalone tests for reasampler::instrument::map::sample_roots — no VST3, no REAPER, no +// test framework. The project identities are addresses of local objects standing in for +// ReaProject*; paths are lowercase forward-slash so normalizeSlashes is the identity on +// every platform. + +#include "../src/core/instrument/map/sample_roots.h" + +#include +#include +#include +#include +#include + +using namespace reasampler::instrument::map; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +namespace { + +int projA = 0, projB = 0, projC = 0, stranger = 0; + +// Tab 0 (projA) plays the focused tab — the one the old EnumProjects(-1) lookup answered. +std::vector threeTabs() { + return { + {&projA, "c:/projects/a/song_a.rpp"}, + {&projB, "c:/projects/b/song_b.rpp"}, + {&projC, ""}, // never saved + }; +} + +std::vector bytesOf(const std::string& s) { + return std::vector(s.begin(), s.end()); +} + +} // namespace + +// --- ownProjectDir ------------------------------------------------------------ + +static void testBackgroundInstanceResolvesItsOwnProjectNotTheFirstTab() { + CHECK(ownProjectDir(&projB, threeTabs()) == "c:/projects/b"); +} + +static void testEachOfTwoProjectsResolvesItsOwnDirectory() { + const auto tabs = threeTabs(); + CHECK(ownProjectDir(&projA, tabs) == "c:/projects/a"); + CHECK(ownProjectDir(&projB, tabs) == "c:/projects/b"); +} + +static void testNullIdentityResolvesNothing() { + CHECK(ownProjectDir(nullptr, threeTabs()).empty()); +} + +static void testUnlistedProjectResolvesNothingRatherThanAnotherTab() { + CHECK(ownProjectDir(&stranger, threeTabs()).empty()); +} + +static void testUnsavedOwnProjectResolvesNothingEvenBesideSavedTabs() { + CHECK(ownProjectDir(&projC, threeTabs()).empty()); +} + +static void testNoOpenProjectsResolvesNothing() { + CHECK(ownProjectDir(&projA, {}).empty()); +} + +// --- sampleRootCandidates ----------------------------------------------------- + +static void testProjectDirIsTheOnlyCandidate() { + const auto roots = sampleRootCandidates("c:/projects/b"); + CHECK(roots.size() == 1); + CHECK(!roots.empty() && roots[0] == "c:/projects/b"); +} + +static void testNoProjectDirMeansNoCandidates() { + CHECK(sampleRootCandidates("").empty()); +} + +// --- readFromFirstRoot -------------------------------------------------------- + +static void testReadsUnderTheFirstRootThatHasTheFile() { + const std::map disk = { + {"c:/second/reasampler_bank/x.wav", "SECOND"}, + }; + std::vector asked; + const auto bytes = readFromFirstRoot( + {"c:/first", "c:/second"}, "reasampler_bank/x.wav", [&](const std::string& p) { + asked.push_back(p); + const auto it = disk.find(p); + return it == disk.end() ? std::vector{} : bytesOf(it->second); + }); + CHECK(bytes == bytesOf("SECOND")); + CHECK(asked.size() == 2); + CHECK(asked.size() == 2 && asked[0] == "c:/first/reasampler_bank/x.wav"); +} + +static void testEarlierRootWinsWhenBothHaveTheFile() { + std::vector asked; + const auto bytes = readFromFirstRoot( + {"c:/first", "c:/second"}, "reasampler_bank/x.wav", [&](const std::string& p) { + asked.push_back(p); + return bytesOf(p); + }); + CHECK(bytes == bytesOf("c:/first/reasampler_bank/x.wav")); + CHECK(asked.size() == 1); +} + +static void testNoRootsReadsNothing() { + int calls = 0; + const auto bytes = readFromFirstRoot({}, "reasampler_bank/x.wav", + [&](const std::string&) { + ++calls; + return bytesOf("never"); + }); + CHECK(bytes.empty()); + CHECK(calls == 0); +} + +static void testEmptyRelativePathReadsNothing() { + int calls = 0; + const auto bytes = readFromFirstRoot({"c:/first"}, "", [&](const std::string&) { + ++calls; + return bytesOf("never"); + }); + CHECK(bytes.empty()); + CHECK(calls == 0); +} + +static void testNoRootHoldingTheFileYieldsEmpty() { + const auto bytes = readFromFirstRoot({"c:/first", "c:/second"}, "reasampler_bank/x.wav", + [](const std::string&) { + return std::vector{}; + }); + CHECK(bytes.empty()); +} + +int main() { + testBackgroundInstanceResolvesItsOwnProjectNotTheFirstTab(); + testEachOfTwoProjectsResolvesItsOwnDirectory(); + testNullIdentityResolvesNothing(); + testUnlistedProjectResolvesNothingRatherThanAnotherTab(); + testUnsavedOwnProjectResolvesNothingEvenBesideSavedTabs(); + testNoOpenProjectsResolvesNothing(); + + testProjectDirIsTheOnlyCandidate(); + testNoProjectDirMeansNoCandidates(); + + testReadsUnderTheFirstRootThatHasTheFile(); + testEarlierRootWinsWhenBothHaveTheFile(); + testNoRootsReadsNothing(); + testEmptyRelativePathReadsNothing(); + testNoRootHoldingTheFileYieldsEmpty(); + + if (g_fail == 0) std::printf("sample_roots: all tests passed\n"); + return g_fail != 0; +}