S4 Tier 0: the bank plays — VST3 marshals MIDI to the S3 core, reads the live bank + resolves WAV the M4 way, mono downmix, lock-free load handoff, LICE sample-pick
This commit is contained in:
+27
-1
@@ -621,6 +621,18 @@ target_include_directories(editor_geometry PUBLIC src/vst)
|
|||||||
add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp)
|
add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp)
|
||||||
target_include_directories(bridge_marshal PUBLIC src/vst)
|
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)
|
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
|
||||||
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
|
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
|
||||||
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
|
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)
|
target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal)
|
||||||
add_test(NAME bridge_marshal_tests COMMAND bridge_marshal_tests)
|
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).
|
# 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
|
${VST3_SDK}/public.sdk/source/main/moduleinit.cpp
|
||||||
${LICE_SRC}
|
${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;
|
# 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.
|
# 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})
|
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
|
|||||||
@@ -193,15 +193,22 @@ following the active project works; it never captures and never inserts into the
|
|||||||
arrange (read-only over the bank).
|
arrange (read-only over the bank).
|
||||||
**Depends on:** S1, S2, S3.
|
**Depends on:** S1, S2, S3.
|
||||||
|
|
||||||
- [ ] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus,
|
- [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.
|
drive the S3 core, write per-voice audio to the output bus. (Block-granular event
|
||||||
- [ ] Live-state seam: read the bank index + selected sample's root note from
|
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
|
`"reasampler"` ext-state via the bridge; resolve the WAV audio path the M4
|
||||||
project-relative way (shared convention with `persist`, not re-implemented).
|
project-relative way (shared convention with `persist`, not re-implemented — the
|
||||||
- [ ] Sample selection UI (minimal, in the `IPlugView` LICE editor or a
|
parent-of-.rpp derivation is extracted to `capture_paths::projectDirOfRpp`, which both
|
||||||
parameters-only default view): choose which bank sample this instance plays.
|
`persist` and the bridge call). Bank JSON parsed via the shared `bank_book` path (the
|
||||||
- [ ] Tier-0 playback: chromatic-from-root, basic polyphony, amp envelope,
|
spike string-scan reader retired); ext-state key names shared via pure `ext_keys.h`.
|
||||||
velocity→volume — plays in REAPER's routing/record/render path like any VSTi.
|
- [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)
|
## S5 — Tier 1: "a keymap" (zoned multisamples, per-sample root notes)
|
||||||
**Goal:** Multiple bank samples zoned across the keyboard (key ranges), each with its
|
**Goal:** Multiple bank samples zoned across the keyboard (key ranges), each with its
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring> // std::memcmp
|
#include <cstring> // std::memcmp
|
||||||
|
#include <filesystem>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
@@ -213,6 +214,15 @@ std::string resolveBankFile(const std::string& projectDir,
|
|||||||
return dir + "/" + rel;
|
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,
|
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
||||||
const std::string& newProjectDir) {
|
const std::string& newProjectDir) {
|
||||||
BankRelocation r;
|
BankRelocation r;
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ std::string bankRelativeForName(const std::string& fileName);
|
|||||||
std::string resolveBankFile(const std::string& projectDir,
|
std::string resolveBankFile(const std::string& projectDir,
|
||||||
const std::string& relativePath);
|
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
|
// 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
|
// 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
|
// project dir, which is what moved with the .rpp), so relocation is purely a
|
||||||
|
|||||||
@@ -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
@@ -128,11 +128,11 @@ void* readActiveProject(std::string& rppPathOut) {
|
|||||||
// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
|
// 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
|
// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the
|
||||||
// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp
|
// .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) {
|
std::string projectDirOf(const std::string& rppPath) {
|
||||||
if (rppPath.empty()) return {};
|
return projectDirOfRpp(rppPath);
|
||||||
std::string dir = fs::path(rppPath).parent_path().string();
|
|
||||||
return normalizeSlashes(dir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProjExtState needs a caller-supplied buffer; the index JSON can be large
|
// GetProjExtState needs a caller-supplied buffer; the index JSON can be large
|
||||||
|
|||||||
+38
-58
@@ -22,6 +22,7 @@
|
|||||||
#include "app_version.h"
|
#include "app_version.h"
|
||||||
#include "bank_book.h"
|
#include "bank_book.h"
|
||||||
#include "bank_model.h"
|
#include "bank_model.h"
|
||||||
|
#include "ext_keys.h"
|
||||||
#include "owned_manifest.h"
|
#include "owned_manifest.h"
|
||||||
#include "prune_reconcile.h"
|
#include "prune_reconcile.h"
|
||||||
#include "tail_control.h"
|
#include "tail_control.h"
|
||||||
@@ -29,71 +30,50 @@
|
|||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
// The ext-state namespace every ReaSampler key is stored under. CHANNEL-DERIVED (Phase V,
|
// The ext-state namespace + the WIRE-SHARED key names are the contract between this
|
||||||
// V4): the pure app_version module owns the one channel-qualified string — "reasampler" on
|
// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h
|
||||||
// stable (byte-identical to the pre-V4 build) or "reasampler_beta" on the isolated beta
|
// (pure, REAPER-free) and are included above — not duplicated here. The namespace is
|
||||||
// build. FOREVER-STABLE per channel once shipped: changing either orphans every already-
|
// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace()
|
||||||
// saved project's state. Beta reads/writes ONLY its own namespace — a project saved by
|
// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte-
|
||||||
// stable shows empty/default state in beta and vice versa; that isolation is the accepted
|
// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both
|
||||||
// V4 safety property (no cross-namespace read, migration, or fallback), not a bug.
|
// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace
|
||||||
// Returns const char* (not a constexpr literal) because the string is channel-derived at
|
// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project
|
||||||
// build time; the accessor is the single call point for all persist reads/writes below.
|
// 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(); }
|
inline const char* projExtNamespace() { return extStateNamespace().c_str(); }
|
||||||
|
|
||||||
// The RETIRED legacy ext-state key: pre-multi-bank projects stored the whole
|
// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument
|
||||||
// serialized BankIndex here (single bank). Phase B2 no longer WRITES it — on save
|
// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h:
|
||||||
// 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
|
// owned_files — the owned-file manifest JSON (project-relative files the capture path
|
||||||
// project, to migrate its single index into the pool (BankBook's parse-time
|
// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's
|
||||||
// promotion). FOREVER-STABLE as a read key for that migration path.
|
// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT
|
||||||
inline constexpr const char* kProjExtIndexKey = "bank_index";
|
// 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 —
|
||||||
// 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 —
|
|
||||||
// graceful, but the attribution safety net is lost until the next capture rebuilds it).
|
// graceful, but the attribution safety net is lost until the next capture rebuilds it).
|
||||||
inline constexpr const char* kProjExtOwnedKey = "owned_files";
|
inline constexpr const char* kProjExtOwnedKey = "owned_files";
|
||||||
|
|
||||||
// The ext-state key holding the ReaSampler version that last WROTE this project
|
// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on
|
||||||
// (Phase V, V1). Written on every save alongside the banks/view/tail keys, so every
|
// every save, so every saved .rpp records which build produced its state — the seam a
|
||||||
// saved .rpp records which build produced its state — the seam a future within-channel
|
// future within-channel forward migration keys off. An absent key is the explicit
|
||||||
// forward migration keys off ("this was written by 0.9.01, I am 0.9.05"). An absent
|
// pre-versioning case, read silently, never an error. FOREVER-STABLE key string.
|
||||||
// key is the explicit pre-versioning case (a project saved before this shipped), read
|
|
||||||
// silently, never an error. FOREVER-STABLE key string once shipped.
|
|
||||||
inline constexpr const char* kProjExtVersionKey = "version";
|
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
|
// 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
|
// against the active REAPER project. One instance lives for the extension's
|
||||||
// lifetime (main.cpp). It tracks
|
// lifetime (main.cpp). It tracks
|
||||||
|
|||||||
@@ -13,66 +13,4 @@ std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
|||||||
return buffer;
|
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
|
} // namespace reasampler::vst
|
||||||
|
|||||||
+12
-22
@@ -1,18 +1,21 @@
|
|||||||
// bridge_marshal.h — PURE marshalling helpers for the REAPER VST-host bridge read
|
// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read.
|
||||||
// (Phase S1). NO VST3, NO REAPER types at the boundary.
|
// NO VST3, NO REAPER types at the boundary.
|
||||||
//
|
//
|
||||||
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
|
// 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
|
// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around
|
||||||
// calls — interpreting GetProjExtState's int return, walking EnumProjExtState's
|
// GetProjExtState — interpreting its int return against the buffer it filled — is pure
|
||||||
// index-until-false contract into a key set, and extracting a single value out of the
|
// and unit-tested here. Mirror of capture_paths / wav_trim splitting the arithmetic out
|
||||||
// "reasampler" bank JSON — are pure and unit-tested here. Mirror of capture_paths /
|
// of a REAPER-facing shell.
|
||||||
// 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:
|
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||||
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
|
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
|
||||||
// -- returns the length written (0 when the key is absent).
|
// -- 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
|
#pragma once
|
||||||
|
|
||||||
@@ -31,17 +34,4 @@ namespace reasampler::vst {
|
|||||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||||
const std::string& buffer);
|
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
|
} // namespace reasampler::vst
|
||||||
|
|||||||
@@ -53,4 +53,23 @@ HitTarget hitTest(const EditorLayout& layout, int x, int y) {
|
|||||||
return HitTarget::kNone;
|
return HitTarget::kNone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rect sampleRowRect(const EditorLayout& layout, int index) {
|
||||||
|
if (index < 0) return Rect{};
|
||||||
|
const int top = layout.canvas.top + index * kSampleRowHeight;
|
||||||
|
return Rect{layout.canvas.left, top, layout.canvas.right, top + kSampleRowHeight};
|
||||||
|
}
|
||||||
|
|
||||||
|
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
||||||
|
if (rowCount <= 0) return -1;
|
||||||
|
// Must be within the canvas horizontally and at/below its top.
|
||||||
|
if (x < layout.canvas.left || x >= layout.canvas.right) return -1;
|
||||||
|
if (y < layout.canvas.top) return -1;
|
||||||
|
const int index = (y - layout.canvas.top) / kSampleRowHeight;
|
||||||
|
if (index < 0 || index >= rowCount) return -1;
|
||||||
|
// Guard the bottom edge: a click below the last row's canvas bottom is outside.
|
||||||
|
const Rect r = sampleRowRect(layout, index);
|
||||||
|
if (y >= r.bottom) return -1;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler::vst
|
} // namespace reasampler::vst
|
||||||
|
|||||||
@@ -56,4 +56,23 @@ enum class HitTarget {
|
|||||||
// is kNone in the spike.
|
// is kNone in the spike.
|
||||||
HitTarget hitTest(const EditorLayout& layout, int x, int y);
|
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
|
} // namespace reasampler::vst
|
||||||
|
|||||||
+39
-17
@@ -5,6 +5,8 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "bridge_marshal.h"
|
#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
|
// The VST3 base types must be included before REAPER's VST3 interface header, which
|
||||||
// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from
|
// 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.
|
// interface (FUnknownPtr uses the iid), so the definition lives with its sole use.
|
||||||
DEF_CLASS_IID(Steinberg::IReaperHostApplication)
|
DEF_CLASS_IID(Steinberg::IReaperHostApplication)
|
||||||
|
|
||||||
// The "reasampler" ext-state namespace + bank key. Kept in sync with persist.h by
|
// The "reasampler" ext-state namespace is the SHARED wire contract between the
|
||||||
// value (the extension writes them); we only READ here, so we duplicate the two string
|
// extension (writer) and this instrument (reader); it lives in ext_keys.h (pure,
|
||||||
// constants rather than pull the whole REAPER-facing persist.h into the VST artifact.
|
// REAPER-free) — reasampler::kProjExtNamespace — so the two artifacts read one symbol
|
||||||
// If persist.h's kProjExtNamespace / kProjExtBanksKey ever change, these must follow —
|
// and cannot drift. The S1 spike duplicated it locally; that duplication is retired.
|
||||||
// they are the shared wire contract between the extension (writer) and instrument
|
|
||||||
// (reader). VERIFY against persist.h.
|
|
||||||
namespace {
|
|
||||||
constexpr const char* kReasamplerNamespace = "reasampler";
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace reasampler::vst {
|
namespace reasampler::vst {
|
||||||
|
|
||||||
bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
||||||
getProjExtState_ = nullptr;
|
getProjExtState_ = nullptr;
|
||||||
enumProjExtState_ = nullptr;
|
enumProjExtState_ = nullptr;
|
||||||
|
enumProjects_ = nullptr;
|
||||||
hostApp_ = nullptr;
|
hostApp_ = nullptr;
|
||||||
if (!context) return false;
|
if (!context) return false;
|
||||||
|
|
||||||
@@ -58,6 +56,10 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
|||||||
reaper->getReaperApi("GetProjExtState"));
|
reaper->getReaperApi("GetProjExtState"));
|
||||||
enumProjExtState_ = reinterpret_cast<EnumProjExtStateFn>(
|
enumProjExtState_ = reinterpret_cast<EnumProjExtStateFn>(
|
||||||
reaper->getReaperApi("EnumProjExtState"));
|
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;
|
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
|
// 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.
|
// 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
|
// GetProjExtState writes into a caller buffer; the bank blob can be large (many
|
||||||
// and let the pure decoder interpret the result. The buffer is NUL-terminated by
|
// samples), so grow the buffer until the value fits rather than risk a silent
|
||||||
// REAPER on success.
|
// truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The
|
||||||
std::vector<char> buf(64 * 1024, '\0');
|
// return value is the value length; if it fits strictly inside the buffer it is
|
||||||
const int rv = getProjExtState_(proj, kReasamplerNamespace, key.c_str(), buf.data(),
|
// complete, else grow and retry up to a 16 MB ceiling.
|
||||||
static_cast<int>(buf.size()));
|
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).
|
std::string ReaperBridge::activeProjectDir() {
|
||||||
return decodeGetProjExtState(rv, std::string(buf.data()));
|
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
|
} // namespace reasampler::vst
|
||||||
|
|||||||
@@ -44,8 +44,19 @@ public:
|
|||||||
// Read a "reasampler" ext-state value by key from the host's active project.
|
// 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
|
// 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.
|
// 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);
|
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:
|
private:
|
||||||
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
|
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
|
||||||
// verified against reaper_plugin_functions.h.
|
// verified against reaper_plugin_functions.h.
|
||||||
@@ -54,10 +65,15 @@ private:
|
|||||||
using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx,
|
using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx,
|
||||||
char* keyOut, int keyOut_sz, char* valOut,
|
char* keyOut, int keyOut_sz, char* valOut,
|
||||||
int valOut_sz);
|
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)
|
void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp)
|
||||||
GetProjExtStateFn getProjExtState_ = nullptr;
|
GetProjExtStateFn getProjExtState_ = nullptr;
|
||||||
EnumProjExtStateFn enumProjExtState_ = nullptr;
|
EnumProjExtStateFn enumProjExtState_ = nullptr;
|
||||||
|
EnumProjectsFn enumProjects_ = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace reasampler::vst
|
} // namespace reasampler::vst
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "editor_geometry.h"
|
#include "editor_geometry.h"
|
||||||
|
#include "ext_keys.h"
|
||||||
#include "reaper_bridge.h"
|
#include "reaper_bridge.h"
|
||||||
|
#include "reasampler_processor.h"
|
||||||
|
#include "sample_map.h"
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
#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
|
#endif
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
ReaSamplerEditor::ReaSamplerEditor(ReaperBridge* bridge)
|
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||||||
: CPluginView(nullptr), bridge_(bridge) {
|
: CPluginView(nullptr), processor_(processor) {
|
||||||
// Default view size; the host may resize (canResize() == true).
|
// Default view size; the host may resize (canResize() == true).
|
||||||
ViewRect r(0, 0, 420, 260);
|
ViewRect r(0, 0, 420, 260);
|
||||||
setRect(r);
|
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() {
|
ReaSamplerEditor::~ReaSamplerEditor() {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
// Defensive teardown: the host normally calls removed() (which destroys the child)
|
// Defensive teardown: the host normally calls removed() (which destroys the child)
|
||||||
@@ -102,6 +117,9 @@ void ReaSamplerEditor::attachedToParent() {
|
|||||||
classRegistered = true;
|
classRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot the live bank so the first paint shows the sample list.
|
||||||
|
refreshSampleList();
|
||||||
|
|
||||||
const ViewRect& r = getRect();
|
const ViewRect& r = getRect();
|
||||||
childHwnd_ = CreateWindowExW(
|
childHwnd_ = CreateWindowExW(
|
||||||
0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(),
|
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);
|
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,
|
LICE_FillRect(&bmp, layout.titleBar.left, layout.titleBar.top,
|
||||||
layout.titleBar.width(), layout.titleBar.height(), kColTitleBg, 1.0f,
|
layout.titleBar.width(), layout.titleBar.height(), kColTitleBg, 1.0f,
|
||||||
0);
|
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";
|
std::string title = "ReaSampler Instrument";
|
||||||
if (bridge_ && bridge_->isConnected()) {
|
if (processor_ && processor_->bridge().isConnected()) {
|
||||||
auto banks = bridge_->readReasamplerExtState("banks");
|
title += samples_.empty() ? " [bank: empty]" : " [pick a sample]";
|
||||||
title += banks ? " [bank: linked]" : " [bank: none]";
|
|
||||||
} else {
|
} else {
|
||||||
title += " [host: no bridge]";
|
title += " [host: no bridge]";
|
||||||
}
|
}
|
||||||
@@ -162,27 +176,42 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
|||||||
layout.titleBar.right - 8, layout.titleBar.bottom};
|
layout.titleBar.right - 8, layout.titleBar.bottom};
|
||||||
drawText(&bmp, titleText, title.c_str(), kRgbText);
|
drawText(&bmp, titleText, title.c_str(), kRgbText);
|
||||||
|
|
||||||
// The clickable button — fill reflects the last hit-test (the routing proof).
|
// Sample list: one row per bank sample, the selected one highlighted. Clip drawing
|
||||||
LICE_FillRect(&bmp, layout.button.left, layout.button.top, layout.button.width(),
|
// to rows that fall within the canvas (sampleRowRect computes all; we skip off-screen
|
||||||
layout.button.height(), buttonHit_ ? kColBtnHitBg : kColBtnBg, 1.0f,
|
// ones so a huge bank doesn't waste paint).
|
||||||
0);
|
for (int i = 0; i < static_cast<int>(samples_.size()); ++i) {
|
||||||
LICE_DrawRect(&bmp, layout.button.left, layout.button.top, layout.button.width(),
|
const Rect row = sampleRowRect(layout, i);
|
||||||
layout.button.height(), kColBtnBorder, 1.0f, 0);
|
if (row.top >= layout.canvas.bottom) break; // past the visible area
|
||||||
Rect btnText{layout.button.left + 8, layout.button.top, layout.button.right,
|
const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_;
|
||||||
layout.button.bottom};
|
LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(),
|
||||||
drawText(&bmp, btnText, buttonHit_ ? "clicked" : "click me", kRgbText);
|
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);
|
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::onClick(int x, int y) {
|
void ReaSamplerEditor::onClick(int x, int y) {
|
||||||
|
if (!processor_) return;
|
||||||
RECT cr{};
|
RECT cr{};
|
||||||
GetClientRect(childHwnd_, &cr);
|
GetClientRect(childHwnd_, &cr);
|
||||||
const EditorLayout layout = layoutEditor(cr.right - cr.left, cr.bottom - cr.top);
|
const EditorLayout layout = layoutEditor(cr.right - cr.left, cr.bottom - cr.top);
|
||||||
if (hitTest(layout, x, y) == HitTarget::kButton) {
|
const int row = sampleRowHitTest(layout, static_cast<int>(samples_.size()), x, y);
|
||||||
buttonHit_ = !buttonHit_;
|
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);
|
InvalidateRect(childHwnd_, nullptr, FALSE);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||||
|
|||||||
@@ -13,21 +13,27 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "public.sdk/source/common/pluginview.h"
|
#include "public.sdk/source/common/pluginview.h"
|
||||||
|
|
||||||
|
#include "sample_map.h" // SampleChoice (the list the editor draws)
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
namespace reasampler::vst {
|
namespace reasampler::vst {
|
||||||
|
|
||||||
class ReaperBridge;
|
class ReaSamplerProcessor;
|
||||||
|
|
||||||
class ReaSamplerEditor : public Steinberg::CPluginView {
|
class ReaSamplerEditor : public Steinberg::CPluginView {
|
||||||
public:
|
public:
|
||||||
// `bridge` is owned by the processor and outlives the editor; the editor reads
|
// `processor` owns this editor's lifetime domain and outlives it; the editor reads
|
||||||
// (never mutates) it to show a live-state readout. May be null (non-REAPER host).
|
// the live bank through it (the sample list) and drives selection + reload when the
|
||||||
explicit ReaSamplerEditor(ReaperBridge* bridge);
|
// user clicks a row. May be null (defensive — a real host always supplies one).
|
||||||
|
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||||
~ReaSamplerEditor() override;
|
~ReaSamplerEditor() override;
|
||||||
|
|
||||||
// Accept only the Windows HWND platform type (D5: Windows-only).
|
// Accept only the Windows HWND platform type (D5: Windows-only).
|
||||||
@@ -48,7 +54,7 @@ private:
|
|||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
// Draw the current surface into the child window's DC via a LICE bitmap.
|
// Draw the current surface into the child window's DC via a LICE bitmap.
|
||||||
void paint(HDC hdc);
|
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);
|
void onClick(int x, int y);
|
||||||
|
|
||||||
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||||
@@ -56,10 +62,16 @@ private:
|
|||||||
HWND childHwnd_ = nullptr;
|
HWND childHwnd_ = nullptr;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
ReaperBridge* bridge_ = nullptr;
|
// Re-read the bank's sample list from the live bridge into `samples_`. Main/UI
|
||||||
// Latched on click so the paint reflects the last hit-test result — the spike's
|
// thread only (reads ext-state); called on attach and after a selection reload.
|
||||||
// proof that host->click->draw routing round-trips.
|
void refreshSampleList();
|
||||||
bool buttonHit_ = false;
|
|
||||||
|
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
|
} // namespace reasampler::vst
|
||||||
|
|||||||
@@ -2,16 +2,64 @@
|
|||||||
|
|
||||||
#include "reasampler_processor.h"
|
#include "reasampler_processor.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "pluginterfaces/base/ibstream.h"
|
||||||
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||||
|
#include "pluginterfaces/vst/ivstevents.h"
|
||||||
#include "pluginterfaces/vst/vstspeaker.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 "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;
|
||||||
using namespace Steinberg::Vst;
|
using namespace Steinberg::Vst;
|
||||||
|
|
||||||
namespace reasampler::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*/) {
|
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
||||||
// The host owns the returned reference. Cast up to the combined interface the SDK
|
// The host owns the returned reference. Cast up to the combined interface the SDK
|
||||||
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
|
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
|
||||||
@@ -23,7 +71,7 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
|||||||
if (result != kResultOk) return result;
|
if (result != kResultOk) return result;
|
||||||
|
|
||||||
// Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the
|
// 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);
|
bridge_.connect(context);
|
||||||
|
|
||||||
// Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio
|
// Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio
|
||||||
@@ -35,46 +83,204 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
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();
|
return SingleComponentEffect::terminate();
|
||||||
}
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool /*state*/) {
|
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
||||||
// Nothing to allocate/free in the silent skeleton; S4 will size voice buffers here
|
// Activating: build the instrument from the currently-selected sample so the first
|
||||||
// against the setupProcessing block size.
|
// 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;
|
return kResultOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
||||||
|
sampleRate_ = setup.sampleRate;
|
||||||
|
maxBlockSize_ = setup.maxSamplesPerBlock;
|
||||||
return SingleComponentEffect::setupProcessing(setup);
|
return SingleComponentEffect::setupProcessing(setup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (got < static_cast<int32>(sizeof(chunk))) break;
|
||||||
|
}
|
||||||
|
setSelectedSampleId(deserializeSelection(bytes));
|
||||||
|
// Rebuild from the restored selection (off-thread — setState is a load-time call).
|
||||||
|
reloadFromBank();
|
||||||
|
return kResultOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||||
|
if (!state) return kResultFalse;
|
||||||
|
const std::vector<std::uint8_t> bytes = serializeSelection(selectedSampleId());
|
||||||
|
if (!bytes.empty()) {
|
||||||
|
state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||||
|
static_cast<int32>(bytes.size()), nullptr);
|
||||||
|
}
|
||||||
|
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_);
|
||||||
|
|
||||||
|
// 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_));
|
||||||
|
// Record which id actually resolved so a first-sample fallback
|
||||||
|
// (empty stored id) becomes the concrete selection.
|
||||||
|
resolvedId = selectedSampleId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the
|
||||||
|
// graveyard (process may still be reading it this block — it is reclaimed only when
|
||||||
|
// process is stopped, in setActive(false)/terminate). A null `built` (no bank /
|
||||||
|
// unreadable WAV) installs silence. `built` is heap-owned; release() hands
|
||||||
|
// ownership to the atomic, and the exchanged pointer is re-owned by the graveyard.
|
||||||
|
LoadedInstrument* prev = live_.exchange(built.release());
|
||||||
|
if (prev) graveyard_.emplace_back(prev);
|
||||||
|
return resolvedId;
|
||||||
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||||
// Silent skeleton: emit silence on the output bus so the instrument runs cleanly in
|
// REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the
|
||||||
// REAPER's render/record path without a null buffer. S4 marshals MIDI->core->audio.
|
// whole block (a single atomic acquire).
|
||||||
if (data.numOutputs > 0 && data.outputs && data.numSamples > 0) {
|
LoadedInstrument* inst = live_.load(std::memory_order_acquire);
|
||||||
|
|
||||||
|
// Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps
|
||||||
|
// events at block granularity (no per-event sample-offset split) — audible timing is
|
||||||
|
// within one block, adequate for Tier 0; sample-accurate scheduling is a later tier.
|
||||||
|
if (inst && data.inputEvents) {
|
||||||
|
const int32 count = data.inputEvents->getEventCount();
|
||||||
|
for (int32 i = 0; i < count; ++i) {
|
||||||
|
Event e;
|
||||||
|
if (data.inputEvents->getEvent(i, e) != kResultOk) continue;
|
||||||
|
if (e.type == Event::kNoteOnEvent) {
|
||||||
|
// A note-on with velocity 0 is a note-off by MIDI convention.
|
||||||
|
const int vel = static_cast<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];
|
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) {
|
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]) {
|
if (double* buf = out.channelBuffers64[ch]) {
|
||||||
for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.0;
|
for (int32 i = 0; i < frames; ++i) buf[i] = 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Flag output silence so the host can optimize (nothing plays yet).
|
|
||||||
out.silenceFlags = (out.numChannels >= 64)
|
out.silenceFlags = (out.numChannels >= 64)
|
||||||
? ~0ULL
|
? ~0ULL
|
||||||
: ((1ULL << out.numChannels) - 1);
|
: ((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;
|
return kResultOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) {
|
IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) {
|
||||||
if (name && FIDStringsEqual(name, ViewType::kEditor)) {
|
if (name && FIDStringsEqual(name, ViewType::kEditor)) {
|
||||||
return new ReaSamplerEditor(&bridge_);
|
return new ReaSamplerEditor(this);
|
||||||
}
|
}
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,53 @@
|
|||||||
// reasampler_processor.h — the VST3 SingleComponentEffect skeleton (Phase S1). THIN
|
// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the
|
||||||
// shell: an instrument that declares an event-input bus + a stereo audio-output bus,
|
// pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio
|
||||||
// sets up processing, and runs an empty (silent) process. Nothing plays yet — S4 wires
|
// output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the
|
||||||
// the pure sampler core into process(); S1 only proves REAPER hosts it.
|
// 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
|
// SingleComponentEffect is the SDK's combined processor+controller base — sanctioned
|
||||||
// for a non-distributable, REAPER-only plugin under D5/D6 (verified: SDK class
|
// for a non-distributable, REAPER-only plugin under D5/D6. It gives us
|
||||||
// reference). It gives us addAudioOutput/addEventInput and the IEditController seat, so
|
// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own
|
||||||
// createView() can hand the host our IPlugView LICE editor.
|
// 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
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
|
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
|
||||||
|
|
||||||
#include "reaper_bridge.h"
|
#include "reaper_bridge.h"
|
||||||
|
#include "sampler_core.h"
|
||||||
|
|
||||||
namespace reasampler::vst {
|
namespace reasampler::vst {
|
||||||
|
|
||||||
|
// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice
|
||||||
|
// engine that plays it. The engine holds a reference into the keymap, so the two MUST
|
||||||
|
// live and die together at a STABLE address — hence this is heap-allocated and neither
|
||||||
|
// copyable nor movable. The audio thread only ever reads it through an atomic pointer;
|
||||||
|
// it is built and destroyed off the audio thread.
|
||||||
|
struct LoadedInstrument {
|
||||||
|
Keymap keymap;
|
||||||
|
VoiceEngine engine;
|
||||||
|
|
||||||
|
LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr)
|
||||||
|
: keymap(std::move(km)), engine(maxVoices, keymap, adsr) {}
|
||||||
|
|
||||||
|
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||||
|
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
|
||||||
|
};
|
||||||
|
|
||||||
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
|
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
|
||||||
public:
|
public:
|
||||||
ReaSamplerProcessor() = default;
|
ReaSamplerProcessor() = default;
|
||||||
@@ -30,10 +62,16 @@ public:
|
|||||||
Steinberg::tresult PLUGIN_API terminate() override;
|
Steinberg::tresult PLUGIN_API terminate() override;
|
||||||
Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) 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 ----------------------------------------------
|
//--- from IAudioProcessor ----------------------------------------------
|
||||||
Steinberg::tresult PLUGIN_API setupProcessing(
|
Steinberg::tresult PLUGIN_API setupProcessing(
|
||||||
Steinberg::Vst::ProcessSetup& setup) override;
|
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::tresult PLUGIN_API process(
|
||||||
Steinberg::Vst::ProcessData& data) override;
|
Steinberg::Vst::ProcessData& data) override;
|
||||||
|
|
||||||
@@ -41,8 +79,50 @@ public:
|
|||||||
// Hands the host our LICE IPlugView editor.
|
// Hands the host our LICE IPlugView editor.
|
||||||
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
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:
|
private:
|
||||||
ReaperBridge bridge_;
|
ReaperBridge bridge_;
|
||||||
|
|
||||||
|
// --- The audio-thread handoff (S4 real-time discipline) -----------------
|
||||||
|
// process() atomically loads `live_` at block start and marshals/renders against it —
|
||||||
|
// a single atomic acquire, no lock, no free on the audio thread.
|
||||||
|
//
|
||||||
|
// reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new
|
||||||
|
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
|
||||||
|
// NOT freed on the reload path: process() may still be mid-block reading it, and two
|
||||||
|
// rapid reloads could otherwise free a pointer process is using. Instead it is parked
|
||||||
|
// in `graveyard_` and reclaimed only when process is GUARANTEED stopped — at
|
||||||
|
// setActive(false) / terminate(), which the host never runs concurrently with
|
||||||
|
// process. The graveyard grows by one engine per reload during a session (bounded by
|
||||||
|
// user sample switches — a few objects), a deliberate leak-until-deactivate trade for
|
||||||
|
// a lock-free, race-free audio thread. Tier 2 can add epoch-based reclaim if needed.
|
||||||
|
std::atomic<LoadedInstrument*> live_{nullptr};
|
||||||
|
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // freed only when stopped
|
||||||
|
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||||
|
|
||||||
|
// The selected sample id (instance state). Off-thread only; a small mutex guards the
|
||||||
|
// string against a getState/editor race. NOT read on the audio thread.
|
||||||
|
std::mutex selectionMutex_;
|
||||||
|
std::string selectedSampleId_;
|
||||||
|
|
||||||
|
// Latched from setupProcessing so setActive/reload can size against it. Read
|
||||||
|
// off-thread only.
|
||||||
|
double sampleRate_ = 44100.0;
|
||||||
|
Steinberg::int32 maxBlockSize_ = 4096;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace reasampler::vst
|
} // namespace reasampler::vst
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -289,18 +289,29 @@ void VoiceEngine::noteOff(int note) {
|
|||||||
if (target != kNoVoice) voices_[target].release();
|
if (target != kNoVoice) voices_[target].release();
|
||||||
}
|
}
|
||||||
|
|
||||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||||
const std::size_t base = out.size();
|
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||||
out.resize(base + frameCount, 0.0f); // S4: caller must pre-reserve — no allocation allowed under the VST3 process callback.
|
// 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_) {
|
for (Voice& voice : voices_) {
|
||||||
if (!voice.active()) continue;
|
if (!voice.active()) continue;
|
||||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||||
if (!voice.active()) break;
|
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 VoiceEngine::activeVoiceCount() const {
|
||||||
std::size_t n = 0;
|
std::size_t n = 0;
|
||||||
for (const Voice& v : voices_) {
|
for (const Voice& v : voices_) {
|
||||||
|
|||||||
+14
-3
@@ -236,9 +236,20 @@ public:
|
|||||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||||
void noteOff(int note);
|
void noteOff(int note);
|
||||||
|
|
||||||
// Renders `frameCount` mono output frames, summing all active voices, appending to
|
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
|
||||||
// `out` (does not clear it — the caller owns mixing/clearing). Voices that finish
|
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
|
||||||
// mid-block go idle and stop contributing.
|
// 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);
|
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||||
|
|
||||||
// Count of currently active voices (for tests / diagnostics).
|
// Count of currently active voices (for tests / diagnostics).
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Standalone tests for reasampler::vst::bridge_marshal — no VST3, no REAPER, no test
|
// 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
|
// framework. Same fast assert loop as the sibling pure tests: assert the REAPER
|
||||||
// bridge-read marshalling (GetProjExtState result decode + a small JSON string-field
|
// bridge-read marshalling (GetProjExtState result decode) directly, so the DAW-facing
|
||||||
// reader) directly, so the DAW-facing shell only has to invoke the API.
|
// shell only has to invoke the API.
|
||||||
//
|
//
|
||||||
// Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer
|
// Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer
|
||||||
// guard); extractJsonStringField present/absent/escapes/whitespace/value-vs-key
|
// guard). The S1 spike's extractJsonStringField string-scan reader was retired in S4
|
||||||
// disambiguation/non-string-value/malformed.
|
// (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"
|
#include "../src/vst/bridge_marshal.h"
|
||||||
|
|
||||||
@@ -44,72 +45,11 @@ static void testDecodeEmptyBuffer() {
|
|||||||
CHECK(!v.has_value());
|
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() {
|
int main() {
|
||||||
testDecodeHit();
|
testDecodeHit();
|
||||||
testDecodeAbsentKey();
|
testDecodeAbsentKey();
|
||||||
testDecodeNegativeReturn();
|
testDecodeNegativeReturn();
|
||||||
testDecodeEmptyBuffer();
|
testDecodeEmptyBuffer();
|
||||||
testExtractPresent();
|
|
||||||
testExtractAbsent();
|
|
||||||
testExtractWhitespaceTolerant();
|
|
||||||
testExtractEscapes();
|
|
||||||
testExtractValueContainingKeyText();
|
|
||||||
testExtractNonStringValue();
|
|
||||||
testExtractMalformed();
|
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n");
|
if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n");
|
||||||
return g_fail != 0;
|
return g_fail != 0;
|
||||||
|
|||||||
@@ -131,6 +131,63 @@ static void testHitTestMatchesDrawnButton() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- sample list (S4) ---------------------------------------------------------
|
||||||
|
|
||||||
|
static void testSampleRowRectStacks() {
|
||||||
|
const EditorLayout L = layoutEditor(400, 260);
|
||||||
|
const Rect r0 = sampleRowRect(L, 0);
|
||||||
|
const Rect r1 = sampleRowRect(L, 1);
|
||||||
|
// Row 0 starts at the canvas top and spans its full width.
|
||||||
|
CHECK(r0.top == L.canvas.top);
|
||||||
|
CHECK(r0.left == L.canvas.left && r0.right == L.canvas.right);
|
||||||
|
CHECK(r0.height() == kSampleRowHeight);
|
||||||
|
// Row 1 sits directly below row 0 (no gap, no overlap).
|
||||||
|
CHECK(r1.top == r0.bottom);
|
||||||
|
CHECK(r1.height() == kSampleRowHeight);
|
||||||
|
// A negative index is an empty rect.
|
||||||
|
CHECK(sampleRowRect(L, -1).width() == 0 && sampleRowRect(L, -1).height() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSampleRowHitTestMapsClickToRow() {
|
||||||
|
const EditorLayout L = layoutEditor(400, 260);
|
||||||
|
const int rows = 5;
|
||||||
|
// A click in the vertical middle of row 2 resolves to index 2.
|
||||||
|
const Rect r2 = sampleRowRect(L, 2);
|
||||||
|
const int midY = (r2.top + r2.bottom) / 2;
|
||||||
|
CHECK(sampleRowHitTest(L, rows, 200, midY) == 2);
|
||||||
|
// Row 0's top-left corner hits row 0.
|
||||||
|
const Rect r0 = sampleRowRect(L, 0);
|
||||||
|
CHECK(sampleRowHitTest(L, rows, r0.left, r0.top) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSampleRowHitTestMisses() {
|
||||||
|
const EditorLayout L = layoutEditor(400, 260);
|
||||||
|
const int rows = 3;
|
||||||
|
// Above the first row (in the title bar) -> no row.
|
||||||
|
CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.top) == -1);
|
||||||
|
// Below the last row -> no row.
|
||||||
|
const Rect last = sampleRowRect(L, rows - 1);
|
||||||
|
CHECK(sampleRowHitTest(L, rows, 200, last.bottom + 1) == -1);
|
||||||
|
// Left of the canvas -> no row.
|
||||||
|
CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1);
|
||||||
|
// Zero rows -> always -1.
|
||||||
|
CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to
|
||||||
|
// that row's index (the same load-bearing invariant as the button).
|
||||||
|
static void testSampleRowHitTestMatchesDrawnRows() {
|
||||||
|
const EditorLayout L = layoutEditor(320, 200);
|
||||||
|
const int rows = 4;
|
||||||
|
for (int i = 0; i < rows; ++i) {
|
||||||
|
const Rect r = sampleRowRect(L, i);
|
||||||
|
if (r.top >= L.canvas.bottom) break; // clipped rows aren't clickable targets
|
||||||
|
const int y = (r.top + r.bottom) / 2;
|
||||||
|
if (y >= L.canvas.bottom) continue;
|
||||||
|
CHECK(sampleRowHitTest(L, rows, r.left + 1, y) == i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testContainsHalfOpen();
|
testContainsHalfOpen();
|
||||||
testContainsDegenerate();
|
testContainsDegenerate();
|
||||||
@@ -141,6 +198,10 @@ int main() {
|
|||||||
testHitTestMissesNonButton();
|
testHitTestMissesNonButton();
|
||||||
testHitTestButtonBoundary();
|
testHitTestButtonBoundary();
|
||||||
testHitTestMatchesDrawnButton();
|
testHitTestMatchesDrawnButton();
|
||||||
|
testSampleRowRectStacks();
|
||||||
|
testSampleRowHitTestMapsClickToRow();
|
||||||
|
testSampleRowHitTestMisses();
|
||||||
|
testSampleRowHitTestMatchesDrawnRows();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("editor_geometry: all tests passed\n");
|
if (g_fail == 0) std::printf("editor_geometry: all tests passed\n");
|
||||||
return g_fail != 0;
|
return g_fail != 0;
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Standalone tests for reasampler::sample_map — no VST3, no REAPER, no test framework.
|
||||||
|
// Same fast assert loop as the sibling pure tests. This module is the S4 mapping heart:
|
||||||
|
// bank blob -> selected sample (through the SHARED bank_book JSON parse), interleaved ->
|
||||||
|
// mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap build, and the
|
||||||
|
// selected-sample instance-state (de)serialization.
|
||||||
|
//
|
||||||
|
// Every assertion is written to FAIL if the mapping were wrong: the bank blobs are built
|
||||||
|
// by serializing a real BankBook (so we exercise the shared parse, not a fixture string),
|
||||||
|
// and the selection / downmix / keymap / state values are checked against independently
|
||||||
|
// computed expectations.
|
||||||
|
//
|
||||||
|
// Covers: selectSample by-id hit (across pool + named banks), first-sample fallback for
|
||||||
|
// an empty / unknown id, empty & malformed blob -> nullopt, zero-samples -> nullopt,
|
||||||
|
// rootNote/loop intrinsic threading incl. the middle-C default; listSamples ordinal
|
||||||
|
// order + empty/malformed; downmixToMono mono passthrough / stereo average / 3-ch
|
||||||
|
// average / zero-stride / empty; buildTier0Keymap single full-keyboard zone with the
|
||||||
|
// root + loop + rate threaded and rate defaulting; selection state round-trip + empty id
|
||||||
|
// + wrong-version / truncated -> "".
|
||||||
|
|
||||||
|
#include "../src/vst/sample_map.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#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)
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testSelectByIdHit();
|
||||||
|
testSelectFirstSampleFallbackOnEmptyId();
|
||||||
|
testSelectFirstSampleFallbackOnUnknownId();
|
||||||
|
testSelectRootNoteDefault();
|
||||||
|
testSelectLoopThreaded();
|
||||||
|
testSelectNoLoopIsAbsent();
|
||||||
|
testSelectEmptyBlob();
|
||||||
|
testSelectMalformedBlob();
|
||||||
|
testSelectZeroSamples();
|
||||||
|
testListSamplesOrdinalOrder();
|
||||||
|
testListSamplesEmptyAndMalformed();
|
||||||
|
testDownmixMonoPassthrough();
|
||||||
|
testDownmixStereoAverages();
|
||||||
|
testDownmixThreeChannelAverages();
|
||||||
|
testDownmixDegenerate();
|
||||||
|
testBuildKeymapSingleFullZone();
|
||||||
|
testBuildKeymapRateDefault();
|
||||||
|
testSelectionStateRoundTrip();
|
||||||
|
testSelectionStateEmptyId();
|
||||||
|
testSelectionStateWrongVersion();
|
||||||
|
testSelectionStateTruncated();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
||||||
|
return g_fail != 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user