Merge ps-w3-tier0: S4 Tier 0 — the bank plays (MIDI to core, live-state seam, LICE sample-pick)

This commit is contained in:
2026-07-26 17:05:07 -04:00
24 changed files with 1411 additions and 302 deletions
+27 -1
View File
@@ -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})
+15 -8
View File
@@ -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
+10
View File
@@ -5,6 +5,7 @@
#include <cstdint>
#include <cstdio>
#include <cstring> // std::memcmp
#include <filesystem>
#include <vector>
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;
+8
View File
@@ -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
+38
View File
@@ -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
+4 -4
View File
@@ -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
+38 -58
View File
@@ -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
-62
View File
@@ -13,66 +13,4 @@ std::optional<std::string> decodeGetProjExtState(int apiReturn,
return buffer;
}
std::optional<std::string> 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
+12 -22
View File
@@ -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<std::string> 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<std::string> extractJsonStringField(const std::string& json,
const std::string& key);
} // namespace reasampler::vst
+22
View File
@@ -53,4 +53,26 @@ 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;
// 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 bottom is outside.
const Rect r = sampleRowRect(layout, index);
if (y >= r.bottom) return -1;
return index;
}
} // namespace reasampler::vst
+19
View File
@@ -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
+39 -17
View File
@@ -5,6 +5,8 @@
#include <vector>
#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<EnumProjExtStateFn>(
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<EnumProjectsFn>(
reaper->getReaperApi("EnumProjects"));
return getProjExtState_ != nullptr;
}
@@ -74,15 +76,35 @@ std::optional<std::string> 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<char> buf(64 * 1024, '\0');
const int rv = getProjExtState_(proj, kReasamplerNamespace, key.c_str(), buf.data(),
static_cast<int>(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<char> buf(static_cast<std::size_t>(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<int>(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<char> buf(4096, '\0');
enumProjects_(-1, buf.data(), static_cast<int>(buf.size()));
return projectDirOfRpp(std::string(buf.data()));
}
} // namespace reasampler::vst
+16
View File
@@ -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<std::string> 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
+51 -22
View File
@@ -7,7 +7,10 @@
#include <string>
#include "editor_geometry.h"
#include "ext_keys.h"
#include "reaper_bridge.h"
#include "reasampler_processor.h"
#include "sample_map.h"
#ifdef _WIN32
#include <windowsx.h> // 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<SampleChoice>{};
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<int>(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<int>(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,
+21 -9
View File
@@ -13,21 +13,27 @@
#pragma once
#include <string>
#include <vector>
#include "public.sdk/source/common/pluginview.h"
#include "sample_map.h" // SampleChoice (the list the editor draws)
#ifdef _WIN32
#include <windows.h>
#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<SampleChoice> samples_;
// The currently-selected sample id, mirrored for the paint's highlight.
std::string selectedId_;
};
} // namespace reasampler::vst
+254 -19
View File
@@ -2,16 +2,65 @@
#include "reasampler_processor.h"
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <vector>
#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<std::int64_t>(kAttackSeconds * sr);
p.decayFrames = static_cast<std::int64_t>(kDecaySeconds * sr);
p.sustainLevel = kSustainLevel;
p.releaseFrames = static_cast<std::int64_t>(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<std::uint8_t> readFileBytes(const std::string& path) {
std::vector<std::uint8_t> 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<std::size_t>(size));
if (!f.read(reinterpret_cast<char*>(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 +72,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 +84,232 @@ 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<std::mutex> 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<std::mutex> 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<std::uint8_t> 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);
}
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<std::uint8_t> bytes = serializeSelection(selectedSampleId());
if (!bytes.empty()) {
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
static_cast<int32>(bytes.size()), nullptr);
if (wr != kResultOk) return wr;
}
return kResultOk;
}
std::string ReaSamplerProcessor::selectedSampleId() {
std::lock_guard<std::mutex> lock(selectionMutex_);
return selectedSampleId_;
}
void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
std::lock_guard<std::mutex> 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<std::mutex> 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<std::string> banksJson =
bridge_.readReasamplerExtState(kProjExtBanksKey);
const std::string projectDir = bridge_.activeProjectDir();
std::string resolvedId;
std::unique_ptr<LoadedInstrument> built;
if (banksJson) {
// 2. Pick the sample (shared bank_book JSON parse — NOT a second parser).
std::optional<SelectedSample> 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<std::uint8_t> bytes = readFileBytes(abs);
const WavLayout layout = parseWavLayout(bytes);
if (layout.valid) {
const std::size_t frames = layout.frameCount();
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, frames);
std::vector<AudioSample> mono =
downmixToMono(interleaved, layout.channelCount);
if (!mono.empty()) {
Keymap km = buildTier0Keymap(
std::move(mono),
static_cast<int>(layout.sampleRate), sel->rootNote,
sel->loop);
built = std::make_unique<LoadedInstrument>(
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();
}
}
}
}
// 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 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_.push_back({gen, std::unique_ptr<LoadedInstrument>(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), 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
// 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<int>(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<std::size_t>(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;
}
+113 -8
View File
@@ -1,21 +1,60 @@
// 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 <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#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.
//
// 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,
std::uint64_t gen)
: keymap(std::move(km)), engine(maxVoices, keymap, adsr), installedAt(gen) {}
LoadedInstrument(const LoadedInstrument&) = delete;
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
};
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
public:
ReaSamplerProcessor() = default;
@@ -30,10 +69,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 +86,68 @@ 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_` 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 01 in normal use). Remaining entries drain at setActive(false) /
// terminate(), when the host guarantees process is stopped.
std::atomic<LoadedInstrument*> live_{nullptr};
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
std::atomic<std::uint64_t> 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<LoadedInstrument> instrument;
};
std::vector<GraveyardEntry> 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
// 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
+129
View File
@@ -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 <cstring> // 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<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) {
if (banksJson.empty()) return std::nullopt;
std::optional<BankBook> 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<SampleChoice> listSamples(const std::string& banksJson) {
std::vector<SampleChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> 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<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
const double inv = 1.0 / static_cast<double>(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<double>(interleaved[base + c]);
}
out[f] = static_cast<AudioSample>(acc * inv);
}
return out;
}
Keymap buildTier0Keymap(std::vector<AudioSample> 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<std::uint8_t> serializeSelection(const std::string& sampleId) {
std::vector<std::uint8_t> out;
out.resize(4 + sampleId.size());
const std::uint32_t v = kSelectionStateVersion;
out[0] = static_cast<std::uint8_t>(v & 0xFF);
out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
return out;
}
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
if (bytes.size() < 4) return {}; // no version tag -> no selection
const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
(static_cast<std::uint32_t>(bytes[1]) << 8) |
(static_cast<std::uint32_t>(bytes[2]) << 16) |
(static_cast<std::uint32_t>(bytes[3]) << 24);
if (v != kSelectionStateVersion) return {}; // unknown version -> ignore
return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
bytes.size() - 4);
}
} // namespace reasampler
+110
View File
@@ -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 <cstdint>
#include <optional>
#include <string>
#include <vector>
#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<SelectedSample> 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<SampleChoice> 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<AudioSample> downmixToMono(const std::vector<AudioSample>& 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<AudioSample> 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<std::uint8_t> 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<std::uint8_t>& bytes);
} // namespace reasampler
+15 -4
View File
@@ -289,18 +289,29 @@ void VoiceEngine::noteOff(int note) {
if (target != kNoVoice) voices_[target].release();
}
void VoiceEngine::render(std::vector<AudioSample>& 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<AudioSample>& 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_) {
+14 -3
View File
@@ -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<AudioSample>& out, std::size_t frameCount);
// Count of currently active voices (for tests / diagnostics).
+5 -65
View File
@@ -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;
+71
View File
@@ -131,6 +131,73 @@ 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);
// 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
// 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 +208,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;
+380
View File
@@ -0,0 +1,380 @@
// 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 -> "".
// 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 <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#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<int> 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<Sample>& poolSamples,
const std::vector<Sample>& 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<SampleChoice> 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<AudioSample> in{0.1f, -0.2f, 0.3f};
const std::vector<AudioSample> 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<AudioSample> in{1.0f, 0.0f, 0.4f, 0.6f};
const std::vector<AudioSample> 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<AudioSample> in{0.3f, 0.3f, 0.6f};
const std::vector<AudioSample> 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<std::uint8_t> 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<std::uint8_t> bytes = serializeSelection("");
CHECK(bytes.size() == 4); // just the version tag
CHECK(deserializeSelection(bytes) == "");
}
static void testSelectionStateWrongVersion() {
std::vector<std::uint8_t> 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)
}
// --- 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<std::uint8_t>& b, std::uint16_t v) {
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
}
static void putU32sm(std::vector<std::uint8_t>& b, std::uint32_t v) {
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
static void putTagsm(std::vector<std::uint8_t>& b, const char* t) {
for (int i = 0; i < 4; ++i) b.push_back(static_cast<std::uint8_t>(t[i]));
}
static void putFloatsm(std::vector<std::uint8_t>& 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 <typename Fn>
static std::vector<std::uint8_t> buildWav(std::uint16_t channels,
std::uint32_t sampleRate,
std::size_t frames,
Fn value) {
const std::uint32_t dataBytes =
static_cast<std::uint32_t>(frames * channels * 4u);
std::vector<std::uint8_t> 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<std::uint16_t>(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<std::uint8_t> wav;
putTagsm(wav, "RIFF");
putU32sm(wav, static_cast<std::uint32_t>(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<float>(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<AudioSample> interleaved =
extractFloatFrames(wav, layout, 0, layout.frameCount());
CHECK(interleaved.size() == kFrames * 2);
const std::vector<AudioSample> mono = downmixToMono(interleaved, layout.channelCount);
CHECK(mono.size() == kFrames);
for (std::size_t f = 0; f < kFrames; ++f) {
const float expected = static_cast<float>(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<float>(f) * 0.5f;
});
WavLayout layout = parseWavLayout(wav);
CHECK(layout.valid);
CHECK(layout.channelCount == 1);
const std::vector<AudioSample> interleaved =
extractFloatFrames(wav, layout, 0, layout.frameCount());
CHECK(interleaved.size() == kFrames);
const std::vector<AudioSample> 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();
testSelectFirstSampleFallbackOnUnknownId();
testSelectRootNoteDefault();
testSelectLoopThreaded();
testSelectNoLoopIsAbsent();
testSelectEmptyBlob();
testSelectMalformedBlob();
testSelectZeroSamples();
testListSamplesOrdinalOrder();
testListSamplesEmptyAndMalformed();
testDownmixMonoPassthrough();
testDownmixStereoAverages();
testDownmixThreeChannelAverages();
testDownmixDegenerate();
testBuildKeymapSingleFullZone();
testBuildKeymapRateDefault();
testSelectionStateRoundTrip();
testSelectionStateEmptyId();
testSelectionStateWrongVersion();
testSelectionStateTruncated();
testWavTrimToDownmixPipelineStereo();
testWavTrimToDownmixPipelineMono();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;
}