From 0cde4572249731d3a24a67e0abc56f6596e7039b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 16:43:04 -0400 Subject: [PATCH 1/2] =?UTF-8?q?S4=20Tier=200:=20the=20bank=20plays=20?= =?UTF-8?q?=E2=80=94=20VST3=20marshals=20MIDI=20to=20the=20S3=20core,=20re?= =?UTF-8?q?ads=20the=20live=20bank=20+=20resolves=20WAV=20the=20M4=20way,?= =?UTF-8?q?=20mono=20downmix,=20lock-free=20load=20handoff,=20LICE=20sampl?= =?UTF-8?q?e-pick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 28 +++- PLAN.md | 23 ++- src/capture_paths.cpp | 10 ++ src/capture_paths.h | 8 + src/ext_keys.h | 38 +++++ src/persist.cpp | 8 +- src/persist.h | 96 +++++------ src/vst/bridge_marshal.cpp | 62 ------- src/vst/bridge_marshal.h | 34 ++-- src/vst/editor_geometry.cpp | 19 +++ src/vst/editor_geometry.h | 19 +++ src/vst/reaper_bridge.cpp | 56 +++++-- src/vst/reaper_bridge.h | 16 ++ src/vst/reasampler_editor.cpp | 73 +++++--- src/vst/reasampler_editor.h | 30 +++- src/vst/reasampler_processor.cpp | 244 ++++++++++++++++++++++++--- src/vst/reasampler_processor.h | 96 ++++++++++- src/vst/sample_map.cpp | 129 +++++++++++++++ src/vst/sample_map.h | 110 +++++++++++++ src/vst/sampler_core.cpp | 19 ++- src/vst/sampler_core.h | 17 +- tests/test_bridge_marshal.cpp | 70 +------- tests/test_editor_geometry.cpp | 61 +++++++ tests/test_sample_map.cpp | 275 +++++++++++++++++++++++++++++++ 24 files changed, 1239 insertions(+), 302 deletions(-) create mode 100644 src/ext_keys.h create mode 100644 src/vst/sample_map.cpp create mode 100644 src/vst/sample_map.h create mode 100644 tests/test_sample_map.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index cdd05ac..22857c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -621,6 +621,18 @@ target_include_directories(editor_geometry PUBLIC src/vst) add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp) target_include_directories(bridge_marshal PUBLIC src/vst) +# sample_map (Phase S4) — PURE mapping logic for the Tier-0 instrument: the live bank +# blob -> selected sample (via the SHARED bank_book JSON parse, NOT a second parser), +# interleaved->mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap +# build, and the selected-sample instance-state (de)serialization. Links the three pure +# modules it composes — bank_book (shared JSON), wav_trim (shared WAV parse), and +# sampler_core (the Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell +# (reasampler_processor.cpp) does the bridge read + file I/O off the audio thread, then +# calls these; the process callback stays allocation-free. +add_library(sample_map STATIC src/vst/sample_map.cpp) +target_include_directories(sample_map PUBLIC src/vst src) +target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core) + add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) @@ -629,6 +641,13 @@ add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp) target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal) add_test(NAME bridge_marshal_tests COMMAND bridge_marshal_tests) +# sample_map: the S4 mapping heart. Links ONLY sample_map (+ its pure deps) — NEITHER +# the VST3 SDK nor the REAPER SDK — the same structural plain-data-boundary proof the +# sampler_core test enforces. +add_executable(sample_map_tests tests/test_sample_map.cpp) +target_link_libraries(sample_map_tests PRIVATE sample_map) +add_test(NAME sample_map_tests COMMAND sample_map_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -788,7 +807,14 @@ if(WIN32) ${VST3_SDK}/public.sdk/source/main/moduleinit.cpp ${LICE_SRC} ) - target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal) + # editor_geometry + bridge_marshal: the pure spike helpers. sample_map (S4): the pure + # bank->keymap mapping + state (de)ser the processor drives off the audio thread; + # linking it pulls its pure deps (bank_book, wav_trim, sampler_core, bank_model, + # peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile / + # projectDirOfRpp) the bridge + processor use. Its PUBLIC include dirs (src, src/vst) + # give the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). + target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal + sample_map capture_paths) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/PLAN.md b/PLAN.md index 44243d1..c7149a7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -193,15 +193,22 @@ following the active project works; it never captures and never inserts into the arrange (read-only over the bank). **Depends on:** S1, S2, S3. -- [ ] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus, - drive the S3 core, write per-voice audio to the output bus. -- [ ] Live-state seam: read the bank index + selected sample's root note from +- [x] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus, + drive the S3 core, write per-voice audio to the output bus. (Block-granular event + timing at Tier 0; sample-accurate offset scheduling is a later tier.) +- [x] Live-state seam: read the bank index + selected sample's root note from `"reasampler"` ext-state via the bridge; resolve the WAV audio path the M4 - project-relative way (shared convention with `persist`, not re-implemented). -- [ ] Sample selection UI (minimal, in the `IPlugView` LICE editor or a - parameters-only default view): choose which bank sample this instance plays. -- [ ] Tier-0 playback: chromatic-from-root, basic polyphony, amp envelope, - velocity→volume — plays in REAPER's routing/record/render path like any VSTi. + project-relative way (shared convention with `persist`, not re-implemented — the + parent-of-.rpp derivation is extracted to `capture_paths::projectDirOfRpp`, which both + `persist` and the bridge call). Bank JSON parsed via the shared `bank_book` path (the + spike string-scan reader retired); ext-state key names shared via pure `ext_keys.h`. +- [x] Sample selection UI (minimal, in the `IPlugView` LICE editor): a clickable list + of the bank's samples; the pick is the instance's own VST3 component state + (setState/getState), never written back to the bank. +- [x] Tier-0 playback: chromatic-from-root, basic polyphony (16 voices), amp envelope, + velocity→volume — plays in REAPER's routing/record/render path like any VSTi. Sample + load / decode / keymap build happen off the audio thread and hand to `process` via a + lock-free atomic pointer swap (graveyard-reclaim); `process` never allocates. ## S5 — Tier 1: "a keymap" (zoned multisamples, per-sample root notes) **Goal:** Multiple bank samples zoned across the keyboard (key ranges), each with its diff --git a/src/capture_paths.cpp b/src/capture_paths.cpp index 47336e4..33bfbce 100644 --- a/src/capture_paths.cpp +++ b/src/capture_paths.cpp @@ -5,6 +5,7 @@ #include #include #include // std::memcmp +#include #include namespace reasampler { @@ -213,6 +214,15 @@ std::string resolveBankFile(const std::string& projectDir, return dir + "/" + rel; } +std::string projectDirOfRpp(const std::string& rppPath) { + // An unsaved project reports an empty .rpp path; keep it empty so downstream + // resolution refuses (no default-location fallback). Mirrors persist.cpp's prior + // projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes. + if (rppPath.empty()) return {}; + std::string dir = std::filesystem::path(rppPath).parent_path().string(); + return normalizeSlashes(dir); +} + BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, const std::string& newProjectDir) { BankRelocation r; diff --git a/src/capture_paths.h b/src/capture_paths.h index a83387a..4ab8026 100644 --- a/src/capture_paths.h +++ b/src/capture_paths.h @@ -121,6 +121,14 @@ std::string bankRelativeForName(const std::string& fileName); std::string resolveBankFile(const std::string& projectDir, const std::string& relativePath); +// The project directory that holds a .rpp: its parent directory, forward-slashed, +// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty +// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the +// no-default-location invariant). This is the M4 convention persist uses to place +// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves +// audio paths the SAME way persist does rather than re-implementing the derivation. +std::string projectDirOfRpp(const std::string& rppPath); + // A relocation plan for the physical bank folder on Save-As to a new project // location. The index's relative paths do NOT change (they are relative to the // project dir, which is what moved with the .rpp), so relocation is purely a diff --git a/src/ext_keys.h b/src/ext_keys.h new file mode 100644 index 0000000..66ef9a2 --- /dev/null +++ b/src/ext_keys.h @@ -0,0 +1,38 @@ +#pragma once +// ext_keys — the SINGLE SOURCE OF TRUTH for the "reasampler" project ext-state +// namespace + key names, shared by the extension (writer, via persist.h) and the +// VST3 instrument (reader, via the bridge). Both sides include this header so the +// wire contract cannot drift between the two artifacts (the S4 reviewer flagged the +// spike's duplicated constants as a drift risk). +// +// PURE HEADER: NO REAPER types, NO VST3 types, NO SWELL, NO vendor/ includes. Just +// string constants, so both the REAPER-facing persist shell and the SDK-facing VST +// bridge can include it without pulling either SDK. +// +// FOREVER-STABLE once shipped: these strings key every already-saved project's +// stored state. Changing any of them orphans that state. See persist.h for the +// per-key retirement / migration semantics — this header only owns the spellings. + +namespace reasampler { + +// The ext-state namespace all ReaSampler project state is stored under. +inline constexpr const char* kProjExtNamespace = "reasampler"; + +// The multi-bank key: the whole serialized BankBook (pool + named banks). This is +// the key the VST3 instrument reads to see the live bank (read-only, S4). persist.h +// documents its authority + the legacy-key migration around it. +inline constexpr const char* kProjExtBanksKey = "banks"; + +// The retired legacy single-bank key (read once on load to migrate into the pool). +inline constexpr const char* kProjExtIndexKey = "bank_index"; + +// The Design-View model key. +inline constexpr const char* kProjExtViewKey = "view_state"; + +// The docked panel's tail-setting key. +inline constexpr const char* kProjExtTailKey = "tail_setting"; + +// The per-project minted-GUID identity key. +inline constexpr const char* kProjExtGuidKey = "project_guid"; + +} // namespace reasampler diff --git a/src/persist.cpp b/src/persist.cpp index 1986840..9c8bf3a 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -128,11 +128,11 @@ void* readActiveProject(std::string& rppPathOut) { // Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> // empty out. Mirrors capture.cpp's derivation so the bank sits alongside the // .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp -// for the full rationale). normalizeSlashes lives in capture_paths (pure). +// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths +// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both +// artifacts share one implementation rather than duplicating the parent-of-.rpp step. std::string projectDirOf(const std::string& rppPath) { - if (rppPath.empty()) return {}; - std::string dir = fs::path(rppPath).parent_path().string(); - return normalizeSlashes(dir); + return projectDirOfRpp(rppPath); } // GetProjExtState needs a caller-supplied buffer; the index JSON can be large diff --git a/src/persist.h b/src/persist.h index b4f1c10..9acd066 100644 --- a/src/persist.h +++ b/src/persist.h @@ -22,6 +22,7 @@ #include "app_version.h" #include "bank_book.h" #include "bank_model.h" +#include "ext_keys.h" #include "owned_manifest.h" #include "prune_reconcile.h" #include "tail_control.h" @@ -29,71 +30,50 @@ namespace reasampler { -// The ext-state namespace every ReaSampler key is stored under. CHANNEL-DERIVED (Phase V, -// V4): the pure app_version module owns the one channel-qualified string — "reasampler" on -// stable (byte-identical to the pre-V4 build) or "reasampler_beta" on the isolated beta -// build. FOREVER-STABLE per channel once shipped: changing either orphans every already- -// saved project's state. Beta reads/writes ONLY its own namespace — a project saved by -// stable shows empty/default state in beta and vice versa; that isolation is the accepted -// V4 safety property (no cross-namespace read, migration, or fallback), not a bug. -// Returns const char* (not a constexpr literal) because the string is channel-derived at -// build time; the accessor is the single call point for all persist reads/writes below. +// The ext-state namespace + the WIRE-SHARED key names are the contract between this +// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h +// (pure, REAPER-free) and are included above — not duplicated here. The namespace is +// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace() +// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte- +// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both +// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace +// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project +// saved by stable shows empty/default state in beta and vice versa; that isolation is the +// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug. +// The per-key semantics persist relies on (spellings owned by ext_keys.h): +// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks). +// AUTHORITATIVE going forward; the VST reads this key to see the live bank. +// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared +// on save); READ once on load to migrate a legacy project into the pool. +// * kProjExtViewKey : the Design-View ViewModeModel JSON. +// * kProjExtTailKey : the docked panel's TailSetting JSON. +// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll() +// tells a Save-As from a recycled-pointer project switch by it). +// All are FOREVER-STABLE once shipped: changing any strands every already-saved +// project's stored state under that key. +// +// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this +// is the const char* the SetProjExtState/GetProjExtState calls in persist.cpp pass. Kept +// as an accessor (not a literal) because the string is channel-derived at build time. inline const char* projExtNamespace() { return extStateNamespace().c_str(); } -// The RETIRED legacy ext-state key: pre-multi-bank projects stored the whole -// serialized BankIndex here (single bank). Phase B2 no longer WRITES it — on save -// the key is cleared (SetProjExtState with "" deletes it) and the book is written -// under kProjExtBanksKey instead. It is still READ once, on load of a legacy -// project, to migrate its single index into the pool (BankBook's parse-time -// promotion). FOREVER-STABLE as a read key for that migration path. -inline constexpr const char* kProjExtIndexKey = "bank_index"; - -// The multi-bank ext-state key (Phase B): one key holds the whole serialized -// BankBook — the pool folded in as bank-zero plus every named bank, each with its -// own BankIndex, ordinals, and the active-bank id. AUTHORITATIVE going forward; -// supersedes kProjExtIndexKey. FOREVER-STABLE once shipped: changing it orphans -// every already-saved project's banks. -inline constexpr const char* kProjExtBanksKey = "banks"; - -// The ext-state key the Design-View ViewModeModel JSON is stored under (one key -// holds the whole serialized model: modes + membership + show-both + snapshots + -// active mode). Distinct from kProjExtIndexKey — one namespace, two keys. -// FOREVER-STABLE: changing it orphans every already-saved project's view state. -inline constexpr const char* kProjExtViewKey = "view_state"; - -// The ext-state key the docked panel's TailSetting JSON (mode + manualMs) is stored -// under, so the tail choice travels inside the .rpp and loads per project. Distinct -// from the index/view keys — one namespace, three keys. FOREVER-STABLE: changing it -// orphans every already-saved project's tail setting (which then falls back to the -// default — graceful, but the user's saved choice would be lost). -inline constexpr const char* kProjExtTailKey = "tail_setting"; - -// The ext-state key holding the owned-file manifest JSON (the set of project-relative -// files the capture path itself created — Phase B B-cap seam, consumed by Phase R -// prune to distinguish the bank system's own orphans from hand-dropped files). A -// SIBLING key alongside banks/view_state/tail_setting — NOT folded into the `banks` -// blob, so it stays decoupled from bank membership (removing an index entry is not a -// manifest removal). One namespace, four content keys. FOREVER-STABLE: changing it -// strands every already-saved project's ownership record, so Phase R prune could no -// longer tell the tool's own files apart (it would fall back to an empty manifest — +// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument +// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h: +// +// owned_files — the owned-file manifest JSON (project-relative files the capture path +// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's +// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT +// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it +// strands every saved project's ownership record (prune falls back to an empty manifest — // graceful, but the attribution safety net is lost until the next capture rebuilds it). inline constexpr const char* kProjExtOwnedKey = "owned_files"; -// The ext-state key holding the ReaSampler version that last WROTE this project -// (Phase V, V1). Written on every save alongside the banks/view/tail keys, so every -// saved .rpp records which build produced its state — the seam a future within-channel -// forward migration keys off ("this was written by 0.9.01, I am 0.9.05"). An absent -// key is the explicit pre-versioning case (a project saved before this shipped), read -// silently, never an error. FOREVER-STABLE key string once shipped. +// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on +// every save, so every saved .rpp records which build produced its state — the seam a +// future within-channel forward migration keys off. An absent key is the explicit +// pre-versioning case, read silently, never an error. FOREVER-STABLE key string. inline constexpr const char* kProjExtVersionKey = "version"; -// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED -// project identity (REAPER exposes no stable per-project GUID). poll() uses it to -// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch -// onto a recycled ReaProject* pointer (different GUID). FOREVER-STABLE: changing -// it strands the identity of every already-saved project. See persist.cpp. -inline constexpr const char* kProjExtGuidKey = "project_guid"; - // Owns the session's BankBook (Phase B: pool + named banks) and drives persistence // against the active REAPER project. One instance lives for the extension's // lifetime (main.cpp). It tracks diff --git a/src/vst/bridge_marshal.cpp b/src/vst/bridge_marshal.cpp index 7faf94b..23d908c 100644 --- a/src/vst/bridge_marshal.cpp +++ b/src/vst/bridge_marshal.cpp @@ -13,66 +13,4 @@ std::optional decodeGetProjExtState(int apiReturn, return buffer; } -std::optional extractJsonStringField(const std::string& json, - const std::string& key) { - // Find the member token: "key" followed (after optional whitespace) by ':' then a - // quoted string. Scan for each candidate occurrence of the quoted key so a value - // that happens to contain the key text can't produce a false match. - const std::string needle = "\"" + key + "\""; - size_t searchFrom = 0; - - while (true) { - const size_t keyPos = json.find(needle, searchFrom); - if (keyPos == std::string::npos) return std::nullopt; - - size_t i = keyPos + needle.size(); - searchFrom = i; // next candidate starts after this key token - - // Skip whitespace to the ':'. - while (i < json.size() && - (json[i] == ' ' || json[i] == '\t' || json[i] == '\n' || - json[i] == '\r')) { - ++i; - } - if (i >= json.size() || json[i] != ':') continue; // not a member — keep looking - ++i; - - // Skip whitespace to the value. - while (i < json.size() && - (json[i] == ' ' || json[i] == '\t' || json[i] == '\n' || - json[i] == '\r')) { - ++i; - } - if (i >= json.size() || json[i] != '"') return std::nullopt; // value not a string - ++i; - - // Read the string body, honoring the common JSON escapes. - std::string out; - while (i < json.size()) { - const char c = json[i]; - if (c == '\\') { - if (i + 1 >= json.size()) return std::nullopt; // dangling escape - const char e = json[i + 1]; - switch (e) { - case '"': out.push_back('"'); break; - case '\\': out.push_back('\\'); break; - case '/': out.push_back('/'); break; - case 'n': out.push_back('\n'); break; - case 't': out.push_back('\t'); break; - case 'r': out.push_back('\r'); break; - case 'b': out.push_back('\b'); break; - case 'f': out.push_back('\f'); break; - default: out.push_back(e); break; // pass through unknown escapes - } - i += 2; - continue; - } - if (c == '"') return out; // closing quote — done - out.push_back(c); - ++i; - } - return std::nullopt; // unterminated string - } -} - } // namespace reasampler::vst diff --git a/src/vst/bridge_marshal.h b/src/vst/bridge_marshal.h index 0c55b63..2c75068 100644 --- a/src/vst/bridge_marshal.h +++ b/src/vst/bridge_marshal.h @@ -1,18 +1,21 @@ -// bridge_marshal.h — PURE marshalling helpers for the REAPER VST-host bridge read -// (Phase S1). NO VST3, NO REAPER types at the boundary. +// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read. +// NO VST3, NO REAPER types at the boundary. // // The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the -// host callback and invokes them; the fiddly-and-easy-to-get-wrong parts around those -// calls — interpreting GetProjExtState's int return, walking EnumProjExtState's -// index-until-false contract into a key set, and extracting a single value out of the -// "reasampler" bank JSON — are pure and unit-tested here. Mirror of capture_paths / -// wav_trim splitting the arithmetic out of a REAPER-facing shell. +// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around +// GetProjExtState — interpreting its int return against the buffer it filled — is pure +// and unit-tested here. Mirror of capture_paths / wav_trim splitting the arithmetic out +// of a REAPER-facing shell. +// +// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a +// stand-in until the instrument could parse the bank properly. S4 retired it: the +// instrument now parses the "reasampler" bank blob through the SHARED bank_book / +// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module +// is back to its one honest job — the API-return decode. // // Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h: // int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz); // -- returns the length written (0 when the key is absent). -// bool EnumProjExtState(ReaProject*, extname, idx, keyOut, keyOut_sz, valOut, valOut_sz); -// -- returns false when idx is past the last entry. #pragma once @@ -31,17 +34,4 @@ namespace reasampler::vst { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer); -// Extract the string value for `key` out of a flat one-level JSON object — the shape -// persist.cpp writes under the "reasampler" ext-state (e.g. the bank blob's top-level -// fields). This is a deliberately small, dependency-free reader for the SPIKE's -// "read a known value" proof, NOT a general JSON parser: it finds "key" as an object -// member and returns its string value, handling the common escapes (\" \\ \n \t \/). -// Returns nullopt if the key is absent or its value is not a string. -// -// The real instrument (S4) will read the bank index through the shared bank_model JSON -// path, not this helper; this exists only to give the S1 bridge read a testable, -// REAPER-free decode step. -std::optional extractJsonStringField(const std::string& json, - const std::string& key); - } // namespace reasampler::vst diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp index d5babe1..39919bb 100644 --- a/src/vst/editor_geometry.cpp +++ b/src/vst/editor_geometry.cpp @@ -53,4 +53,23 @@ HitTarget hitTest(const EditorLayout& layout, int x, int y) { return HitTarget::kNone; } +Rect sampleRowRect(const EditorLayout& layout, int index) { + if (index < 0) return Rect{}; + const int top = layout.canvas.top + index * kSampleRowHeight; + return Rect{layout.canvas.left, top, layout.canvas.right, top + kSampleRowHeight}; +} + +int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { + if (rowCount <= 0) return -1; + // Must be within the canvas horizontally and at/below its top. + if (x < layout.canvas.left || x >= layout.canvas.right) return -1; + if (y < layout.canvas.top) return -1; + const int index = (y - layout.canvas.top) / kSampleRowHeight; + if (index < 0 || index >= rowCount) return -1; + // Guard the bottom edge: a click below the last row's canvas bottom is outside. + const Rect r = sampleRowRect(layout, index); + if (y >= r.bottom) return -1; + return index; +} + } // namespace reasampler::vst diff --git a/src/vst/editor_geometry.h b/src/vst/editor_geometry.h index b6bb5b1..2fb3697 100644 --- a/src/vst/editor_geometry.h +++ b/src/vst/editor_geometry.h @@ -56,4 +56,23 @@ enum class HitTarget { // is kNone in the spike. HitTarget hitTest(const EditorLayout& layout, int x, int y); +// --- Sample-selection list (S4 Tier-0 UI) ----------------------------------- +// +// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows +// below the title bar; clicking a row selects that sample. This is the pure geometry: +// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the +// shell draws the names and routes the click into the processor's reloadFromBank. + +// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree. +inline constexpr int kSampleRowHeight = 22; + +// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside +// the layout's canvas. Rows beyond what the canvas can show are still computed (the +// shell clips at paint time); a negative index yields an empty rect. Pure. +Rect sampleRowRect(const EditorLayout& layout, int index); + +// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click +// outside the list (above the first row, past the last, or on the title bar). Pure. +int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y); + } // namespace reasampler::vst diff --git a/src/vst/reaper_bridge.cpp b/src/vst/reaper_bridge.cpp index 8096a92..f58e567 100644 --- a/src/vst/reaper_bridge.cpp +++ b/src/vst/reaper_bridge.cpp @@ -5,6 +5,8 @@ #include #include "bridge_marshal.h" +#include "capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation) +#include "ext_keys.h" // kProjExtNamespace (shared wire contract) // The VST3 base types must be included before REAPER's VST3 interface header, which // uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from @@ -27,21 +29,17 @@ namespace Steinberg { // interface (FUnknownPtr uses the iid), so the definition lives with its sole use. DEF_CLASS_IID(Steinberg::IReaperHostApplication) -// The "reasampler" ext-state namespace + bank key. Kept in sync with persist.h by -// value (the extension writes them); we only READ here, so we duplicate the two string -// constants rather than pull the whole REAPER-facing persist.h into the VST artifact. -// If persist.h's kProjExtNamespace / kProjExtBanksKey ever change, these must follow — -// they are the shared wire contract between the extension (writer) and instrument -// (reader). VERIFY against persist.h. -namespace { -constexpr const char* kReasamplerNamespace = "reasampler"; -} +// The "reasampler" ext-state namespace is the SHARED wire contract between the +// extension (writer) and this instrument (reader); it lives in ext_keys.h (pure, +// REAPER-free) — reasampler::kProjExtNamespace — so the two artifacts read one symbol +// and cannot drift. The S1 spike duplicated it locally; that duplication is retired. namespace reasampler::vst { bool ReaperBridge::connect(Steinberg::FUnknown* context) { getProjExtState_ = nullptr; enumProjExtState_ = nullptr; + enumProjects_ = nullptr; hostApp_ = nullptr; if (!context) return false; @@ -58,6 +56,10 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { reaper->getReaperApi("GetProjExtState")); enumProjExtState_ = reinterpret_cast( reaper->getReaperApi("EnumProjExtState")); + // EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call + // persist.cpp uses, so the instrument derives the project directory identically. + enumProjects_ = reinterpret_cast( + reaper->getReaperApi("EnumProjects")); return getProjExtState_ != nullptr; } @@ -74,15 +76,35 @@ std::optional ReaperBridge::readReasamplerExtState(const std::strin // REAPER treats null as the current project for these calls, so we pass it through // rather than bailing — but if the read yields nothing the caller sees nullopt. - // GetProjExtState writes into a caller buffer; size it generously for a JSON blob - // and let the pure decoder interpret the result. The buffer is NUL-terminated by - // REAPER on success. - std::vector buf(64 * 1024, '\0'); - const int rv = getProjExtState_(proj, kReasamplerNamespace, key.c_str(), buf.data(), - static_cast(buf.size())); + // GetProjExtState writes into a caller buffer; the bank blob can be large (many + // samples), so grow the buffer until the value fits rather than risk a silent + // truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The + // return value is the value length; if it fits strictly inside the buffer it is + // complete, else grow and retry up to a 16 MB ceiling. + for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { + std::vector buf(static_cast(cap), '\0'); + const int rv = getProjExtState_(proj, kProjExtNamespace, key.c_str(), + buf.data(), cap); + if (rv <= 0) return std::nullopt; // absent / empty key + std::string s(buf.data()); + if (static_cast(s.size()) + 1 < cap) { + return decodeGetProjExtState(rv, s); + } + // else: possibly truncated -> grow and retry. + } + return std::nullopt; // pathologically large (>16 MB) — give up rather than loop +} - // Marshal the raw result through the pure decoder (handles the absent-key case). - return decodeGetProjExtState(rv, std::string(buf.data())); +std::string ReaperBridge::activeProjectDir() { + if (!enumProjects_) return {}; + // idx=-1 is the current project tab; the out-buffer receives the full .rpp path, + // EMPTY for a never-saved project. Same call + convention as persist.cpp; the pure + // projectDirOfRpp turns the .rpp path into the project directory (parent, forward- + // slashed) and keeps an unsaved project's empty path empty (no default-location + // fallback — the tool's invariant). + std::vector buf(4096, '\0'); + enumProjects_(-1, buf.data(), static_cast(buf.size())); + return projectDirOfRpp(std::string(buf.data())); } } // namespace reasampler::vst diff --git a/src/vst/reaper_bridge.h b/src/vst/reaper_bridge.h index 8eea7dc..8c5dd80 100644 --- a/src/vst/reaper_bridge.h +++ b/src/vst/reaper_bridge.h @@ -44,8 +44,19 @@ public: // Read a "reasampler" ext-state value by key from the host's active project. // Returns nullopt when unconnected, when the project can't be resolved, or when the // key is absent. This is the S1 read-spike entry point. + // + // NOT REAL-TIME SAFE (it allocates a read buffer and calls into REAPER): callers on + // the audio thread MUST NOT invoke it. The S4 instrument reads on the main/UI thread + // and hands a snapshot to the process path (see reasampler_processor.cpp). std::optional readReasamplerExtState(const std::string& key); + // The active project's directory (the folder holding its .rpp), forward-slashed, + // no trailing slash — the M4 convention persist uses to place the bank alongside + // the .rpp. Empty for an unsaved project or when unconnected. The instrument + // resolves relative sample paths against this the SAME way persist does + // (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe. + std::string activeProjectDir(); + private: // Resolved REAPER API function pointers (by name via getReaperApi). Signatures // verified against reaper_plugin_functions.h. @@ -54,10 +65,15 @@ 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 (SDK line + // ~1264). The instrument uses idx=-1 (current tab) so it follows the active project, + // and reads the .rpp path from the out-buffer exactly as persist.cpp does. + using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz); void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp) GetProjExtStateFn getProjExtState_ = nullptr; EnumProjExtStateFn enumProjExtState_ = nullptr; + EnumProjectsFn enumProjects_ = nullptr; }; } // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 0e68a69..3daf183 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -7,7 +7,10 @@ #include #include "editor_geometry.h" +#include "ext_keys.h" #include "reaper_bridge.h" +#include "reasampler_processor.h" +#include "sample_map.h" #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM @@ -47,13 +50,25 @@ void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { #endif } // namespace -ReaSamplerEditor::ReaSamplerEditor(ReaperBridge* bridge) - : CPluginView(nullptr), bridge_(bridge) { +ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) + : CPluginView(nullptr), processor_(processor) { // Default view size; the host may resize (canResize() == true). ViewRect r(0, 0, 420, 260); setRect(r); } +void ReaSamplerEditor::refreshSampleList() { + // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). + if (!processor_) { + samples_.clear(); + selectedId_.clear(); + return; + } + auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banks ? listSamples(*banks) : std::vector{}; + selectedId_ = processor_->selectedSampleId(); +} + ReaSamplerEditor::~ReaSamplerEditor() { #ifdef _WIN32 // Defensive teardown: the host normally calls removed() (which destroys the child) @@ -102,6 +117,9 @@ void ReaSamplerEditor::attachedToParent() { classRegistered = true; } + // Snapshot the live bank so the first paint shows the sample list. + refreshSampleList(); + const ViewRect& r = getRect(); childHwnd_ = CreateWindowExW( 0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(), @@ -144,17 +162,13 @@ void ReaSamplerEditor::paint(HDC hdc) { const EditorLayout layout = layoutEditor(w, h); - // Title band. + // Title band: the plugin name + whether a live bank is linked. LICE_FillRect(&bmp, layout.titleBar.left, layout.titleBar.top, layout.titleBar.width(), layout.titleBar.height(), kColTitleBg, 1.0f, 0); - // Read a known live-state value over the bridge to prove the read spike. Show the - // raw ext-state presence (never the full blob) so the title reflects live project - // state without dumping JSON into the UI. std::string title = "ReaSampler Instrument"; - if (bridge_ && bridge_->isConnected()) { - auto banks = bridge_->readReasamplerExtState("banks"); - title += banks ? " [bank: linked]" : " [bank: none]"; + if (processor_ && processor_->bridge().isConnected()) { + title += samples_.empty() ? " [bank: empty]" : " [pick a sample]"; } else { title += " [host: no bridge]"; } @@ -162,27 +176,42 @@ void ReaSamplerEditor::paint(HDC hdc) { layout.titleBar.right - 8, layout.titleBar.bottom}; drawText(&bmp, titleText, title.c_str(), kRgbText); - // The clickable button — fill reflects the last hit-test (the routing proof). - LICE_FillRect(&bmp, layout.button.left, layout.button.top, layout.button.width(), - layout.button.height(), buttonHit_ ? kColBtnHitBg : kColBtnBg, 1.0f, - 0); - LICE_DrawRect(&bmp, layout.button.left, layout.button.top, layout.button.width(), - layout.button.height(), kColBtnBorder, 1.0f, 0); - Rect btnText{layout.button.left + 8, layout.button.top, layout.button.right, - layout.button.bottom}; - drawText(&bmp, btnText, buttonHit_ ? "clicked" : "click me", kRgbText); + // Sample list: one row per bank sample, the selected one highlighted. Clip drawing + // to rows that fall within the canvas (sampleRowRect computes all; we skip off-screen + // ones so a huge bank doesn't waste paint). + for (int i = 0; i < static_cast(samples_.size()); ++i) { + const Rect row = sampleRowRect(layout, i); + if (row.top >= layout.canvas.bottom) break; // past the visible area + const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_; + LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(), + sel ? kColBtnHitBg : kColBtnBg, 1.0f, 0); + if (sel) { + LICE_DrawRect(&bmp, row.left, row.top, row.width() - 1, row.height() - 1, + kColBtnBorder, 1.0f, 0); + } + Rect textR{row.left + 8, row.top, row.right - 8, row.bottom}; + const std::string& name = samples_[i].displayName; + drawText(&bmp, textR, name.empty() ? samples_[i].id.c_str() : name.c_str(), + kRgbText); + } BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } void ReaSamplerEditor::onClick(int x, int y) { + if (!processor_) return; RECT cr{}; GetClientRect(childHwnd_, &cr); const EditorLayout layout = layoutEditor(cr.right - cr.left, cr.bottom - cr.top); - if (hitTest(layout, x, y) == HitTarget::kButton) { - buttonHit_ = !buttonHit_; - InvalidateRect(childHwnd_, nullptr, FALSE); - } + const int row = sampleRowHitTest(layout, static_cast(samples_.size()), x, y); + if (row < 0) return; + + // Select this sample and rebuild the instrument OFF the audio thread (this WM_ + // handler runs on the UI thread). reloadFromBank reads the id we just set. + processor_->setSelectedSampleId(samples_[row].id); + processor_->reloadFromBank(); + selectedId_ = samples_[row].id; + InvalidateRect(childHwnd_, nullptr, FALSE); } LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 45c4a60..c89d7d8 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -13,21 +13,27 @@ #pragma once +#include +#include + #include "public.sdk/source/common/pluginview.h" +#include "sample_map.h" // SampleChoice (the list the editor draws) + #ifdef _WIN32 #include #endif namespace reasampler::vst { -class ReaperBridge; +class ReaSamplerProcessor; class ReaSamplerEditor : public Steinberg::CPluginView { public: - // `bridge` is owned by the processor and outlives the editor; the editor reads - // (never mutates) it to show a live-state readout. May be null (non-REAPER host). - explicit ReaSamplerEditor(ReaperBridge* bridge); + // `processor` owns this editor's lifetime domain and outlives it; the editor reads + // the live bank through it (the sample list) and drives selection + reload when the + // user clicks a row. May be null (defensive — a real host always supplies one). + explicit ReaSamplerEditor(ReaSamplerProcessor* processor); ~ReaSamplerEditor() override; // Accept only the Windows HWND platform type (D5: Windows-only). @@ -48,7 +54,7 @@ private: #ifdef _WIN32 // Draw the current surface into the child window's DC via a LICE bitmap. void paint(HDC hdc); - // Route a client-space click through editor_geometry::hitTest. + // Route a client-space click: select the sample row under (x, y), if any. void onClick(int x, int y); static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); @@ -56,10 +62,16 @@ private: HWND childHwnd_ = nullptr; #endif - ReaperBridge* bridge_ = nullptr; - // Latched on click so the paint reflects the last hit-test result — the spike's - // proof that host->click->draw routing round-trips. - bool buttonHit_ = false; + // Re-read the bank's sample list from the live bridge into `samples_`. Main/UI + // thread only (reads ext-state); called on attach and after a selection reload. + void refreshSampleList(); + + ReaSamplerProcessor* processor_ = nullptr; + // The bank's samples, snapshotted for the current paint. Refreshed off the audio + // thread; the paint just draws it. + std::vector samples_; + // The currently-selected sample id, mirrored for the paint's highlight. + std::string selectedId_; }; } // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 565ee10..6346b83 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -2,16 +2,64 @@ #include "reasampler_processor.h" +#include +#include +#include + +#include "pluginterfaces/base/ibstream.h" #include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivstevents.h" #include "pluginterfaces/vst/vstspeaker.h" +#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "ext_keys.h" // kProjExtBanksKey (shared wire contract) #include "reasampler_editor.h" +#include "sample_map.h" // selectSample, downmixToMono, buildTier0Keymap, state (de)ser +#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) using namespace Steinberg; using namespace Steinberg::Vst; namespace reasampler::vst { +namespace { + +// Tier-0 fixed instrument shape (Tier 2 makes these editable). A gentle amp envelope so +// notes neither click on nor cut off abruptly; sustain at unity (velocity does the +// dynamics), a short release for a natural tail. Times are in seconds, converted to +// frames against the live sample rate at build time. +constexpr double kAttackSeconds = 0.003; +constexpr double kDecaySeconds = 0.0; +constexpr double kSustainLevel = 1.0; +constexpr double kReleaseSeconds = 0.060; +constexpr std::size_t kMaxVoices = 16; + +AdsrParams tier0Adsr(double sampleRate) { + const double sr = sampleRate > 0.0 ? sampleRate : 44100.0; + AdsrParams p; + p.attackFrames = static_cast(kAttackSeconds * sr); + p.decayFrames = static_cast(kDecaySeconds * sr); + p.sustainLevel = kSustainLevel; + p.releaseFrames = static_cast(kReleaseSeconds * sr); + return p; +} + +// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on +// any failure — the caller treats an unreadable WAV as "nothing to play". +std::vector readFileBytes(const std::string& path) { + std::vector bytes; + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return bytes; + const std::streamoff size = f.tellg(); + if (size <= 0) return bytes; + f.seekg(0, std::ios::beg); + bytes.resize(static_cast(size)); + if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); + return bytes; +} + +} // namespace + FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { // The host owns the returned reference. Cast up to the combined interface the SDK // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. @@ -23,7 +71,7 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { if (result != kResultOk) return result; // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the - // instrument still loads, the editor just shows "no bridge". + // instrument still loads, it just has no live bank to play. bridge_.connect(context); // Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio @@ -35,46 +83,204 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { } tresult PLUGIN_API ReaSamplerProcessor::terminate() { + // process() is not running at terminate. Free the live instrument and drain the + // graveyard. Take the pointer out of the atomic first so nothing else races it. + std::lock_guard lock(reloadMutex_); + delete live_.exchange(nullptr); + graveyard_.clear(); return SingleComponentEffect::terminate(); } -tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool /*state*/) { - // Nothing to allocate/free in the silent skeleton; S4 will size voice buffers here - // against the setupProcessing block size. +tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { + // Activating: build the instrument from the currently-selected sample so the first + // block after activation can play. Deactivating: process is now GUARANTEED stopped by + // the host, so this is the safe point to reclaim the graveyard (the displaced engines + // no reload could free while active). The build/drain are off the audio thread — + // setActive is a main/UI-thread call. + if (state) { + reloadFromBank(); + } else { + std::lock_guard lock(reloadMutex_); + graveyard_.clear(); + } return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { + sampleRate_ = setup.sampleRate; + maxBlockSize_ = setup.maxSamplesPerBlock; return SingleComponentEffect::setupProcessing(setup); } -tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { - // Silent skeleton: emit silence on the output bus so the instrument runs cleanly in - // REAPER's render/record path without a null buffer. S4 marshals MIDI->core->audio. - if (data.numOutputs > 0 && data.outputs && data.numSamples > 0) { - AudioBusBuffers& out = data.outputs[0]; - for (int32 ch = 0; ch < out.numChannels; ++ch) { - if (data.symbolicSampleSize == kSample32) { - if (float* buf = out.channelBuffers32[ch]) { - for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.f; - } - } else if (data.symbolicSampleSize == kSample64) { - if (double* buf = out.channelBuffers64[ch]) { - for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.0; +tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { + if (!state) return kResultFalse; + // Read the whole component-state blob (the selected sample id, versioned). The blob + // is small; read in one shot into a growable buffer. + std::vector bytes; + std::uint8_t chunk[256]; + int32 got = 0; + while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { + bytes.insert(bytes.end(), chunk, chunk + got); + if (got < static_cast(sizeof(chunk))) break; + } + setSelectedSampleId(deserializeSelection(bytes)); + // Rebuild from the restored selection (off-thread — setState is a load-time call). + reloadFromBank(); + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { + if (!state) return kResultFalse; + const std::vector bytes = serializeSelection(selectedSampleId()); + if (!bytes.empty()) { + state->write(const_cast(bytes.data()), + static_cast(bytes.size()), nullptr); + } + return kResultOk; +} + +std::string ReaSamplerProcessor::selectedSampleId() { + std::lock_guard lock(selectionMutex_); + return selectedSampleId_; +} + +void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { + std::lock_guard lock(selectionMutex_); + selectedSampleId_ = id; +} + +std::string ReaSamplerProcessor::reloadFromBank() { + // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so + // the retired-slot free is single-writer. This mutex is NEVER taken on the audio + // thread — process() only touches the atomic. + std::lock_guard lock(reloadMutex_); + + // 1. Read the live bank + resolve the project dir over the bridge (allocates, + // calls REAPER — fine here, off-thread). + std::optional banksJson = + bridge_.readReasamplerExtState(kProjExtBanksKey); + const std::string projectDir = bridge_.activeProjectDir(); + + std::string resolvedId; + std::unique_ptr built; + + if (banksJson) { + // 2. Pick the sample (shared bank_book JSON parse — NOT a second parser). + std::optional sel = + selectSample(*banksJson, selectedSampleId()); + if (sel) { + // 3. Resolve the project-relative WAV path the M4 way persist does, read + + // decode it (file I/O off-thread), downmix to the core's mono contract. + const std::string abs = resolveBankFile(projectDir, sel->relativePath); + if (!abs.empty()) { + const std::vector bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + const std::size_t frames = layout.frameCount(); + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, frames); + std::vector mono = + downmixToMono(interleaved, layout.channelCount); + if (!mono.empty()) { + Keymap km = buildTier0Keymap( + std::move(mono), + static_cast(layout.sampleRate), sel->rootNote, + sel->loop); + built = std::make_unique( + std::move(km), kMaxVoices, tier0Adsr(sampleRate_)); + // Record which id actually resolved so a first-sample fallback + // (empty stored id) becomes the concrete selection. + resolvedId = selectedSampleId(); + } } } } - // Flag output silence so the host can optimize (nothing plays yet). + } + + // 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the + // graveyard (process may still be reading it this block — it is reclaimed only when + // process is stopped, in setActive(false)/terminate). A null `built` (no bank / + // unreadable WAV) installs silence. `built` is heap-owned; release() hands + // ownership to the atomic, and the exchanged pointer is re-owned by the graveyard. + LoadedInstrument* prev = live_.exchange(built.release()); + if (prev) graveyard_.emplace_back(prev); + return resolvedId; +} + +tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { + // REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the + // whole block (a single atomic acquire). + LoadedInstrument* inst = live_.load(std::memory_order_acquire); + + // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps + // events at block granularity (no per-event sample-offset split) — audible timing is + // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. + if (inst && data.inputEvents) { + const int32 count = data.inputEvents->getEventCount(); + for (int32 i = 0; i < count; ++i) { + Event e; + if (data.inputEvents->getEvent(i, e) != kResultOk) continue; + if (e.type == Event::kNoteOnEvent) { + // A note-on with velocity 0 is a note-off by MIDI convention. + const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); + if (vel <= 0) { + inst->engine.noteOff(e.noteOn.pitch); + } else { + inst->engine.noteOn(e.noteOn.pitch, vel); + } + } else if (e.type == Event::kNoteOffEvent) { + inst->engine.noteOff(e.noteOff.pitch); + } + } + } + + if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { + return kResultOk; + } + AudioBusBuffers& out = data.outputs[0]; + const int32 frames = data.numSamples; + + // 64-bit host processing is not supported by the mono float core; emit silence + // rather than mis-render. REAPER runs 32-bit float by default. + if (data.symbolicSampleSize != kSample32) { + for (int32 ch = 0; ch < out.numChannels; ++ch) { + if (double* buf = out.channelBuffers64[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; + } + } out.silenceFlags = (out.numChannels >= 64) ? ~0ULL : ((1ULL << out.numChannels) - 1); + return kResultOk; } + + // Render mono into channel 0's buffer, then replicate to the other channels (the + // core is mono-per-sample). Clear channel 0 first (render ADDS), then mix. + float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; + if (ch0) { + for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; + if (inst) { + inst->engine.render(ch0, static_cast(frames)); + } + // Duplicate the mono render across the remaining output channels. + for (int32 ch = 1; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + } + + // Report silence only when nothing is loaded (lets the host optimize when idle). + // With an instrument loaded we clear the flag so a ringing voice is not skipped. + out.silenceFlags = inst ? 0 : ((out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1)); return kResultOk; } IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { if (name && FIDStringsEqual(name, ViewType::kEditor)) { - return new ReaSamplerEditor(&bridge_); + return new ReaSamplerEditor(this); } return nullptr; } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index c189ec5..a4bad33 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -1,21 +1,53 @@ -// reasampler_processor.h — the VST3 SingleComponentEffect skeleton (Phase S1). THIN -// shell: an instrument that declares an event-input bus + a stereo audio-output bus, -// sets up processing, and runs an empty (silent) process. Nothing plays yet — S4 wires -// the pure sampler core into process(); S1 only proves REAPER hosts it. +// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the +// pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio +// output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the +// engine's audio into the output bus — so a chosen bank sample plays chromatically from +// its root note in REAPER's routing/record/render path. // // SingleComponentEffect is the SDK's combined processor+controller base — sanctioned -// for a non-distributable, REAPER-only plugin under D5/D6 (verified: SDK class -// reference). It gives us addAudioOutput/addEventInput and the IEditController seat, so -// createView() can hand the host our IPlugView LICE editor. +// for a non-distributable, REAPER-only plugin under D5/D6. It gives us +// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own +// state (the selected sample), and the IEditController seat so createView() can hand the +// host our IPlugView LICE editor. +// +// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO +// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — bridge ext-state +// read, WAV decode, path resolve, keymap build, VoiceEngine construction — all happens +// OFF the audio thread (reloadFromBank, driven from the main/UI thread) and is handed to +// process via a single atomic pointer swap. See the LoadedInstrument handoff below. #pragma once +#include +#include +#include +#include +#include +#include + #include "public.sdk/source/vst/vstsinglecomponenteffect.h" #include "reaper_bridge.h" +#include "sampler_core.h" namespace reasampler::vst { +// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice +// engine that plays it. The engine holds a reference into the keymap, so the two MUST +// live and die together at a STABLE address — hence this is heap-allocated and neither +// copyable nor movable. The audio thread only ever reads it through an atomic pointer; +// it is built and destroyed off the audio thread. +struct LoadedInstrument { + Keymap keymap; + VoiceEngine engine; + + LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr) + : keymap(std::move(km)), engine(maxVoices, keymap, adsr) {} + + LoadedInstrument(const LoadedInstrument&) = delete; + LoadedInstrument& operator=(const LoadedInstrument&) = delete; +}; + class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect { public: ReaSamplerProcessor() = default; @@ -30,10 +62,16 @@ public: Steinberg::tresult PLUGIN_API terminate() override; Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override; + // Instance state = the selected bank sample id (D-B: a performance choice the + // instrument owns; NEVER written back to the bank). Component-state, so a saved + // REAPER project restores which sample each instance plays. + Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override; + Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override; + //--- from IAudioProcessor ---------------------------------------------- Steinberg::tresult PLUGIN_API setupProcessing( Steinberg::Vst::ProcessSetup& setup) override; - // Empty in the spike: emits silence (S4 fills it). + // Marshals MIDI -> VoiceEngine -> audio output. Real-time safe (no alloc/IO/lock). Steinberg::tresult PLUGIN_API process( Steinberg::Vst::ProcessData& data) override; @@ -41,8 +79,50 @@ public: // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; + // Called by the editor (main/UI thread) when the user picks a sample, and internally + // on load. Reads the live bank over the bridge, resolves+decodes the selected WAV + // OFF the audio thread, and publishes the built instrument to process() via an + // atomic swap. Safe to call with no bridge / no bank (leaves silence). Returns the + // resolved selection id ("" if nothing was loaded) for the editor to reflect. + std::string reloadFromBank(); + + // The bridge, for the editor's live-state readout + sample list. Owned here; the + // editor borrows it (outlives the editor). + ReaperBridge& bridge() { return bridge_; } + // The current selection id (main/UI thread reads for the editor). Guarded by + // selectionMutex_ — never touched on the audio thread. + std::string selectedSampleId(); + void setSelectedSampleId(const std::string& id); + private: ReaperBridge bridge_; + + // --- The audio-thread handoff (S4 real-time discipline) ----------------- + // process() atomically loads `live_` at block start and marshals/renders against it — + // a single atomic acquire, no lock, no free on the audio thread. + // + // reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new + // LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is + // NOT freed on the reload path: process() may still be mid-block reading it, and two + // rapid reloads could otherwise free a pointer process is using. Instead it is parked + // in `graveyard_` and reclaimed only when process is GUARANTEED stopped — at + // setActive(false) / terminate(), which the host never runs concurrently with + // process. The graveyard grows by one engine per reload during a session (bounded by + // user sample switches — a few objects), a deliberate leak-until-deactivate trade for + // a lock-free, race-free audio thread. Tier 2 can add epoch-based reclaim if needed. + std::atomic live_{nullptr}; + std::vector> graveyard_; // freed only when stopped + std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access + + // The selected sample id (instance state). Off-thread only; a small mutex guards the + // string against a getState/editor race. NOT read on the audio thread. + std::mutex selectionMutex_; + std::string selectedSampleId_; + + // Latched from setupProcessing so setActive/reload can size against it. Read + // off-thread only. + double sampleRate_ = 44100.0; + Steinberg::int32 maxBlockSize_ = 4096; }; } // namespace reasampler::vst diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp new file mode 100644 index 0000000..a87a13f --- /dev/null +++ b/src/vst/sample_map.cpp @@ -0,0 +1,129 @@ +// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL / +// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. + +#include "sample_map.h" + +#include // std::memcpy + +namespace reasampler { + +namespace { + +// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank +// stores loop points as an optional LoopPoints (both-or-neither); the core wants a +// SampleLoop with an explicit hasLoop. Absent -> no loop. +SampleLoop loopFromSample(const Sample& s) { + SampleLoop out; + if (s.loop) { + out.hasLoop = true; + out.start = s.loop->start; + out.end = s.loop->end; + } + return out; +} + +// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C +// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on +// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4). +SelectedSample distill(const Sample& s) { + SelectedSample out; + out.relativePath = s.relativePath; + out.rootNote = s.rootNote ? *s.rootNote : 60; + out.loop = loopFromSample(s); + return out; +} + +} // namespace + +std::optional selectSample(const std::string& banksJson, + const std::string& sampleId) { + if (banksJson.empty()) return std::nullopt; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return std::nullopt; // malformed -> nothing to play (never throw) + + // Search every bank (pool first, then named — banks() is ordinal order) for the + // stored id. A sample lives in exactly one bank, so first hit wins. + if (!sampleId.empty()) { + for (const Bank& b : book->banks()) { + if (const Sample* s = b.index.query(sampleId)) { + return distill(*s); + } + } + } + + // No stored id, or the id no longer resolves (the sample was deleted/moved out): + // fall back to the FIRST sample in ordinal order so a fresh instance plays. + for (const Bank& b : book->banks()) { + if (!b.index.all().empty()) { + return distill(b.index.all().front()); + } + } + return std::nullopt; // bank has zero samples anywhere +} + +std::vector listSamples(const std::string& banksJson) { + std::vector out; + if (banksJson.empty()) return out; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; + for (const Bank& b : book->banks()) { + for (const Sample& s : b.index.all()) { + out.push_back(SampleChoice{s.id, s.displayName}); + } + } + return out; +} + +std::vector downmixToMono(const std::vector& interleaved, + int channelCount) { + std::vector out; + if (channelCount <= 0 || interleaved.empty()) return out; + const std::size_t stride = static_cast(channelCount); + const std::size_t frames = interleaved.size() / stride; + out.resize(frames); + const double inv = 1.0 / static_cast(channelCount); + for (std::size_t f = 0; f < frames; ++f) { + double acc = 0.0; + const std::size_t base = f * stride; + for (std::size_t c = 0; c < stride; ++c) { + acc += static_cast(interleaved[base + c]); + } + out[f] = static_cast(acc * inv); + } + return out; +} + +Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, + int rootNote, const SampleLoop& loop) { + SampleData data; + data.frames = std::move(monoFrames); + data.sampleRate = sampleRate > 0 ? sampleRate : 44100; + data.rootNote = rootNote; + data.loop = loop; + return Keymap::singleSampleChromatic(std::move(data)); +} + +std::vector serializeSelection(const std::string& sampleId) { + std::vector out; + out.resize(4 + sampleId.size()); + const std::uint32_t v = kSelectionStateVersion; + out[0] = static_cast(v & 0xFF); + out[1] = static_cast((v >> 8) & 0xFF); + out[2] = static_cast((v >> 16) & 0xFF); + out[3] = static_cast((v >> 24) & 0xFF); + std::memcpy(out.data() + 4, sampleId.data(), sampleId.size()); + return out; +} + +std::string deserializeSelection(const std::vector& bytes) { + if (bytes.size() < 4) return {}; // no version tag -> no selection + const std::uint32_t v = static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8) | + (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); + if (v != kSelectionStateVersion) return {}; // unknown version -> ignore + return std::string(reinterpret_cast(bytes.data() + 4), + bytes.size() - 4); +} + +} // namespace reasampler diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h new file mode 100644 index 0000000..6d3177a --- /dev/null +++ b/src/vst/sample_map.h @@ -0,0 +1,110 @@ +#pragma once +// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live +// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core +// plays, and (de)serialize the instance's selected-sample choice for VST3 component +// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the +// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable +// arithmetic out of a host-facing shell. +// +// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam +// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV). +// Both of those raw inputs cross the bridge/file boundary in the shell; everything +// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second +// parser; the S1 spike's string-scan reader is retired), pick the selected sample, +// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic +// Keymap — is pure and unit-tested here. +// +// It links bank_book (the shared BankBook::deserialize) and wav_trim (the shared +// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap / +// SampleData it produces). All three are pure; this stays pure. + +#include +#include +#include +#include + +#include "bank_book.h" // BankBook::deserialize (shared bank JSON parse) +#include "sampler_core.h" // Keymap, SampleData, SampleLoop +#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) + +namespace reasampler { + +// The bank sample this instance is bound to, distilled from the live "banks" blob: +// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank +// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet. +struct SelectedSample { + std::string relativePath; // project-relative; the shell resolves it (M4 convention) + int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty + SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty +}; + +// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks" +// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank +// project). `sampleId` is this instance's stored selection. +// +// Precedence, all pure: +// * empty / malformed banksJson -> nullopt (nothing to play) +// * sampleId names a sample in ANY bank -> that sample (searched pool + named) +// * sampleId empty or not found, bank has -> the FIRST sample in ordinal order +// >= 1 sample (a sensible default so a fresh +// instance plays SOMETHING; the UI can +// then pick a specific one) +// * bank has zero samples -> nullopt +// +// The "first sample" fallback is deliberate: Tier 0 is "the bank plays", and a brand- +// new instance with no stored selection should map the bank's first sample rather than +// stay silent until the user opens the editor. +std::optional selectSample(const std::string& banksJson, + const std::string& sampleId); + +// All (id, displayName) pairs across every bank in ordinal order (pool first), for the +// selection UI to list. Empty for an empty / malformed blob. Pure projection over the +// shared parse — the UI never parses JSON itself. +struct SampleChoice { + std::string id; + std::string displayName; +}; +std::vector listSamples(const std::string& banksJson); + +// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields: +// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per +// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0, +// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve +// their source channel count, so a stereo (or N-channel) capture is folded to a single +// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is +// the least-surprising, no-clip default — a centered mono source stays unity, and a +// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride +// in -> empty out. Pure. +std::vector downmixToMono(const std::vector& interleaved, + int channelCount); + +// Build the Tier-0 chromatic keymap for one decoded, mono sample: one zone spanning +// the whole keyboard, repitched from `rootNote`, looped per `loop`. This is the +// single-sample degenerate case (Keymap::singleSampleChromatic) with the S2 intrinsics +// threaded in. `monoFrames` is the downmixed PCM; `sampleRate` is the WAV's rate. +Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, + int rootNote, const SampleLoop& loop); + +// --- Instance state (VST3 setState/getState) -------------------------------- +// +// The instrument's OWN state is which bank sample it plays (D-B: the selection is a +// performance choice, held by the instrument, never written back to the bank). It is a +// single string id. serialize/deserialize keep the on-the-wire form explicit and +// versioned so a future Tier can extend it without breaking already-saved instances. +// +// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No +// length prefix is needed — the id runs to the end of the stream (the host tells us the +// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob +// by returning "" (no selection — the instrument falls back to the bank's first sample), +// never throwing across the host boundary. + +inline constexpr std::uint32_t kSelectionStateVersion = 1; + +// The selected-sample id serialized to bytes for IBStream (getState). +std::vector serializeSelection(const std::string& sampleId); + +// The selected-sample id parsed back from IBStream bytes (setState). Unknown version, +// too-short, or empty -> "" (graceful no-selection). +std::string deserializeSelection(const std::vector& bytes); + +} // namespace reasampler diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index ae502bb..57a7748 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -289,18 +289,29 @@ void VoiceEngine::noteOff(int note) { if (target != kNoVoice) voices_[target].release(); } -void VoiceEngine::render(std::vector& out, std::size_t frameCount) { - const std::size_t base = out.size(); - out.resize(base + frameCount, 0.0f); // S4: caller must pre-reserve — no allocation allowed under the VST3 process callback. +void VoiceEngine::render(AudioSample* out, std::size_t frameCount) { + // Real-time safe: no allocation, no resize — mix straight into the caller's buffer. + // The VST3 process callback hands us the host's output channel buffer here, so the + // audio thread never touches the heap (S4 real-time discipline). + if (out == nullptr || frameCount == 0) return; for (Voice& voice : voices_) { if (!voice.active()) continue; for (std::size_t f = 0; f < frameCount; ++f) { if (!voice.active()) break; - out[base + f] += voice.renderFrame(); + out[f] += voice.renderFrame(); } } } +void VoiceEngine::render(std::vector& out, std::size_t frameCount) { + // Off-thread / test path: grow the buffer (this allocates — never call under + // process), zero-fill the appended span, then delegate to the RT mix loop so both + // overloads share exactly one summation path. + const std::size_t base = out.size(); + out.resize(base + frameCount, 0.0f); + render(out.data() + base, frameCount); +} + std::size_t VoiceEngine::activeVoiceCount() const { std::size_t n = 0; for (const Voice& v : voices_) { diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index a51bccc..5efa837 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -236,9 +236,20 @@ public: // the older tail to ring — matches hardware behavior). No-op if none match. void noteOff(int note); - // Renders `frameCount` mono output frames, summing all active voices, appending to - // `out` (does not clear it — the caller owns mixing/clearing). Voices that finish - // mid-block go idle and stop contributing. + // REAL-TIME render (S4): sums all active voices into the caller-provided buffer + // `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes — + // this never touches memory it does not own and NEVER allocates). This is the + // audio-thread entry point: the VST3 process callback passes the host's own output + // channel buffer, so no allocation, resize, or heap traffic happens under process. + // Voices that finish mid-block go idle and stop contributing. `out` must point at + // at least `frameCount` writable samples; a null `out` or zero count is a no-op. + void render(AudioSample* out, std::size_t frameCount); + + // TEST / off-thread convenience: appends `frameCount` summed frames to `out` + // (grows it — DO NOT call on the audio thread; it allocates). Delegates to the + // real-time overload after sizing the buffer, so both paths share one mix loop. + // Does not clear existing contents — appends, matching the pre-S4 contract the + // unit tests rely on. void render(std::vector& out, std::size_t frameCount); // Count of currently active voices (for tests / diagnostics). diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp index e3beb6d..8c6abbb 100644 --- a/tests/test_bridge_marshal.cpp +++ b/tests/test_bridge_marshal.cpp @@ -1,11 +1,12 @@ // Standalone tests for reasampler::vst::bridge_marshal — no VST3, no REAPER, no test // framework. Same fast assert loop as the sibling pure tests: assert the REAPER -// bridge-read marshalling (GetProjExtState result decode + a small JSON string-field -// reader) directly, so the DAW-facing shell only has to invoke the API. +// bridge-read marshalling (GetProjExtState result decode) directly, so the DAW-facing +// shell only has to invoke the API. // // Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer -// guard); extractJsonStringField present/absent/escapes/whitespace/value-vs-key -// disambiguation/non-string-value/malformed. +// guard). The S1 spike's extractJsonStringField string-scan reader was retired in S4 +// (the instrument now parses the bank through the shared bank_book JSON path), so its +// cases are gone with it. #include "../src/vst/bridge_marshal.h" @@ -44,72 +45,11 @@ static void testDecodeEmptyBuffer() { CHECK(!v.has_value()); } -// --- extractJsonStringField --------------------------------------------------- - -static void testExtractPresent() { - const std::string json = R"({"guid":"ABC-123","name":"kick"})"; - auto g = extractJsonStringField(json, "guid"); - CHECK(g && *g == "ABC-123"); - auto n = extractJsonStringField(json, "name"); - CHECK(n && *n == "kick"); -} - -static void testExtractAbsent() { - const std::string json = R"({"guid":"ABC-123"})"; - CHECK(!extractJsonStringField(json, "missing").has_value()); -} - -static void testExtractWhitespaceTolerant() { - const std::string json = "{ \"guid\" : \"X\" , \"n\":\"y\" }"; - auto g = extractJsonStringField(json, "guid"); - CHECK(g && *g == "X"); -} - -static void testExtractEscapes() { - // \" \\ \/ \n \t all decode. - const std::string json = R"({"path":"a\\b\/c\"d\ne"})"; - auto p = extractJsonStringField(json, "path"); - CHECK(p && *p == "a\\b/c\"d\ne"); -} - -static void testExtractValueContainingKeyText() { - // A VALUE that contains the key text must not be mistaken for the member. Here the - // first "guid" occurrence is inside another value; the real member comes later. - const std::string json = R"({"note":"the guid is here","guid":"REAL"})"; - auto g = extractJsonStringField(json, "guid"); - CHECK(g && *g == "REAL"); -} - -static void testExtractNonStringValue() { - // A numeric/object value is not a string — return nullopt rather than garbage. - const std::string json = R"({"count":42,"name":"ok"})"; - CHECK(!extractJsonStringField(json, "count").has_value()); - // The sibling string field still reads. - auto n = extractJsonStringField(json, "name"); - CHECK(n && *n == "ok"); -} - -static void testExtractMalformed() { - CHECK(!extractJsonStringField(R"({"guid":"unterminated)", "guid").has_value()); - CHECK(!extractJsonStringField(R"({"guid":)", "guid").has_value()); - CHECK(!extractJsonStringField(R"({"guid")", "guid").has_value()); - CHECK(!extractJsonStringField("", "guid").has_value()); - // Dangling escape at end of string. - CHECK(!extractJsonStringField(R"({"guid":"abc\)", "guid").has_value()); -} - int main() { testDecodeHit(); testDecodeAbsentKey(); testDecodeNegativeReturn(); testDecodeEmptyBuffer(); - testExtractPresent(); - testExtractAbsent(); - testExtractWhitespaceTolerant(); - testExtractEscapes(); - testExtractValueContainingKeyText(); - testExtractNonStringValue(); - testExtractMalformed(); if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index 2a18a80..b19db13 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -131,6 +131,63 @@ static void testHitTestMatchesDrawnButton() { } } +// --- sample list (S4) --------------------------------------------------------- + +static void testSampleRowRectStacks() { + const EditorLayout L = layoutEditor(400, 260); + const Rect r0 = sampleRowRect(L, 0); + const Rect r1 = sampleRowRect(L, 1); + // Row 0 starts at the canvas top and spans its full width. + CHECK(r0.top == L.canvas.top); + CHECK(r0.left == L.canvas.left && r0.right == L.canvas.right); + CHECK(r0.height() == kSampleRowHeight); + // Row 1 sits directly below row 0 (no gap, no overlap). + CHECK(r1.top == r0.bottom); + CHECK(r1.height() == kSampleRowHeight); + // A negative index is an empty rect. + CHECK(sampleRowRect(L, -1).width() == 0 && sampleRowRect(L, -1).height() == 0); +} + +static void testSampleRowHitTestMapsClickToRow() { + const EditorLayout L = layoutEditor(400, 260); + const int rows = 5; + // A click in the vertical middle of row 2 resolves to index 2. + const Rect r2 = sampleRowRect(L, 2); + const int midY = (r2.top + r2.bottom) / 2; + CHECK(sampleRowHitTest(L, rows, 200, midY) == 2); + // Row 0's top-left corner hits row 0. + const Rect r0 = sampleRowRect(L, 0); + CHECK(sampleRowHitTest(L, rows, r0.left, r0.top) == 0); +} + +static void testSampleRowHitTestMisses() { + const EditorLayout L = layoutEditor(400, 260); + const int rows = 3; + // Above the first row (in the title bar) -> no row. + CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.top) == -1); + // Below the last row -> no row. + const Rect last = sampleRowRect(L, rows - 1); + CHECK(sampleRowHitTest(L, rows, 200, last.bottom + 1) == -1); + // Left of the canvas -> no row. + CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1); + // Zero rows -> always -1. + CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1); +} + +// The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to +// that row's index (the same load-bearing invariant as the button). +static void testSampleRowHitTestMatchesDrawnRows() { + const EditorLayout L = layoutEditor(320, 200); + const int rows = 4; + for (int i = 0; i < rows; ++i) { + const Rect r = sampleRowRect(L, i); + if (r.top >= L.canvas.bottom) break; // clipped rows aren't clickable targets + const int y = (r.top + r.bottom) / 2; + if (y >= L.canvas.bottom) continue; + CHECK(sampleRowHitTest(L, rows, r.left + 1, y) == i); + } +} + int main() { testContainsHalfOpen(); testContainsDegenerate(); @@ -141,6 +198,10 @@ int main() { testHitTestMissesNonButton(); testHitTestButtonBoundary(); testHitTestMatchesDrawnButton(); + testSampleRowRectStacks(); + testSampleRowHitTestMapsClickToRow(); + testSampleRowHitTestMisses(); + testSampleRowHitTestMatchesDrawnRows(); if (g_fail == 0) std::printf("editor_geometry: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp new file mode 100644 index 0000000..130dc05 --- /dev/null +++ b/tests/test_sample_map.cpp @@ -0,0 +1,275 @@ +// Standalone tests for reasampler::sample_map — no VST3, no REAPER, no test framework. +// Same fast assert loop as the sibling pure tests. This module is the S4 mapping heart: +// bank blob -> selected sample (through the SHARED bank_book JSON parse), interleaved -> +// mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap build, and the +// selected-sample instance-state (de)serialization. +// +// Every assertion is written to FAIL if the mapping were wrong: the bank blobs are built +// by serializing a real BankBook (so we exercise the shared parse, not a fixture string), +// and the selection / downmix / keymap / state values are checked against independently +// computed expectations. +// +// Covers: selectSample by-id hit (across pool + named banks), first-sample fallback for +// an empty / unknown id, empty & malformed blob -> nullopt, zero-samples -> nullopt, +// rootNote/loop intrinsic threading incl. the middle-C default; listSamples ordinal +// order + empty/malformed; downmixToMono mono passthrough / stereo average / 3-ch +// average / zero-stride / empty; buildTier0Keymap single full-keyboard zone with the +// root + loop + rate threaded and rate defaulting; selection state round-trip + empty id +// + wrong-version / truncated -> "". + +#include "../src/vst/sample_map.h" + +#include +#include +#include + +#include "../src/bank_book.h" +#include "../src/bank_model.h" + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// Build a Sample with the fields sample_map reads. Relative path is required by +// BankIndex::add (relative-only invariant); a content hash is set so dedup does not +// collapse distinct entries. +static Sample makeSample(const std::string& id, const std::string& name, + const std::string& rel, std::optional root) { + Sample s; + s.id = id; + s.displayName = name; + s.relativePath = rel; + s.contentHash = "hash-" + id; + s.rootNote = root; + return s; +} + +// A serialized BankBook: the pool carries `poolSamples`, and one named bank "Drums" +// carries `drumSamples`. Returns the JSON the instrument would read from ext-state. +static std::string bookJson(const std::vector& poolSamples, + const std::vector& drumSamples) { + BankBook book; + for (const Sample& s : poolSamples) book.pool().index.add(s); + if (!drumSamples.empty()) { + book.createBank("drums-id", "Drums"); + BankIndex* di = book.index("drums-id"); + for (const Sample& s : drumSamples) di->add(s); + } + return book.serialize(); +} + +// --- selectSample ------------------------------------------------------------- + +static void testSelectByIdHit() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + // A sample in the NAMED bank resolves by id (search spans every bank). + auto sel = selectSample(json, "b"); + CHECK(sel.has_value()); + CHECK(sel && sel->relativePath == "reasampler_bank/b.wav"); + CHECK(sel && sel->rootNote == 38); +} + +static void testSelectFirstSampleFallbackOnEmptyId() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + // No stored selection -> the FIRST sample in ordinal order (pool first). + auto sel = selectSample(json, ""); + CHECK(sel.has_value()); + CHECK(sel && sel->relativePath == "reasampler_bank/a.wav"); + CHECK(sel && sel->rootNote == 36); +} + +static void testSelectFirstSampleFallbackOnUnknownId() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {}); + // A stored id that no longer resolves falls back to the first sample, not silence. + auto sel = selectSample(json, "deleted-id"); + CHECK(sel.has_value()); + CHECK(sel && sel->relativePath == "reasampler_bank/a.wav"); +} + +static void testSelectRootNoteDefault() { + const std::string json = bookJson( + {makeSample("a", "Loop", "reasampler_bank/a.wav", std::nullopt)}, {}); + // A sample with no root-note intrinsic defaults to middle C (60). + auto sel = selectSample(json, "a"); + CHECK(sel.has_value()); + CHECK(sel && sel->rootNote == 60); +} + +static void testSelectLoopThreaded() { + Sample s = makeSample("a", "Pad", "reasampler_bank/a.wav", 60); + s.loop = LoopPoints{100, 500}; + const std::string json = bookJson({s}, {}); + auto sel = selectSample(json, "a"); + CHECK(sel.has_value()); + CHECK(sel && sel->loop.hasLoop); + CHECK(sel && sel->loop.start == 100 && sel->loop.end == 500); +} + +static void testSelectNoLoopIsAbsent() { + const std::string json = bookJson( + {makeSample("a", "OneShot", "reasampler_bank/a.wav", 60)}, {}); + auto sel = selectSample(json, "a"); + CHECK(sel.has_value()); + CHECK(sel && !sel->loop.hasLoop); // absent loop -> hasLoop false (not a zero loop) +} + +static void testSelectEmptyBlob() { + CHECK(!selectSample("", "a").has_value()); +} + +static void testSelectMalformedBlob() { + CHECK(!selectSample("{not valid json", "a").has_value()); +} + +static void testSelectZeroSamples() { + // A valid book with NO samples anywhere -> nothing to play. + const std::string json = bookJson({}, {}); + CHECK(!selectSample(json, "").has_value()); + CHECK(!selectSample(json, "anything").has_value()); +} + +// --- listSamples -------------------------------------------------------------- + +static void testListSamplesOrdinalOrder() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36), + makeSample("c", "Hat", "reasampler_bank/c.wav", 42)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + const std::vector list = listSamples(json); + // Pool samples (insertion order) come before the named bank's. + CHECK(list.size() == 3); + CHECK(list.size() == 3 && list[0].id == "a" && list[0].displayName == "Kick"); + CHECK(list.size() == 3 && list[1].id == "c"); + CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare"); +} + +static void testListSamplesEmptyAndMalformed() { + CHECK(listSamples("").empty()); + CHECK(listSamples("{garbage").empty()); + CHECK(listSamples(bookJson({}, {})).empty()); +} + +// --- downmixToMono ------------------------------------------------------------ + +static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; } + +static void testDownmixMonoPassthrough() { + const std::vector in{0.1f, -0.2f, 0.3f}; + const std::vector out = downmixToMono(in, 1); + CHECK(out.size() == 3); + CHECK(out.size() == 3 && approx(out[0], 0.1) && approx(out[1], -0.2) && + approx(out[2], 0.3)); +} + +static void testDownmixStereoAverages() { + // Two frames, stereo interleaved: frame0 = (1.0, 0.0) -> 0.5; frame1 = (0.4, 0.6) -> 0.5. + const std::vector in{1.0f, 0.0f, 0.4f, 0.6f}; + const std::vector out = downmixToMono(in, 2); + CHECK(out.size() == 2); + CHECK(out.size() == 2 && approx(out[0], 0.5) && approx(out[1], 0.5)); +} + +static void testDownmixThreeChannelAverages() { + // One 3-channel frame (0.3, 0.3, 0.6) -> 0.4. + const std::vector in{0.3f, 0.3f, 0.6f}; + const std::vector out = downmixToMono(in, 3); + CHECK(out.size() == 1); + CHECK(out.size() == 1 && approx(out[0], 0.4)); +} + +static void testDownmixDegenerate() { + CHECK(downmixToMono({}, 2).empty()); // empty input + CHECK(downmixToMono({0.1f, 0.2f}, 0).empty()); // zero stride + CHECK(downmixToMono({0.1f, 0.2f}, -1).empty()); // negative stride +} + +// --- buildTier0Keymap --------------------------------------------------------- + +static void testBuildKeymapSingleFullZone() { + SampleLoop loop; + loop.hasLoop = true; + loop.start = 10; + loop.end = 90; + const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 40, loop); + // One sample, one zone spanning the whole keyboard, rooted at 40. + CHECK(km.samples.size() == 1); + CHECK(km.zones.size() == 1); + CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 0 && km.zones[0].highNote == 127); + CHECK(km.zones.size() == 1 && km.zones[0].rootNote == 40); + CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 48000); + CHECK(km.samples.size() == 1 && km.samples[0].rootNote == 40); + CHECK(km.samples.size() == 1 && km.samples[0].frames.size() == 3); + CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop && + km.samples[0].loop.start == 10 && km.samples[0].loop.end == 90); + // Resolution: any note lands in the single zone. + CHECK(km.resolve(0, 100).matched); + CHECK(km.resolve(127, 100).matched); +} + +static void testBuildKeymapRateDefault() { + // A zero/invalid rate defaults to 44100 rather than producing a divide-by-zero-shaped + // sample rate downstream. + const Keymap km = buildTier0Keymap({0.1f}, 0, 60, SampleLoop{}); + CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 44100); +} + +// --- selection state (setState/getState) -------------------------------------- + +static void testSelectionStateRoundTrip() { + const std::string id = "sample-guid-123"; + const std::vector bytes = serializeSelection(id); + // Versioned: 4-byte tag + the id bytes. + CHECK(bytes.size() == 4 + id.size()); + CHECK(deserializeSelection(bytes) == id); +} + +static void testSelectionStateEmptyId() { + const std::vector bytes = serializeSelection(""); + CHECK(bytes.size() == 4); // just the version tag + CHECK(deserializeSelection(bytes) == ""); +} + +static void testSelectionStateWrongVersion() { + std::vector bytes = serializeSelection("id"); + bytes[0] = 0xEE; // corrupt the version tag + CHECK(deserializeSelection(bytes) == ""); // unknown version -> no selection +} + +static void testSelectionStateTruncated() { + CHECK(deserializeSelection({}) == ""); // empty + CHECK(deserializeSelection({1, 0, 0}) == ""); // fewer than 4 bytes (no tag) +} + +int main() { + testSelectByIdHit(); + testSelectFirstSampleFallbackOnEmptyId(); + testSelectFirstSampleFallbackOnUnknownId(); + testSelectRootNoteDefault(); + testSelectLoopThreaded(); + testSelectNoLoopIsAbsent(); + testSelectEmptyBlob(); + testSelectMalformedBlob(); + testSelectZeroSamples(); + testListSamplesOrdinalOrder(); + testListSamplesEmptyAndMalformed(); + testDownmixMonoPassthrough(); + testDownmixStereoAverages(); + testDownmixThreeChannelAverages(); + testDownmixDegenerate(); + testBuildKeymapSingleFullZone(); + testBuildKeymapRateDefault(); + testSelectionStateRoundTrip(); + testSelectionStateEmptyId(); + testSelectionStateWrongVersion(); + testSelectionStateTruncated(); + + if (g_fail == 0) std::printf("sample_map: all tests passed\n"); + return g_fail != 0; +} From b4b64ce68f69784409e04645090a9e7f468db2eb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 16:57:48 -0400 Subject: [PATCH 2/2] fix(S4): close graveyard epoch ordering race; add canvas-clip + stride integration tests Tag LoadedInstrument with installedAt; process() publishes that field (not a re-read of reloadGeneration_) so the pruner's displacedAt <= seen bound is airtight. Also fixes sampleRowHitTest canvas.bottom over-read and adds interleave-stride + canvas-clip tests. --- src/vst/editor_geometry.cpp | 5 +- src/vst/reasampler_processor.cpp | 49 ++++++++++++--- src/vst/reasampler_processor.h | 41 +++++++++--- tests/test_editor_geometry.cpp | 10 +++ tests/test_sample_map.cpp | 105 +++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 19 deletions(-) diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp index 39919bb..83a9b7d 100644 --- a/src/vst/editor_geometry.cpp +++ b/src/vst/editor_geometry.cpp @@ -64,9 +64,12 @@ int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { // Must be within the canvas horizontally and at/below its top. if (x < layout.canvas.left || x >= layout.canvas.right) return -1; if (y < layout.canvas.top) return -1; + // Clip at the canvas bottom: clicks in the canvas's dead-zone below the last + // visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom. + if (y >= layout.canvas.bottom) return -1; const int index = (y - layout.canvas.top) / kSampleRowHeight; if (index < 0 || index >= rowCount) return -1; - // Guard the bottom edge: a click below the last row's canvas bottom is outside. + // Guard the bottom edge: a click below the last row's bottom is outside. const Rect r = sampleRowRect(layout, index); if (y >= r.bottom) return -1; return index; diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 6346b83..ed3c337 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -2,6 +2,7 @@ #include "reasampler_processor.h" +#include #include #include #include @@ -121,7 +122,6 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { int32 got = 0; while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { bytes.insert(bytes.end(), chunk, chunk + got); - if (got < static_cast(sizeof(chunk))) break; } setSelectedSampleId(deserializeSelection(bytes)); // Rebuild from the restored selection (off-thread — setState is a load-time call). @@ -133,8 +133,9 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; const std::vector bytes = serializeSelection(selectedSampleId()); if (!bytes.empty()) { - state->write(const_cast(bytes.data()), - static_cast(bytes.size()), nullptr); + const tresult wr = state->write(const_cast(bytes.data()), + static_cast(bytes.size()), nullptr); + if (wr != kResultOk) return wr; } return kResultOk; } @@ -155,6 +156,10 @@ std::string ReaSamplerProcessor::reloadFromBank() { // thread — process() only touches the atomic. std::lock_guard lock(reloadMutex_); + // Mint this reload's generation number first so we can stamp the built instrument + // with it before publishing. Under reloadMutex_ no other reload races here. + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + // 1. Read the live bank + resolve the project dir over the bridge (allocates, // calls REAPER — fine here, off-thread). std::optional banksJson = @@ -187,7 +192,7 @@ std::string ReaSamplerProcessor::reloadFromBank() { static_cast(layout.sampleRate), sel->rootNote, sel->loop); built = std::make_unique( - std::move(km), kMaxVoices, tier0Adsr(sampleRate_)); + std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen); // Record which id actually resolved so a first-sample fallback // (empty stored id) becomes the concrete selection. resolvedId = selectedSampleId(); @@ -198,19 +203,43 @@ std::string ReaSamplerProcessor::reloadFromBank() { } // 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the - // graveyard (process may still be reading it this block — it is reclaimed only when - // process is stopped, in setActive(false)/terminate). A null `built` (no bank / - // unreadable WAV) installs silence. `built` is heap-owned; release() hands - // ownership to the atomic, and the exchanged pointer is re-owned by the graveyard. + // graveyard tagged with this generation (process may still be mid-block reading + // it). A null `built` (no bank / unreadable WAV) installs silence. + // `built` is heap-owned; release() hands ownership to the atomic, and the + // exchanged pointer is re-owned by the graveyard. + // + // Bounded reclaim: prune graveyard entries where displacedAt <= seen, where seen + // is the last generation process() published. process() publishes inst->installedAt + // (not a re-read of reloadGeneration_), so seen == D means process holds the + // instrument installed at gen D. An entry with displacedAt == D was displaced by + // reload D, which installed that very successor — process cannot be holding the + // displaced entry. The pruning condition is therefore <= (see header for the full + // proof). Remaining entries drain at setActive(false) / terminate() when process + // is guaranteed stopped. + const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); + graveyard_.erase( + std::remove_if(graveyard_.begin(), graveyard_.end(), + [seen](const GraveyardEntry& e) { return e.displacedAt <= seen; }), + graveyard_.end()); LoadedInstrument* prev = live_.exchange(built.release()); - if (prev) graveyard_.emplace_back(prev); + if (prev) graveyard_.push_back({gen, std::unique_ptr(prev)}); return resolvedId; } tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the - // whole block (a single atomic acquire). + // whole block (a single atomic acquire), then publish inst->installedAt so the off- + // thread graveyard pruner knows exactly which generation this block is holding. + // + // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an + // ordering race: reading reloadGeneration_ after live_ could observe a generation + // newer than the pointer we actually hold, causing the pruner to free an instrument + // process is still reading. installedAt was set on the reload path before the atomic + // exchange that made the instrument visible, so it is always <= the generation of any + // instrument that could have been loaded after our acquire above. LoadedInstrument* inst = live_.load(std::memory_order_acquire); + const std::uint64_t heldGen = inst ? inst->installedAt : 0; + processGeneration_.store(heldGen, std::memory_order_release); // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps // events at block granularity (no per-event sample-offset split) — audible timing is diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index a4bad33..c0bffbc 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -37,12 +37,19 @@ namespace reasampler::vst { // live and die together at a STABLE address — hence this is heap-allocated and neither // copyable nor movable. The audio thread only ever reads it through an atomic pointer; // it is built and destroyed off the audio thread. +// +// installedAt: the reloadGeneration_ value at which this instrument was atomically +// installed into live_. Set on the reload path before the exchange. process() publishes +// this field (not a fresh re-read of reloadGeneration_) so the published generation is +// exactly the generation of the instrument actually in hand for the block. struct LoadedInstrument { Keymap keymap; VoiceEngine engine; + std::uint64_t installedAt = 0; // reload generation at which this was installed - LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr) - : keymap(std::move(km)), engine(maxVoices, keymap, adsr) {} + LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr, + std::uint64_t gen) + : keymap(std::move(km)), engine(maxVoices, keymap, adsr), installedAt(gen) {} LoadedInstrument(const LoadedInstrument&) = delete; LoadedInstrument& operator=(const LoadedInstrument&) = delete; @@ -105,13 +112,31 @@ private: // LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is // NOT freed on the reload path: process() may still be mid-block reading it, and two // rapid reloads could otherwise free a pointer process is using. Instead it is parked - // in `graveyard_` and reclaimed only when process is GUARANTEED stopped — at - // setActive(false) / terminate(), which the host never runs concurrently with - // process. The graveyard grows by one engine per reload during a session (bounded by - // user sample switches — a few objects), a deliberate leak-until-deactivate trade for - // a lock-free, race-free audio thread. Tier 2 can add epoch-based reclaim if needed. + // in `graveyard_` tagged with the reload generation at which it was displaced. + // + // Bounded reclaim: process() publishes inst->installedAt (the generation at which the + // held instrument was installed) via processGeneration_ — a single atomic store, RT- + // safe. The reload path prunes graveyard entries where displacedAt <= seen (where seen + // is the last published processGeneration_). + // + // Safety argument: an entry with displacedAt == D was displaced by reload D, which + // simultaneously installed its successor with installedAt == D. process() publishing + // seen == D means it holds that successor (or a later one). In either case, the + // displaced entry is not the pointer process is using, so freeing it is safe. The + // pruning condition is therefore <= (not strict <): an entry displaced at exactly the + // published generation is also provably unreachable. + // + // The graveyard's upper bound is the number of reloads since process last ran + // (typically 0–1 in normal use). Remaining entries drain at setActive(false) / + // terminate(), when the host guarantees process is stopped. std::atomic live_{nullptr}; - std::vector> graveyard_; // freed only when stopped + std::atomic reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process) + std::atomic processGeneration_{0}; // generation last seen by process (written on audio thread, read off-thread) + struct GraveyardEntry { + std::uint64_t displacedAt = 0; // reloadGeneration_ value when this was displaced + std::unique_ptr instrument; + }; + std::vector graveyard_; // drained on reclaim + setActive(false) + terminate std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access // The selected sample id (instance state). Off-thread only; a small mutex guards the diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index b19db13..d808584 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -172,6 +172,16 @@ static void testSampleRowHitTestMisses() { CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1); // Zero rows -> always -1. CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1); + // At or below canvas.bottom -> always -1, even if rowCount would cover that y. + // This guards paint<->hit-test agreement: sampleRowRect does not clamp to canvas, + // so without this clip a row that extends past canvas.bottom would hit-test but + // never be drawn (or vice versa). + CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom) == -1); + // Use a large rowCount so index arithmetic would return a valid row without the + // canvas.bottom guard — proving the guard fires independently of rowCount. + const int bigRows = 1000; + CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom) == -1); + CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom + 5) == -1); } // The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 130dc05..70e2bc5 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -16,12 +16,17 @@ // average / zero-stride / empty; buildTier0Keymap single full-keyboard zone with the // root + loop + rate threaded and rate defaulting; selection state round-trip + empty id // + wrong-version / truncated -> "". +// wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave- +// stride contract across the seam (that the byte stride wav_trim reports matches the +// channel-count stride downmixToMono divides by). #include "../src/vst/sample_map.h" #include #include +#include #include +#include #include "../src/bank_book.h" #include "../src/bank_model.h" @@ -247,6 +252,104 @@ static void testSelectionStateTruncated() { CHECK(deserializeSelection({1, 0, 0}) == ""); // fewer than 4 bytes (no tag) } +// --- wav_trim -> extractFloatFrames -> downmixToMono integration --------------- +// +// Locks the interleave-stride contract at the seam between wav_trim and sample_map: +// wav_trim reports channelCount, extractFloatFrames yields interleaved samples with +// that stride, and downmixToMono divides by that same stride. If either module +// changed its understanding of the layout (e.g. extractFloatFrames started packing +// differently, or downmixToMono changed its stride divisor), this test catches it. + +static void putU16sm(std::vector& b, std::uint16_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); +} +static void putU32sm(std::vector& b, std::uint32_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); + b.push_back(static_cast((v >> 16) & 0xFF)); + b.push_back(static_cast((v >> 24) & 0xFF)); +} +static void putTagsm(std::vector& b, const char* t) { + for (int i = 0; i < 4; ++i) b.push_back(static_cast(t[i])); +} +static void putFloatsm(std::vector& b, float f) { + std::uint8_t tmp[4]; + std::memcpy(tmp, &f, 4); + for (int i = 0; i < 4; ++i) b.push_back(tmp[i]); +} + +// Build a 32-bit-float WAV byte buffer. Samples: frame f, channel c = value(f, c). +template +static std::vector buildWav(std::uint16_t channels, + std::uint32_t sampleRate, + std::size_t frames, + Fn value) { + const std::uint32_t dataBytes = + static_cast(frames * channels * 4u); + std::vector chunks; + putTagsm(chunks, "fmt "); + putU32sm(chunks, 16); + putU16sm(chunks, 3); // IEEE float + putU16sm(chunks, channels); + putU32sm(chunks, sampleRate); + putU32sm(chunks, sampleRate * channels * 4u); + putU16sm(chunks, static_cast(channels * 4)); + putU16sm(chunks, 32); + putTagsm(chunks, "data"); + putU32sm(chunks, dataBytes); + for (std::size_t f = 0; f < frames; ++f) + for (std::uint16_t c = 0; c < channels; ++c) + putFloatsm(chunks, value(f, c)); + std::vector wav; + putTagsm(wav, "RIFF"); + putU32sm(wav, static_cast(4 + chunks.size())); + putTagsm(wav, "WAVE"); + wav.insert(wav.end(), chunks.begin(), chunks.end()); + return wav; +} + +static void testWavTrimToDownmixPipelineStereo() { + // Stereo WAV: frame f, L = f * 0.1f, R = f * 0.1f + 0.5f. Expected mono average: + // (f * 0.1f + f * 0.1f + 0.5f) / 2 = f * 0.1f + 0.25f. + const std::size_t kFrames = 4; + auto wav = buildWav(2, 48000, kFrames, + [](std::size_t f, std::uint16_t c) { + return static_cast(f) * 0.1f + (c == 1 ? 0.5f : 0.0f); + }); + WavLayout layout = parseWavLayout(wav); + CHECK(layout.valid); + CHECK(layout.channelCount == 2); + CHECK(layout.frameCount() == kFrames); + const std::vector interleaved = + extractFloatFrames(wav, layout, 0, layout.frameCount()); + CHECK(interleaved.size() == kFrames * 2); + const std::vector mono = downmixToMono(interleaved, layout.channelCount); + CHECK(mono.size() == kFrames); + for (std::size_t f = 0; f < kFrames; ++f) { + const float expected = static_cast(f) * 0.1f + 0.25f; + CHECK(approx(mono[f], expected)); + } +} + +static void testWavTrimToDownmixPipelineMono() { + // Mono WAV: extractFloatFrames -> downmixToMono with channelCount==1 is a passthrough. + const std::size_t kFrames = 3; + auto wav = buildWav(1, 44100, kFrames, + [](std::size_t f, std::uint16_t) { + return static_cast(f) * 0.5f; + }); + WavLayout layout = parseWavLayout(wav); + CHECK(layout.valid); + CHECK(layout.channelCount == 1); + const std::vector interleaved = + extractFloatFrames(wav, layout, 0, layout.frameCount()); + CHECK(interleaved.size() == kFrames); + const std::vector mono = downmixToMono(interleaved, layout.channelCount); + CHECK(mono.size() == kFrames); + CHECK(approx(mono[0], 0.0) && approx(mono[1], 0.5) && approx(mono[2], 1.0)); +} + int main() { testSelectByIdHit(); testSelectFirstSampleFallbackOnEmptyId(); @@ -269,6 +372,8 @@ int main() { testSelectionStateEmptyId(); testSelectionStateWrongVersion(); testSelectionStateTruncated(); + testWavTrimToDownmixPipelineStereo(); + testWavTrimToDownmixPipelineMono(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0;