From 3c2a7c45b26bc992f3e8b5e7d2dec82bccd83063 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 15:29:54 -0400 Subject: [PATCH 01/49] =?UTF-8?q?feat(vst):=20stand=20up=20VST3=20instrume?= =?UTF-8?q?nt=20spike=20(S1)=20=E2=80=94=20skeleton,=20IPlugView=E2=86=94L?= =?UTF-8?q?ICE=20editor,=20REAPER=20bridge=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendor Steinberg VST3 SDK (v3.7.9_build_61); add reasampler_vst.vst3 as a second, additive build artifact with pure editor-geometry + bridge-marshal helpers under CTest. --- .gitmodules | 3 + CMakeLists.txt | 102 ++++++++++++++ src/vst/bridge_marshal.cpp | 78 +++++++++++ src/vst/bridge_marshal.h | 47 +++++++ src/vst/editor_geometry.cpp | 56 ++++++++ src/vst/editor_geometry.h | 59 +++++++++ src/vst/reaper_bridge.cpp | 88 +++++++++++++ src/vst/reaper_bridge.h | 63 +++++++++ src/vst/reasampler_editor.cpp | 220 +++++++++++++++++++++++++++++++ src/vst/reasampler_editor.h | 65 +++++++++ src/vst/reasampler_processor.cpp | 82 ++++++++++++ src/vst/reasampler_processor.h | 48 +++++++ src/vst/reasampler_vst.h | 36 +++++ src/vst/vst_entry.cpp | 45 +++++++ tests/test_bridge_marshal.cpp | 116 ++++++++++++++++ tests/test_editor_geometry.cpp | 147 +++++++++++++++++++++ vendor/vst3sdk | 1 + 17 files changed, 1256 insertions(+) create mode 100644 src/vst/bridge_marshal.cpp create mode 100644 src/vst/bridge_marshal.h create mode 100644 src/vst/editor_geometry.cpp create mode 100644 src/vst/editor_geometry.h create mode 100644 src/vst/reaper_bridge.cpp create mode 100644 src/vst/reaper_bridge.h create mode 100644 src/vst/reasampler_editor.cpp create mode 100644 src/vst/reasampler_editor.h create mode 100644 src/vst/reasampler_processor.cpp create mode 100644 src/vst/reasampler_processor.h create mode 100644 src/vst/reasampler_vst.h create mode 100644 src/vst/vst_entry.cpp create mode 100644 tests/test_bridge_marshal.cpp create mode 100644 tests/test_editor_geometry.cpp create mode 160000 vendor/vst3sdk diff --git a/.gitmodules b/.gitmodules index 97b3864..25ffe5c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "vendor/WDL"] path = vendor/WDL url = https://github.com/justinfrankel/WDL +[submodule "vendor/vst3sdk"] + path = vendor/vst3sdk + url = https://github.com/steinbergmedia/vst3sdk diff --git a/CMakeLists.txt b/CMakeLists.txt index 8392ba8..3025dde 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -584,6 +584,28 @@ add_executable(card_drag_tests tests/test_card_drag.cpp) target_link_libraries(card_drag_tests PRIVATE card_drag) add_test(NAME card_drag_tests COMMAND card_drag_tests) +# --------------------------------------------------------------------------- +# 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE. +# editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math +# (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge +# read marshalling — GetProjExtState result decode + a small JSON string-field +# reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW; +# the VST3 shell (src/vst/*) that draws/routes/invokes is DAW-verified. +# --------------------------------------------------------------------------- +add_library(editor_geometry STATIC src/vst/editor_geometry.cpp) +target_include_directories(editor_geometry PUBLIC src/vst) + +add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp) +target_include_directories(bridge_marshal PUBLIC src/vst) + +add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) +target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) +add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) + +add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp) +target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal) +add_test(NAME bridge_marshal_tests COMMAND bridge_marshal_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -671,3 +693,83 @@ else() # php ${WDL_INC}/swell/mac_resgen.php src/resource.rc # target_sources(reaper_reasampler PRIVATE src/resource.rc_mac_dlg.h) # generated endif() + +# =========================================================================== +# 5) The ReaSampler VST3 instrument — the SECOND build artifact (Phase S1). +# +# Windows-only, VST3-only, REAPER-only (D5). A separate native VST3 plugin the user +# instantiates on an instrument track. Additive: the reaper_reasampler target above +# builds unchanged. This is the S1 opening spike — a silent-but-loading +# SingleComponentEffect skeleton, an IPlugView<->LICE editor, and the REAPER VST-host +# bridge read — not yet a sampler. +# +# ONE-TIME SDK SUBMODULE SETUP (see README / .gitmodules): the vst3sdk superproject is +# vendored pinned to tag v3.7.9_build_61; only three of its sub-submodules are needed +# (VSTGUI/examples/tests are NOT). After `git submodule update --init vendor/vst3sdk`: +# cd vendor/vst3sdk && git submodule update --init pluginterfaces base public.sdk +# =========================================================================== +if(WIN32) + set(VST3_SDK ${CMAKE_CURRENT_SOURCE_DIR}/vendor/vst3sdk) + + # --- 5a) The bounded slice of the Steinberg VST3 SDK this spike needs. -------- + # Enumerated (not add_subdirectory of the whole SDK) to keep the build hermetic and + # lean, matching the project's two-submodule discipline: no VSTGUI, no examples, no + # SDK-global CMake helpers/install machinery. Pinned to tag v3.7.9_build_61, so the + # list is fixed. If the SDK tag is bumped, re-verify this set. + add_library(vst3_sdk STATIC + # pluginterfaces/base — FUnknown, IIDs, string table, ustring. + ${VST3_SDK}/pluginterfaces/base/funknown.cpp + ${VST3_SDK}/pluginterfaces/base/coreiids.cpp + ${VST3_SDK}/pluginterfaces/base/conststringtable.cpp + ${VST3_SDK}/pluginterfaces/base/ustring.cpp + # base/source — FObject, strings, buffers, streamer, debug, IIDs, update handler. + ${VST3_SDK}/base/source/fobject.cpp + ${VST3_SDK}/base/source/fstring.cpp + ${VST3_SDK}/base/source/fbuffer.cpp + ${VST3_SDK}/base/source/fstreamer.cpp + ${VST3_SDK}/base/source/fdebug.cpp + ${VST3_SDK}/base/source/baseiids.cpp + ${VST3_SDK}/base/source/updatehandler.cpp + ${VST3_SDK}/base/thread/source/flock.cpp + # public.sdk/source/vst — the SingleComponentEffect base + its deps. NOTE: + # vstsinglecomponenteffect.cpp #includes vsteditcontroller.cpp (unity-style), so + # vsteditcontroller.cpp must NOT be listed separately (double definition). + ${VST3_SDK}/public.sdk/source/vst/vstsinglecomponenteffect.cpp + ${VST3_SDK}/public.sdk/source/vst/vstcomponentbase.cpp + ${VST3_SDK}/public.sdk/source/vst/vstbus.cpp + ${VST3_SDK}/public.sdk/source/vst/vstparameters.cpp + ${VST3_SDK}/public.sdk/source/vst/vstinitiids.cpp + # public.sdk/source/common — CPluginView (IPlugView base) + IIDs. + ${VST3_SDK}/public.sdk/source/common/pluginview.cpp + ${VST3_SDK}/public.sdk/source/common/commoniids.cpp + # public.sdk/source/main — the class-factory (GetPluginFactory) support. NOTE: + # dllmain.cpp + moduleinit.cpp (which carry the InitDll/ExitDll dll exports) are + # compiled into the MODULE target directly, NOT here: their SMTG_EXPORT_SYMBOL + # functions have no internal referrer, so the linker strips them from a static + # lib. Compiling them into the module keeps the exports. + ${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp + ) + target_include_directories(vst3_sdk PUBLIC ${VST3_SDK}) + # The SDK requires exactly one of RELEASE / DEVELOPMENT (fdebug.cpp keys off it). + target_compile_definitions(vst3_sdk PUBLIC $,DEVELOPMENT=1,RELEASE=1>) + + # --- 5b) The VST3 module (loadable .vst3 DLL). ------------------------------- + add_library(reasampler_vst MODULE + src/vst/vst_entry.cpp + src/vst/reasampler_processor.cpp + src/vst/reasampler_editor.cpp + src/vst/reaper_bridge.cpp + # SDK module entry — compiled into the module (not the static lib) so the + # InitDll/ExitDll dll exports survive the link (see vst3_sdk note above). + ${VST3_SDK}/public.sdk/source/main/dllmain.cpp + ${VST3_SDK}/public.sdk/source/main/moduleinit.cpp + ${LICE_SRC} + ) + target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal) + # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; + # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. + target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) + # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. + set_target_properties(reasampler_vst PROPERTIES PREFIX "" SUFFIX ".vst3" + OUTPUT_NAME "reasampler_vst") +endif() diff --git a/src/vst/bridge_marshal.cpp b/src/vst/bridge_marshal.cpp new file mode 100644 index 0000000..7faf94b --- /dev/null +++ b/src/vst/bridge_marshal.cpp @@ -0,0 +1,78 @@ +// bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types. + +#include "bridge_marshal.h" + +namespace reasampler::vst { + +std::optional decodeGetProjExtState(int apiReturn, + const std::string& buffer) { + // REAPER returns the length of the stored value; 0 means the key is absent. Guard + // both the return AND the buffer: a caller that reused a dirty buffer must not + // surface stale bytes as a value when the API reported nothing. + if (apiReturn <= 0 || buffer.empty()) return std::nullopt; + return buffer; +} + +std::optional extractJsonStringField(const std::string& json, + const std::string& key) { + // Find the member token: "key" followed (after optional whitespace) by ':' then a + // quoted string. Scan for each candidate occurrence of the quoted key so a value + // that happens to contain the key text can't produce a false match. + const std::string needle = "\"" + key + "\""; + size_t searchFrom = 0; + + while (true) { + const size_t keyPos = json.find(needle, searchFrom); + if (keyPos == std::string::npos) return std::nullopt; + + size_t i = keyPos + needle.size(); + searchFrom = i; // next candidate starts after this key token + + // Skip whitespace to the ':'. + while (i < json.size() && + (json[i] == ' ' || json[i] == '\t' || json[i] == '\n' || + json[i] == '\r')) { + ++i; + } + if (i >= json.size() || json[i] != ':') continue; // not a member — keep looking + ++i; + + // Skip whitespace to the value. + while (i < json.size() && + (json[i] == ' ' || json[i] == '\t' || json[i] == '\n' || + json[i] == '\r')) { + ++i; + } + if (i >= json.size() || json[i] != '"') return std::nullopt; // value not a string + ++i; + + // Read the string body, honoring the common JSON escapes. + std::string out; + while (i < json.size()) { + const char c = json[i]; + if (c == '\\') { + if (i + 1 >= json.size()) return std::nullopt; // dangling escape + const char e = json[i + 1]; + switch (e) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'n': out.push_back('\n'); break; + case 't': out.push_back('\t'); break; + case 'r': out.push_back('\r'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + default: out.push_back(e); break; // pass through unknown escapes + } + i += 2; + continue; + } + if (c == '"') return out; // closing quote — done + out.push_back(c); + ++i; + } + return std::nullopt; // unterminated string + } +} + +} // namespace reasampler::vst diff --git a/src/vst/bridge_marshal.h b/src/vst/bridge_marshal.h new file mode 100644 index 0000000..0c55b63 --- /dev/null +++ b/src/vst/bridge_marshal.h @@ -0,0 +1,47 @@ +// bridge_marshal.h — PURE marshalling helpers for the REAPER VST-host bridge read +// (Phase S1). NO VST3, NO REAPER types at the boundary. +// +// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the +// host callback and invokes them; the fiddly-and-easy-to-get-wrong parts around those +// calls — interpreting GetProjExtState's int return, walking EnumProjExtState's +// index-until-false contract into a key set, and extracting a single value out of the +// "reasampler" bank JSON — are pure and unit-tested here. Mirror of capture_paths / +// wav_trim splitting the arithmetic out of a REAPER-facing shell. +// +// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h: +// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz); +// -- returns the length written (0 when the key is absent). +// bool EnumProjExtState(ReaProject*, extname, idx, keyOut, keyOut_sz, valOut, valOut_sz); +// -- returns false when idx is past the last entry. + +#pragma once + +#include +#include + +namespace reasampler::vst { + +// Interpret a GetProjExtState result: the int return value (bytes the API reports for +// the key) and the buffer it filled. Returns the value only when the API reported a +// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer +// untouched for an absent key, and we must not treat stale buffer contents as a hit. +// +// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it +// wrote (already truncated to the C string by the caller). +std::optional decodeGetProjExtState(int apiReturn, + const std::string& buffer); + +// Extract the string value for `key` out of a flat one-level JSON object — the shape +// persist.cpp writes under the "reasampler" ext-state (e.g. the bank blob's top-level +// fields). This is a deliberately small, dependency-free reader for the SPIKE's +// "read a known value" proof, NOT a general JSON parser: it finds "key" as an object +// member and returns its string value, handling the common escapes (\" \\ \n \t \/). +// Returns nullopt if the key is absent or its value is not a string. +// +// The real instrument (S4) will read the bank index through the shared bank_model JSON +// path, not this helper; this exists only to give the S1 bridge read a testable, +// REAPER-free decode step. +std::optional extractJsonStringField(const std::string& json, + const std::string& key); + +} // namespace reasampler::vst diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp new file mode 100644 index 0000000..d5babe1 --- /dev/null +++ b/src/vst/editor_geometry.cpp @@ -0,0 +1,56 @@ +// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types. + +#include "editor_geometry.h" + +#include + +namespace reasampler::vst { + +namespace { + +// Spike editor layout constants. These are the editor's fixed metrics; the real +// editor (S4/S5) will parameterize as its content demands. +constexpr int kTitleBarHeight = 28; +constexpr int kButtonMargin = 10; +constexpr int kButtonWidth = 120; +constexpr int kButtonHeight = 24; + +} // namespace + +bool contains(const Rect& r, int x, int y) { + if (r.width() <= 0 || r.height() <= 0) return false; + return x >= r.left && x < r.right && y >= r.top && y < r.bottom; +} + +EditorLayout layoutEditor(int w, int h) { + // Clamp the surface to non-negative extents so a degenerate view can't produce + // inverted rects. + const int cw = std::max(0, w); + const int ch = std::max(0, h); + + EditorLayout out; + + // Title bar spans the top, clamped so it never exceeds the client height. + const int titleH = std::min(kTitleBarHeight, ch); + out.titleBar = Rect{0, 0, cw, titleH}; + + // Canvas is everything below the title bar. + out.canvas = Rect{0, titleH, cw, ch}; + + // Button sits at the top-left of the canvas, inset by a margin, and is clamped to + // fit inside the canvas so it never overhangs on a small view. + const int bx = out.canvas.left + kButtonMargin; + const int by = out.canvas.top + kButtonMargin; + const int bRight = std::min(bx + kButtonWidth, out.canvas.right); + const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom); + out.button = Rect{bx, by, std::max(bx, bRight), std::max(by, bBottom)}; + + return out; +} + +HitTarget hitTest(const EditorLayout& layout, int x, int y) { + if (contains(layout.button, x, y)) return HitTarget::kButton; + return HitTarget::kNone; +} + +} // namespace reasampler::vst diff --git a/src/vst/editor_geometry.h b/src/vst/editor_geometry.h new file mode 100644 index 0000000..b6bb5b1 --- /dev/null +++ b/src/vst/editor_geometry.h @@ -0,0 +1,59 @@ +// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE +// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. +// +// The IPlugView shell (reasampler_editor.cpp) owns the window/bitmap/SWELL plumbing +// and is DAW-verified; this module holds the fiddly rectangle math and hit-testing so +// it can be unit-tested outside the DAW — the mirror of how bank_grid / mode_switch / +// tab_strip split their layout math out of the panel shell. +// +// The spike's editor is deliberately trivial (a title band + one clickable button), +// enough to PROVE the host->draw/hit-test event routing works. As the real editor +// (S4/S5) grows, its layout math accretes here, not in the shell. + +#pragma once + +namespace reasampler::vst { + +// A plain integer rectangle. left/top inclusive, right/bottom exclusive — the same +// half-open convention LICE/SWELL RECTs use, kept REAPER-free here. +struct Rect { + int left = 0; + int top = 0; + int right = 0; + int bottom = 0; + + int width() const { return right - left; } + int height() const { return bottom - top; } +}; + +// Returns true if (x, y) falls inside r under the half-open convention +// (left <= x < right, top <= y < bottom). A zero-or-negative-area rect contains +// nothing. +bool contains(const Rect& r, int x, int y); + +// The regions the spike editor draws, derived from the current view size. All are +// clamped to the client area so a degenerate (too-small) view never yields a region +// that spills outside the surface. +struct EditorLayout { + Rect titleBar; // top band: the plugin name + a live-state readout + Rect button; // a single clickable button (proves hit-test routing) + Rect canvas; // the remaining surface below the title bar +}; + +// Divide a (w x h) client area into the spike editor's regions. Pure: the same +// inputs always yield the same layout. Guards tiny sizes — every returned rect stays +// within [0,w] x [0,h], and the button never overhangs the canvas. +EditorLayout layoutEditor(int w, int h); + +// The editor's hit-test targets. kNone means the point landed on inert surface. +enum class HitTarget { + kNone, + kButton, +}; + +// Classify a click at (x, y) against a layout. The button wins only when the point is +// inside the button rect; everything else (including the title bar and empty canvas) +// is kNone in the spike. +HitTarget hitTest(const EditorLayout& layout, int x, int y); + +} // namespace reasampler::vst diff --git a/src/vst/reaper_bridge.cpp b/src/vst/reaper_bridge.cpp new file mode 100644 index 0000000..8096a92 --- /dev/null +++ b/src/vst/reaper_bridge.cpp @@ -0,0 +1,88 @@ +// reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin. + +#include "reaper_bridge.h" + +#include + +#include "bridge_marshal.h" + +// The VST3 base types must be included before REAPER's VST3 interface header, which +// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from +// pluginterfaces/base — all in namespace Steinberg. +#include "pluginterfaces/base/funknown.h" +#include "pluginterfaces/base/ftypes.h" + +// REAPER's VST3-side bridge interface (vendored). IReaperHostApplication is what REAPER +// passes (as an IHostApplication) to IComponent::initialize; it exposes getReaperApi +// (resolve-by-name) and getReaperParent (host context). The header uses UNQUALIFIED +// Steinberg types (FUnknown, CStringA, uint32, FUID, DECLARE_CLASS_IID, PLUGIN_API), so +// it must be pulled into the Steinberg namespace — the same way REAPER's own VST3 +// examples include it. +namespace Steinberg { +#include "reaper_vst3_interfaces.h" +} // namespace Steinberg + +// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperHostApplication::iid; some +// TU must DEFINE it. We do it here — this is the only place that queries for the +// interface (FUnknownPtr uses the iid), so the definition lives with its sole use. +DEF_CLASS_IID(Steinberg::IReaperHostApplication) + +// The "reasampler" ext-state namespace + bank key. Kept in sync with persist.h by +// value (the extension writes them); we only READ here, so we duplicate the two string +// constants rather than pull the whole REAPER-facing persist.h into the VST artifact. +// If persist.h's kProjExtNamespace / kProjExtBanksKey ever change, these must follow — +// they are the shared wire contract between the extension (writer) and instrument +// (reader). VERIFY against persist.h. +namespace { +constexpr const char* kReasamplerNamespace = "reasampler"; +} + +namespace reasampler::vst { + +bool ReaperBridge::connect(Steinberg::FUnknown* context) { + getProjExtState_ = nullptr; + enumProjExtState_ = nullptr; + hostApp_ = nullptr; + if (!context) return false; + + // Query the host context for REAPER's bridge interface. In a non-REAPER host this + // query fails and we stay unconnected — the instrument still loads. + Steinberg::FUnknownPtr reaper(context); + if (!reaper) return false; + hostApp_ = reaper.get(); + + // Resolve the ext-state functions by name. getReaperApi returns the same function + // pointers the extension resolves via rec->GetFunc; a null return means the symbol + // is unavailable (very old REAPER) — degrade gracefully. + getProjExtState_ = reinterpret_cast( + reaper->getReaperApi("GetProjExtState")); + enumProjExtState_ = reinterpret_cast( + reaper->getReaperApi("EnumProjExtState")); + + return getProjExtState_ != nullptr; +} + +std::optional ReaperBridge::readReasamplerExtState(const std::string& key) { + if (!getProjExtState_ || !hostApp_) return std::nullopt; + + // Fetch the host project (getReaperParent(3) — project). Reads that live "reasampler" + // ext-state against the ACTIVE project the instrument was instantiated in, so it + // follows project switches for free (D6). + auto* reaper = static_cast(hostApp_); + void* proj = reaper->getReaperParent(3); + // A null project is legitimate (e.g. instantiated before a project context exists); + // REAPER treats null as the current project for these calls, so we pass it through + // rather than bailing — but if the read yields nothing the caller sees nullopt. + + // GetProjExtState writes into a caller buffer; size it generously for a JSON blob + // and let the pure decoder interpret the result. The buffer is NUL-terminated by + // REAPER on success. + std::vector buf(64 * 1024, '\0'); + const int rv = getProjExtState_(proj, kReasamplerNamespace, key.c_str(), buf.data(), + static_cast(buf.size())); + + // Marshal the raw result through the pure decoder (handles the absent-key case). + return decodeGetProjExtState(rv, std::string(buf.data())); +} + +} // namespace reasampler::vst diff --git a/src/vst/reaper_bridge.h b/src/vst/reaper_bridge.h new file mode 100644 index 0000000..8eea7dc --- /dev/null +++ b/src/vst/reaper_bridge.h @@ -0,0 +1,63 @@ +// reaper_bridge.h — the REAPER VST-host bridge (Phase S1 read spike). THIN shell: +// resolves REAPER API functions by name over the host context and reads the live +// "reasampler" project ext-state. The fiddly decode lives in bridge_marshal (pure). +// +// VERIFIED BRIDGE MECHANISM (corrects §1a's estimate). §1a described the VST2-style +// hostcb opcode pattern (hostcb(&effect, 0xdeadbeef, 0xdeadf00d, ...)). That is the +// VST2 path (video_processor.h documents it for a VST2 aEffect). For a VST3 plugin the +// bridge is exposed differently and more cleanly: REAPER passes an IHostApplication as +// the `context` to IComponent::initialize(FUnknown* context); querying it for +// IReaperHostApplication (vendor/reaper-sdk/sdk/reaper_vst3_interfaces.h) yields: +// * getReaperApi(funcname) -> resolve a REAPER API function pointer by name +// (the VST3 equivalent of opcode 0xdeadf00d), and +// * getReaperParent(3) -> the host ReaProject* (the VST3 equivalent of the +// 0xdeadf00e host-context fetch; 1=track, 2=take, 3=project, 4=fxdsp, 5=trackchan). +// So a VST3 uses IReaperHostApplication, not the raw hostcb opcodes. Verified against +// reaper_vst3_interfaces.h + reaper_plugin_functions.h at the spike. + +#pragma once + +#include +#include + +#include "pluginterfaces/base/funknown.h" + +namespace reasampler::vst { + +// Wraps the REAPER host bridge for a single plugin instance. Constructed cheaply; +// connect() must be called with the initialize() context before any read. All reads +// degrade to nullopt (never crash) when the host is not REAPER or a symbol is absent — +// the instrument must load in non-REAPER hosts too, just without live state. +class ReaperBridge { +public: + ReaperBridge() = default; + + // Bind to the host. `context` is the FUnknown* REAPER hands IComponent::initialize. + // Returns true when the REAPER bridge is available (host is REAPER and the ext-state + // API resolved). Safe to call with a null or non-REAPER context — returns false. + bool connect(Steinberg::FUnknown* context); + + // True once connect() found the REAPER host application AND resolved the ext-state + // functions. + bool isConnected() const { return getProjExtState_ != nullptr; } + + // Read a "reasampler" ext-state value by key from the host's active project. + // Returns nullopt when unconnected, when the project can't be resolved, or when the + // key is absent. This is the S1 read-spike entry point. + std::optional readReasamplerExtState(const std::string& key); + +private: + // Resolved REAPER API function pointers (by name via getReaperApi). Signatures + // verified against reaper_plugin_functions.h. + using GetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key, + char* valOutNeedBig, int valOutNeedBig_sz); + using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx, + char* keyOut, int keyOut_sz, char* valOut, + int valOut_sz); + + void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp) + GetProjExtStateFn getProjExtState_ = nullptr; + EnumProjExtStateFn enumProjExtState_ = nullptr; +}; + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp new file mode 100644 index 0000000..0e68a69 --- /dev/null +++ b/src/vst/reasampler_editor.cpp @@ -0,0 +1,220 @@ +// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge. +// Windows-only (D5); the whole file is guarded so a non-Windows build (not a target, +// but keeps the TU honest) degrades to the CPluginView defaults. + +#include "reasampler_editor.h" + +#include + +#include "editor_geometry.h" +#include "reaper_bridge.h" + +#ifdef _WIN32 +#include // GET_X_LPARAM / GET_Y_LPARAM + +// LICE — the same drawing stack bank_panel uses. On Windows LICE routes through native +// GDI; no SWELL needed for the child window itself. +#include "wdltypes.h" +#include "lice/lice.h" +#endif + +using namespace Steinberg; + +namespace reasampler::vst { + +namespace { +#ifdef _WIN32 +constexpr const wchar_t* kChildClassName = L"ReaSamplerVstEditor"; + +// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads +// as the same tool. +const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); +const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255); +const LICE_pixel kColBtnBg = LICE_RGBA(44, 44, 48, 255); +const LICE_pixel kColBtnHitBg = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColBtnBorder = LICE_RGBA(120, 200, 160, 255); +const COLORREF kRgbText = RGB(210, 230, 220); + +void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { + // LICE has no built-in font handle here; use GDI text into the bitmap DC, matching + // bank_panel's drawCenteredText approach (SetTextColor + DrawText on getDC()). + HDC dc = bmp->getDC(); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, col); + RECT gr{r.left, r.top, r.right, r.bottom}; + DrawTextA(dc, s, -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); +} +#endif +} // namespace + +ReaSamplerEditor::ReaSamplerEditor(ReaperBridge* bridge) + : CPluginView(nullptr), bridge_(bridge) { + // Default view size; the host may resize (canResize() == true). + ViewRect r(0, 0, 420, 260); + setRect(r); +} + +ReaSamplerEditor::~ReaSamplerEditor() { +#ifdef _WIN32 + // Defensive teardown: the host normally calls removed() (which destroys the child) + // before releasing us, but if we're destroyed while still attached, don't leak the + // window — mirror the create in attachedToParent(). + if (childHwnd_) { + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +#endif +} + +tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) { +#ifdef _WIN32 + if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue; +#endif + return kResultFalse; +} + +tresult PLUGIN_API ReaSamplerEditor::canResize() { + return kResultTrue; +} + +#ifdef _WIN32 + +void ReaSamplerEditor::attachedToParent() { + // systemWindow is the parent HWND the host created (CPluginView::attached set it + // from the void* parent when the type is HWND). + HWND parent = static_cast(systemWindow); + if (!parent) return; + + HINSTANCE hInst = reinterpret_cast( + GetWindowLongPtr(parent, GWLP_HINSTANCE)); + if (!hInst) hInst = GetModuleHandle(nullptr); + + // Register the child window class once per module. + static bool classRegistered = false; + if (!classRegistered) { + WNDCLASSW wc{}; + wc.lpfnWndProc = &ReaSamplerEditor::wndProc; + wc.hInstance = hInst; + wc.lpszClassName = kChildClassName; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.style = CS_HREDRAW | CS_VREDRAW; + RegisterClassW(&wc); + classRegistered = true; + } + + const ViewRect& r = getRect(); + childHwnd_ = CreateWindowExW( + 0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(), + r.getHeight(), parent, nullptr, hInst, nullptr); + if (childHwnd_) { + // Stash `this` so wndProc can route messages back to the instance. + SetWindowLongPtr(childHwnd_, GWLP_USERDATA, + reinterpret_cast(this)); + } +} + +void ReaSamplerEditor::removedFromParent() { + if (childHwnd_) { + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +} + +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + // Let CPluginView latch the new rect, then resize the child window to match so the + // LICE surface fills the host-provided seat. + tresult res = CPluginView::onSize(newSize); + if (childHwnd_ && newSize) { + MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); + } + return res; +} + +void ReaSamplerEditor::paint(HDC hdc) { + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + if (w <= 0 || h <= 0) return; + + // Same double-buffered LICE pattern as bank_panel::paintPanel: draw into a + // LICE_SysBitmap, then BitBlt to the window DC. + LICE_SysBitmap bmp(w, h); + LICE_Clear(&bmp, kColBackground); + + const EditorLayout layout = layoutEditor(w, h); + + // Title band. + LICE_FillRect(&bmp, layout.titleBar.left, layout.titleBar.top, + layout.titleBar.width(), layout.titleBar.height(), kColTitleBg, 1.0f, + 0); + // Read a known live-state value over the bridge to prove the read spike. Show the + // raw ext-state presence (never the full blob) so the title reflects live project + // state without dumping JSON into the UI. + std::string title = "ReaSampler Instrument"; + if (bridge_ && bridge_->isConnected()) { + auto banks = bridge_->readReasamplerExtState("banks"); + title += banks ? " [bank: linked]" : " [bank: none]"; + } else { + title += " [host: no bridge]"; + } + Rect titleText{layout.titleBar.left + 8, layout.titleBar.top, + layout.titleBar.right - 8, layout.titleBar.bottom}; + drawText(&bmp, titleText, title.c_str(), kRgbText); + + // The clickable button — fill reflects the last hit-test (the routing proof). + LICE_FillRect(&bmp, layout.button.left, layout.button.top, layout.button.width(), + layout.button.height(), buttonHit_ ? kColBtnHitBg : kColBtnBg, 1.0f, + 0); + LICE_DrawRect(&bmp, layout.button.left, layout.button.top, layout.button.width(), + layout.button.height(), kColBtnBorder, 1.0f, 0); + Rect btnText{layout.button.left + 8, layout.button.top, layout.button.right, + layout.button.bottom}; + drawText(&bmp, btnText, buttonHit_ ? "clicked" : "click me", kRgbText); + + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); +} + +void ReaSamplerEditor::onClick(int x, int y) { + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const EditorLayout layout = layoutEditor(cr.right - cr.left, cr.bottom - cr.top); + if (hitTest(layout, x, y) == HitTarget::kButton) { + buttonHit_ = !buttonHit_; + InvalidateRect(childHwnd_, nullptr, FALSE); + } +} + +LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, + LPARAM lParam) { + auto* self = reinterpret_cast( + GetWindowLongPtr(hwnd, GWLP_USERDATA)); + switch (msg) { + case WM_PAINT: { + PAINTSTRUCT ps{}; + HDC hdc = BeginPaint(hwnd, &ps); + if (self) self->paint(hdc); + EndPaint(hwnd, &ps); + return 0; + } + case WM_LBUTTONDOWN: + if (self) self->onClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_ERASEBKGND: + return 1; // we fully repaint in WM_PAINT; skip the flicker-inducing erase + default: + return DefWindowProcW(hwnd, msg, wParam, lParam); + } +} + +#else // non-Windows: not a build target (D5), but keep the TU compilable. + +void ReaSamplerEditor::attachedToParent() {} +void ReaSamplerEditor::removedFromParent() {} +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + return CPluginView::onSize(newSize); +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h new file mode 100644 index 0000000..45c4a60 --- /dev/null +++ b/src/vst/reasampler_editor.h @@ -0,0 +1,65 @@ +// reasampler_editor.h — the VST3 IPlugView LICE editor (Phase S1 bridge spike). THIN +// shell: hosts a LICE-drawn child window inside the host's IPlugView seat and routes +// host paint/click into the pure editor_geometry hit-test. Windows-only (D5). +// +// This is the phase's ONE genuine unknown (per CONTEXT.md §Phase S / S1): wiring LICE +// into an IPlugView. It reuses the bank_panel LICE/SWELL competence — a LICE_SysBitmap +// blitted in WM_PAINT, GET_X/Y_LPARAM hit-testing in WM_LBUTTONDOWN — but hangs it off a +// child HWND created in IPlugView::attached() rather than a docked SWELL dialog. +// +// Subclasses CPluginView (public.sdk/source/common/pluginview.h) for the IPlugView +// refcount + attached/removed/getSize/onSize boilerplate; we override the attach/remove +// hooks to create/destroy the child window and onSize to resize it. + +#pragma once + +#include "public.sdk/source/common/pluginview.h" + +#ifdef _WIN32 +#include +#endif + +namespace reasampler::vst { + +class ReaperBridge; + +class ReaSamplerEditor : public Steinberg::CPluginView { +public: + // `bridge` is owned by the processor and outlives the editor; the editor reads + // (never mutates) it to show a live-state readout. May be null (non-REAPER host). + explicit ReaSamplerEditor(ReaperBridge* bridge); + ~ReaSamplerEditor() override; + + // Accept only the Windows HWND platform type (D5: Windows-only). + Steinberg::tresult PLUGIN_API isPlatformTypeSupported( + Steinberg::FIDString type) override; + + // The view is user-resizable in the spike so we exercise the onSize path. + Steinberg::tresult PLUGIN_API canResize() override; + +protected: + // CPluginView hooks: systemWindow is set by the time attachedToParent() fires. + void attachedToParent() override; + void removedFromParent() override; + + Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override; + +private: +#ifdef _WIN32 + // Draw the current surface into the child window's DC via a LICE bitmap. + void paint(HDC hdc); + // Route a client-space click through editor_geometry::hitTest. + void onClick(int x, int y); + + static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); + + HWND childHwnd_ = nullptr; +#endif + + ReaperBridge* bridge_ = nullptr; + // Latched on click so the paint reflects the last hit-test result — the spike's + // proof that host->click->draw routing round-trips. + bool buttonHit_ = false; +}; + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp new file mode 100644 index 0000000..565ee10 --- /dev/null +++ b/src/vst/reasampler_processor.cpp @@ -0,0 +1,82 @@ +// reasampler_processor.cpp — see reasampler_processor.h. + +#include "reasampler_processor.h" + +#include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/vstspeaker.h" + +#include "reasampler_editor.h" + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace reasampler::vst { + +FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { + // The host owns the returned reference. Cast up to the combined interface the SDK + // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. + return static_cast(new ReaSamplerProcessor()); +} + +tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { + tresult result = SingleComponentEffect::initialize(context); + if (result != kResultOk) return result; + + // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the + // instrument still loads, the editor just shows "no bridge". + bridge_.connect(context); + + // Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio + // output, no audio input. This is the standard VSTi arrangement. + addEventInput(STR16("MIDI In"), 16); + addAudioOutput(STR16("Stereo Out"), SpeakerArr::kStereo); + + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::terminate() { + return SingleComponentEffect::terminate(); +} + +tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool /*state*/) { + // Nothing to allocate/free in the silent skeleton; S4 will size voice buffers here + // against the setupProcessing block size. + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { + return SingleComponentEffect::setupProcessing(setup); +} + +tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { + // Silent skeleton: emit silence on the output bus so the instrument runs cleanly in + // REAPER's render/record path without a null buffer. S4 marshals MIDI->core->audio. + if (data.numOutputs > 0 && data.outputs && data.numSamples > 0) { + AudioBusBuffers& out = data.outputs[0]; + for (int32 ch = 0; ch < out.numChannels; ++ch) { + if (data.symbolicSampleSize == kSample32) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.f; + } + } else if (data.symbolicSampleSize == kSample64) { + if (double* buf = out.channelBuffers64[ch]) { + for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.0; + } + } + } + // Flag output silence so the host can optimize (nothing plays yet). + out.silenceFlags = (out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1); + } + return kResultOk; +} + +IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { + if (name && FIDStringsEqual(name, ViewType::kEditor)) { + return new ReaSamplerEditor(&bridge_); + } + return nullptr; +} + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h new file mode 100644 index 0000000..c189ec5 --- /dev/null +++ b/src/vst/reasampler_processor.h @@ -0,0 +1,48 @@ +// reasampler_processor.h — the VST3 SingleComponentEffect skeleton (Phase S1). THIN +// shell: an instrument that declares an event-input bus + a stereo audio-output bus, +// sets up processing, and runs an empty (silent) process. Nothing plays yet — S4 wires +// the pure sampler core into process(); S1 only proves REAPER hosts it. +// +// SingleComponentEffect is the SDK's combined processor+controller base — sanctioned +// for a non-distributable, REAPER-only plugin under D5/D6 (verified: SDK class +// reference). It gives us addAudioOutput/addEventInput and the IEditController seat, so +// createView() can hand the host our IPlugView LICE editor. + +#pragma once + +#include "public.sdk/source/vst/vstsinglecomponenteffect.h" + +#include "reaper_bridge.h" + +namespace reasampler::vst { + +class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect { +public: + ReaSamplerProcessor() = default; + + // The factory create function (registered in vst_entry.cpp). + static Steinberg::FUnknown* createInstance(void* /*context*/); + + //--- from IComponent / IPluginBase ------------------------------------- + // Connects the REAPER bridge (context is REAPER's IHostApplication) and declares + // the instrument bus topology. + Steinberg::tresult PLUGIN_API initialize(Steinberg::FUnknown* context) override; + Steinberg::tresult PLUGIN_API terminate() override; + Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override; + + //--- from IAudioProcessor ---------------------------------------------- + Steinberg::tresult PLUGIN_API setupProcessing( + Steinberg::Vst::ProcessSetup& setup) override; + // Empty in the spike: emits silence (S4 fills it). + Steinberg::tresult PLUGIN_API process( + Steinberg::Vst::ProcessData& data) override; + + //--- from IEditController ----------------------------------------------- + // Hands the host our LICE IPlugView editor. + Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; + +private: + ReaperBridge bridge_; +}; + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_vst.h b/src/vst/reasampler_vst.h new file mode 100644 index 0000000..120311b --- /dev/null +++ b/src/vst/reasampler_vst.h @@ -0,0 +1,36 @@ +// reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument +// (Phase S). One place for the plugin's class UID, name, vendor, and version so the +// processor, factory, and editor agree. +// +// The class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates this +// instrument records the UID, so changing it orphans every saved instance. Minted once +// for the spike; do not regenerate. + +#pragma once + +#include "pluginterfaces/base/funknown.h" + +namespace reasampler::vst { + +// Human-facing identity. "ReaSampler" is the tool; the instrument surfaces as +// "ReaSampler Instrument" in REAPER's FX browser to distinguish it from the extension. +inline constexpr const char* kPluginName = "ReaSampler Instrument"; +inline constexpr const char* kVendorName = "ReaSampler"; +inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/reasampler"; +inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com"; + +// The processor class UID (the SingleComponentEffect). FOREVER-STABLE once shipped — +// a saved REAPER project records it, so changing it orphans every saved instance. +// Minted once for the S1 spike (2026-07-26). Defined as four longs so the factory's +// INLINE_UID (compile-time brace init) and the runtime FUID below share one source. +#define REASAMPLER_PROC_UID_1 0x5E45A11E +#define REASAMPLER_PROC_UID_2 0x9C7B4D6A +#define REASAMPLER_PROC_UID_3 0xB1E3F208 +#define REASAMPLER_PROC_UID_4 0x4A6C1D9F + +static const Steinberg::FUID kReaSamplerProcessorUID(REASAMPLER_PROC_UID_1, + REASAMPLER_PROC_UID_2, + REASAMPLER_PROC_UID_3, + REASAMPLER_PROC_UID_4); + +} // namespace reasampler::vst diff --git a/src/vst/vst_entry.cpp b/src/vst/vst_entry.cpp new file mode 100644 index 0000000..d24ce1a --- /dev/null +++ b/src/vst/vst_entry.cpp @@ -0,0 +1,45 @@ +// vst_entry.cpp — the VST3 module class factory (Phase S1). Enumerates the one class +// this module offers (the ReaSampler instrument) via the SDK's factory macros. The +// Windows module exports — GetPluginFactory (here, via BEGIN_FACTORY) and +// InitDll/ExitDll (from the SDK's dllmain.cpp) — are how REAPER discovers and loads a +// VST3. +// +// VERIFIED (corrects §1a's "experienced estimate" flags on export names + macros, +// against vendor/vst3sdk/public.sdk/source/main/): +// * Windows exports: InitDll / ExitDll (SMTG_EXPORT_SYMBOL, in dllmain.cpp) + +// GetPluginFactory (SMTG_EXPORT_SYMBOL IPluginFactory* PLUGIN_API, emitted by the +// BEGIN_FACTORY macro). The plug-in must provide InitModule/DeinitModule — supplied +// here by linking moduleinit.cpp (the SDK's default one-time init/term). +// * Factory macros: BEGIN_FACTORY(vendor,url,email,flags) / DEF_CLASS2(...) / +// END_FACTORY — exact spellings from pluginfactory.h. +// * Instrument subcategory string: "Instrument|Synth|Sampler" +// (PlugType::kInstrumentSynthSampler, ivstaudioprocessor.h). +// * classFlags = 0 for a SingleComponentEffect (non-distributable), matching the +// AGain example. + +#include "public.sdk/source/main/pluginfactory.h" + +#include "pluginterfaces/vst/ivstaudioprocessor.h" // kVstAudioEffectClass, PlugType + +#include "reasampler_processor.h" +#include "reasampler_vst.h" + +// A concrete version string for PClassInfo2. Phase V owns the real version scheme; the +// spike ships a fixed 0.1.0. +#define REASAMPLER_VST_VERSION "0.1.0.0" + +BEGIN_FACTORY(reasampler::vst::kVendorName, reasampler::vst::kVendorUrl, + reasampler::vst::kVendorEmail, Steinberg::PFactoryInfo::kNoFlags) + +DEF_CLASS2(INLINE_UID(REASAMPLER_PROC_UID_1, REASAMPLER_PROC_UID_2, + REASAMPLER_PROC_UID_3, REASAMPLER_PROC_UID_4), + Steinberg::PClassInfo::kManyInstances, // cardinality + kVstAudioEffectClass, // component category (fixed) + reasampler::vst::kPluginName, // plug-in display name + 0, // single-component => 0 + Steinberg::Vst::PlugType::kInstrumentSynthSampler, // subcategory + REASAMPLER_VST_VERSION, // plug-in version + kVstVersionString, // VST3 SDK version (fixed) + reasampler::vst::ReaSamplerProcessor::createInstance) + +END_FACTORY diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp new file mode 100644 index 0000000..e3beb6d --- /dev/null +++ b/tests/test_bridge_marshal.cpp @@ -0,0 +1,116 @@ +// Standalone tests for reasampler::vst::bridge_marshal — no VST3, no REAPER, no test +// framework. Same fast assert loop as the sibling pure tests: assert the REAPER +// bridge-read marshalling (GetProjExtState result decode + a small JSON string-field +// reader) directly, so the DAW-facing shell only has to invoke the API. +// +// Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer +// guard); extractJsonStringField present/absent/escapes/whitespace/value-vs-key +// disambiguation/non-string-value/malformed. + +#include "../src/vst/bridge_marshal.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- decodeGetProjExtState ---------------------------------------------------- + +static void testDecodeHit() { + // REAPER reports a non-zero length and filled the buffer: that IS the value. + auto v = decodeGetProjExtState(5, "hello"); + CHECK(v.has_value()); + CHECK(v && *v == "hello"); +} + +static void testDecodeAbsentKey() { + // REAPER returns 0 for an absent key. Even if a caller passed a dirty buffer, the + // decoder must NOT surface it — the zero return means "no value". + auto v = decodeGetProjExtState(0, "stale-bytes-from-a-prior-read"); + CHECK(!v.has_value()); +} + +static void testDecodeNegativeReturn() { + auto v = decodeGetProjExtState(-1, "whatever"); + CHECK(!v.has_value()); +} + +static void testDecodeEmptyBuffer() { + // Positive return but empty buffer — treat as no value (defensive). + auto v = decodeGetProjExtState(3, ""); + CHECK(!v.has_value()); +} + +// --- extractJsonStringField --------------------------------------------------- + +static void testExtractPresent() { + const std::string json = R"({"guid":"ABC-123","name":"kick"})"; + auto g = extractJsonStringField(json, "guid"); + CHECK(g && *g == "ABC-123"); + auto n = extractJsonStringField(json, "name"); + CHECK(n && *n == "kick"); +} + +static void testExtractAbsent() { + const std::string json = R"({"guid":"ABC-123"})"; + CHECK(!extractJsonStringField(json, "missing").has_value()); +} + +static void testExtractWhitespaceTolerant() { + const std::string json = "{ \"guid\" : \"X\" , \"n\":\"y\" }"; + auto g = extractJsonStringField(json, "guid"); + CHECK(g && *g == "X"); +} + +static void testExtractEscapes() { + // \" \\ \/ \n \t all decode. + const std::string json = R"({"path":"a\\b\/c\"d\ne"})"; + auto p = extractJsonStringField(json, "path"); + CHECK(p && *p == "a\\b/c\"d\ne"); +} + +static void testExtractValueContainingKeyText() { + // A VALUE that contains the key text must not be mistaken for the member. Here the + // first "guid" occurrence is inside another value; the real member comes later. + const std::string json = R"({"note":"the guid is here","guid":"REAL"})"; + auto g = extractJsonStringField(json, "guid"); + CHECK(g && *g == "REAL"); +} + +static void testExtractNonStringValue() { + // A numeric/object value is not a string — return nullopt rather than garbage. + const std::string json = R"({"count":42,"name":"ok"})"; + CHECK(!extractJsonStringField(json, "count").has_value()); + // The sibling string field still reads. + auto n = extractJsonStringField(json, "name"); + CHECK(n && *n == "ok"); +} + +static void testExtractMalformed() { + CHECK(!extractJsonStringField(R"({"guid":"unterminated)", "guid").has_value()); + CHECK(!extractJsonStringField(R"({"guid":)", "guid").has_value()); + CHECK(!extractJsonStringField(R"({"guid")", "guid").has_value()); + CHECK(!extractJsonStringField("", "guid").has_value()); + // Dangling escape at end of string. + CHECK(!extractJsonStringField(R"({"guid":"abc\)", "guid").has_value()); +} + +int main() { + testDecodeHit(); + testDecodeAbsentKey(); + testDecodeNegativeReturn(); + testDecodeEmptyBuffer(); + testExtractPresent(); + testExtractAbsent(); + testExtractWhitespaceTolerant(); + testExtractEscapes(); + testExtractValueContainingKeyText(); + testExtractNonStringValue(); + testExtractMalformed(); + + if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp new file mode 100644 index 0000000..2a18a80 --- /dev/null +++ b/tests/test_editor_geometry.cpp @@ -0,0 +1,147 @@ +// Standalone tests for reasampler::vst::editor_geometry — no VST3, no REAPER, no test +// framework. Same fast assert loop as the sibling pure tests (mode_switch et al.): +// assert the IPlugView LICE editor's layout math + hit-testing directly. +// +// Covers: contains() half-open convention + degenerate rects; layoutEditor regions on a +// normal view (title band + button + canvas), a tiny view (button clamped to canvas, +// never overhanging), and a zero view (all rects empty, no inversion); hitTest hitting +// the button, missing on the title/canvas, missing outside the surface, and boundary +// pixels; layout<->hit-test agreement (a click on the drawn button rect hits it). + +#include "../src/vst/editor_geometry.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- contains() --------------------------------------------------------------- + +static void testContainsHalfOpen() { + Rect r{10, 20, 50, 40}; // [10,50) x [20,40) + CHECK(contains(r, 10, 20)); // top-left inclusive + CHECK(contains(r, 49, 39)); // bottom-right exclusive edge, inside + CHECK(!contains(r, 50, 30)); // right edge excluded + CHECK(!contains(r, 30, 40)); // bottom edge excluded + CHECK(!contains(r, 9, 30)); // left of rect + CHECK(!contains(r, 30, 19)); // above rect +} + +static void testContainsDegenerate() { + CHECK(!contains(Rect{10, 10, 10, 20}, 10, 15)); // zero width + CHECK(!contains(Rect{10, 10, 20, 10}, 15, 10)); // zero height + CHECK(!contains(Rect{20, 10, 10, 20}, 15, 15)); // inverted (right < left) +} + +// --- layoutEditor: normal view ------------------------------------------------ + +static void testLayoutNormalView() { + // A comfortable 400x260 view: title band spans the top full width; canvas is the + // rest; button sits inside the canvas, inset by the margin. + const EditorLayout L = layoutEditor(400, 260); + + CHECK(L.titleBar.left == 0 && L.titleBar.top == 0); + CHECK(L.titleBar.right == 400); + CHECK(L.titleBar.height() > 0 && L.titleBar.height() <= 260); + + // Canvas begins right below the title bar and reaches the bottom-right. + CHECK(L.canvas.top == L.titleBar.bottom); + CHECK(L.canvas.right == 400 && L.canvas.bottom == 260); + + // Button is inside the canvas (does not overhang any edge). + CHECK(L.button.left >= L.canvas.left); + CHECK(L.button.top >= L.canvas.top); + CHECK(L.button.right <= L.canvas.right); + CHECK(L.button.bottom <= L.canvas.bottom); + CHECK(L.button.width() > 0 && L.button.height() > 0); +} + +// --- layoutEditor: tiny view (clamping) --------------------------------------- + +static void testLayoutTinyViewClampsButton() { + // A view narrower/shorter than the button's natural size: the button must clamp to + // the canvas and never produce an inverted or overhanging rect. + const EditorLayout L = layoutEditor(40, 40); + CHECK(L.button.right <= L.canvas.right); + CHECK(L.button.bottom <= L.canvas.bottom); + CHECK(L.button.right >= L.button.left); // never inverted + CHECK(L.button.bottom >= L.button.top); + // Title bar clamps to the client height when the view is shorter than its height. + CHECK(L.titleBar.bottom <= 40); +} + +// --- layoutEditor: zero view (all empty, no inversion) ------------------------ + +static void testLayoutZeroView() { + const EditorLayout L = layoutEditor(0, 0); + CHECK(L.titleBar.width() <= 0 || L.titleBar.height() <= 0); + CHECK(L.canvas.width() <= 0 || L.canvas.height() <= 0); + // No rect is inverted. + CHECK(L.button.right >= L.button.left); + CHECK(L.button.bottom >= L.button.top); + CHECK(L.canvas.right >= L.canvas.left); + CHECK(L.canvas.bottom >= L.canvas.top); + // A click anywhere on an empty layout hits nothing. + CHECK(hitTest(L, 0, 0) == HitTarget::kNone); + CHECK(hitTest(L, 5, 5) == HitTarget::kNone); +} + +// --- hitTest ------------------------------------------------------------------ + +static void testHitTestButton() { + const EditorLayout L = layoutEditor(400, 260); + // Center of the button hits it. + const int cx = (L.button.left + L.button.right) / 2; + const int cy = (L.button.top + L.button.bottom) / 2; + CHECK(hitTest(L, cx, cy) == HitTarget::kButton); +} + +static void testHitTestMissesNonButton() { + const EditorLayout L = layoutEditor(400, 260); + // Title bar is inert in the spike. + CHECK(hitTest(L, 200, L.titleBar.top + 1) == HitTarget::kNone); + // Empty canvas away from the button. + CHECK(hitTest(L, 380, 240) == HitTarget::kNone); + // Outside the surface entirely. + CHECK(hitTest(L, -5, -5) == HitTarget::kNone); + CHECK(hitTest(L, 500, 500) == HitTarget::kNone); +} + +static void testHitTestButtonBoundary() { + const EditorLayout L = layoutEditor(400, 260); + // Top-left corner of the button is inclusive; the right/bottom edges are excluded. + CHECK(hitTest(L, L.button.left, L.button.top) == HitTarget::kButton); + CHECK(hitTest(L, L.button.right, L.button.top) == HitTarget::kNone); + CHECK(hitTest(L, L.button.left, L.button.bottom) == HitTarget::kNone); +} + +// --- layout<->hit-test agreement ---------------------------------------------- + +// Every pixel inside the drawn button rect must hit the button; this is the +// load-bearing consistency invariant between what the shell draws and what it routes. +static void testHitTestMatchesDrawnButton() { + const EditorLayout L = layoutEditor(320, 200); + for (int y = L.button.top; y < L.button.bottom; ++y) { + for (int x = L.button.left; x < L.button.right; ++x) { + CHECK(hitTest(L, x, y) == HitTarget::kButton); + } + } +} + +int main() { + testContainsHalfOpen(); + testContainsDegenerate(); + testLayoutNormalView(); + testLayoutTinyViewClampsButton(); + testLayoutZeroView(); + testHitTestButton(); + testHitTestMissesNonButton(); + testHitTestButtonBoundary(); + testHitTestMatchesDrawnButton(); + + if (g_fail == 0) std::printf("editor_geometry: all tests passed\n"); + return g_fail != 0; +} diff --git a/vendor/vst3sdk b/vendor/vst3sdk new file mode 160000 index 0000000..dfff2e3 --- /dev/null +++ b/vendor/vst3sdk @@ -0,0 +1 @@ +Subproject commit dfff2e399c1a638bd7f5e334440a61e7262eed3f From b5a573aebb4822d4b838ff693ada7746df6dddbb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 15:29:57 -0400 Subject: [PATCH 02/49] feat(bank_model): add Phase S seam fields (rootNote + loop points) to Sample Additive optional MIDI root note and sustain-loop points with JSON round-trip and deserialize-boundary validation; capture leaves them empty (not derivable). Mirrors the provenance addition; BankIndex behavior unchanged. --- src/bank_model.cpp | 57 +++++++++++++++- src/bank_model.h | 28 ++++++++ src/capture.cpp | 5 ++ src/realtime_record.cpp | 3 + tests/test_bank_model.cpp | 134 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 1 deletion(-) diff --git a/src/bank_model.cpp b/src/bank_model.cpp index e451219..a5286ec 100644 --- a/src/bank_model.cpp +++ b/src/bank_model.cpp @@ -35,6 +35,10 @@ bool Levels::operator==(const Levels& o) const { return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs; } +bool LoopPoints::operator==(const LoopPoints& o) const { + return start == o.start && end == o.end; +} + bool Sample::operator==(const Sample& o) const { return id == o.id && displayName == o.displayName && relativePath == o.relativePath && sourceMode == o.sourceMode && sourceRange == o.sourceRange && @@ -43,7 +47,8 @@ bool Sample::operator==(const Sample& o) const { lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats && captureTempo == o.captureTempo && captureTimeSigNum == o.captureTimeSigNum && - captureTimeSigDenom == o.captureTimeSigDenom && key == o.key && levels == o.levels && + captureTimeSigDenom == o.captureTimeSigDenom && key == o.key && + rootNote == o.rootNote && loop == o.loop && levels == o.levels && clipped == o.clipped && tier == o.tier && contentHash == o.contentHash && provenance == o.provenance && createdTimestamp == o.createdTimestamp; } @@ -253,6 +258,21 @@ void writeSample(std::string& out, const Sample& s) { w.keyBegin("key"); if (s.key) writeEscaped(out, *s.key); else out += "null"; + // Phase S seam fields (D-B). Emitted as null when absent (same shape as `key` + // and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely — + // parses to empty optionals and re-serializes without invention. + w.keyBegin("rootNote"); + if (s.rootNote) out += numToStr(*s.rootNote); else out += "null"; + + w.keyBegin("loop"); + if (s.loop) { + ObjWriter lp(out); + lp.keyRaw("start", numToStr(s.loop->start)); + lp.keyRaw("end", numToStr(s.loop->end)); + } else { + out += "null"; + } + w.keyBegin("levels"); { ObjWriter l(out); @@ -608,6 +628,41 @@ bool Parser::parseSample(Sample& s) { if (!parseString(k)) return false; s.key = k; } + } else if (key == "rootNote") { + bool wasNull = false; + if (!expectNullOr(wasNull)) return false; + if (wasNull) { + s.rootNote.reset(); + } else { + int v = 0; + if (!parseInt(v)) return false; + // Valid MIDI note range: 0..127 inclusive (boundaries valid). + if (v < 0 || v > 127) return false; + s.rootNote = v; + } + } else if (key == "loop") { + bool wasNull = false; + if (!expectNullOr(wasNull)) return false; + if (wasNull) { + s.loop.reset(); + } else { + if (!consume('{')) return false; + LoopPoints lp; + do { + std::string lk; + if (!parseKey(lk)) return false; + std::int64_t lv = 0; + if (!parseInt64(lv)) return false; + if (lk == "start") lp.start = lv; + else if (lk == "end") lp.end = lv; + } while (consume(',')); + if (!consume('}')) return false; + // Invariant: 0 <= start <= end. start == end is a valid zero-length + // marker; a negative index or start > end is malformed, not silently + // clamped (mirrors the enum-range rejection above). + if (lp.start < 0 || lp.end < lp.start) return false; + s.loop = lp; + } } else if (key == "levels") { if (!consume('{')) return false; do { diff --git a/src/bank_model.h b/src/bank_model.h index 19ce437..5ad8472 100644 --- a/src/bank_model.h +++ b/src/bank_model.h @@ -63,6 +63,22 @@ struct Levels { bool operator==(const Levels& o) const; }; +// Sample-accurate sustain-loop bounds, as frame indices into the captured file +// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like +// sampleRate or length — consumed by the future MIDI-playback instrument to hold +// notes past the recorded length. Modeled as one optional struct (not two loose +// optionals) so "both points or neither" is a structural invariant, not a rule to +// re-check at every boundary. Frame indices, not seconds, because the loop is a +// per-sample-frame contract; the instrument reads the file's sample rate to relate +// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end. +// start == end is a valid zero-length loop marker. +struct LoopPoints { + std::int64_t start = 0; + std::int64_t end = 0; + + bool operator==(const LoopPoints& o) const; +}; + // The metadata record for one captured sample. The audio itself lives in a // project-relative file; `relativePath` is ALWAYS relative (enforced at the // BankIndex::add boundary — see AddResult). @@ -95,6 +111,18 @@ struct Sample { std::optional key; // musical key, when known + // Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument, + // additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S + // samples deserialize without them and re-serialize without inventing values. + // - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument + // can repitch it across the keyboard. DISTINCT from the musical `key` above: + // `key` is a human label ("F#m"); `rootNote` is the exact pitch for repitch. + // Populated at/after capture only where derivable — left empty (never guessed) + // when the source is not a single played note. + // - loop: sustain-loop bounds, populated only where explicitly set. + std::optional rootNote; + std::optional loop; + Levels levels; bool clipped = false; diff --git a/src/capture.cpp b/src/capture.cpp index 48bb3b6..61c9113 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -530,6 +530,11 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { } } s.createdTimestamp = static_cast(std::time(nullptr)); + // Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a + // master mix / track / time-selection is not a single played note, so no root + // note is derivable here — we do NOT guess one. Loop points are set later by an + // explicit user action, not at capture. Leaving them empty is the honest default; + // the instrument (Phase S) treats an absent root note as "not a pitched sample". result.status = CaptureStatus::Ok; result.sample = s; diff --git a/src/realtime_record.cpp b/src/realtime_record.cpp index 9175005..9b287d1 100644 --- a/src/realtime_record.cpp +++ b/src/realtime_record.cpp @@ -58,6 +58,9 @@ Sample sampleFromRecordedCapture(const RecordedCapture& cap) { // finalized and on disk — the hash is over the finished file bytes. Left empty // here because sampleFromRecordedCapture runs before the file exists (the // mapping is pure / DAW-free); the shell patches it in after the move+trim. + // Phase S seam fields (rootNote / loop) left empty (D-B) — same reasoning as the + // offline path: a realtime record of wet output is not a single played note, so + // no root note is derivable; loop points are set by a later explicit action. s.createdTimestamp = cap.createdTimestamp; return s; } diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index 53aaf22..5de98db 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -36,6 +36,8 @@ static Sample fullSample(const std::string& seed) { s.captureTimeSigNum = 6; // L7 F1 meter stamp (non-4/4 to prove it round-trips) s.captureTimeSigDenom = 8; s.key = "F#m"; + s.rootNote = 60; // Phase S seam field (present) + s.loop = LoopPoints{4096, 65536}; // Phase S seam field (present) s.levels = {-0.3, -12.7, -14.2}; s.clipped = true; s.tier = Tier::Archive; @@ -86,11 +88,18 @@ static void testFullFieldRoundTrip() { CHECK(minMeter && minMeter->captureTimeSigNum == 0 && minMeter->captureTimeSigDenom == 0); CHECK(full && full->provenance.has_value()); CHECK(full && full->provenance->fxChainSnapshot == ""); + // Phase S seam fields survive round-trip exactly. + CHECK(full && full->rootNote.has_value() && *full->rootNote == 60); + CHECK(full && full->loop.has_value()); + CHECK(full && full->loop && full->loop->start == 4096 && full->loop->end == 65536); const Sample* min = back->query("min-b"); CHECK(min && !min->key.has_value()); CHECK(min && !min->provenance.has_value()); CHECK(min && min->trackGuids.empty()); + // Seam fields absent on the minimal sample and stay absent. + CHECK(min && !min->rootNote.has_value()); + CHECK(min && !min->loop.has_value()); } } @@ -417,6 +426,128 @@ static void testEnumRangeValidation() { CHECK(!r2.has_value()); } +// S2 test case 2: a legacy Sample JSON — written before the Phase S seam fields +// existed, so it has NO "rootNote" or "loop" keys at all — parses to clean empty +// optionals (no loss, no migration) and re-serializes without inventing values. +// (The parser's forward-compat unknown-key skipping is what makes the reverse case +// — new keys ignored by an old parser — safe too; here we test old-JSON→new-parser.) +static void testLegacyJsonDefaults() { + const char* legacy = + "{\"samples\":[{\"id\":\"leg1\",\"relativePath\":\"bank/leg.wav\"," + "\"displayName\":\"legacy\"," + "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":1.5,\"endSeconds\":2.5," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":2,\"sampleRate\":48000," + "\"lengthSeconds\":1.0,\"lengthBeats\":0.0,\"captureTempo\":120.0," + "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-leg1\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"; + auto r = BankIndex::deserialize(legacy); + CHECK(r.has_value()); + if (r) { + const Sample* s = r->query("leg1"); + CHECK(s != nullptr); + CHECK(s && !s->rootNote.has_value()); // clean default, not a guessed value + CHECK(s && !s->loop.has_value()); + + // Re-serialize is lossless: parsing it again yields an equal index. This + // proves the absent fields did not silently gain values on the way out. + std::string out = r->serialize(); + auto again = BankIndex::deserialize(out); + CHECK(again.has_value()); + CHECK(again && *again == *r); + if (again) { + const Sample* s2 = again->query("leg1"); + CHECK(s2 && !s2->rootNote.has_value()); + CHECK(s2 && !s2->loop.has_value()); + } + } +} + +// S2 test case 4: boundary values for the seam fields are representable and +// round-trip. rootNote 0 and 127 (the MIDI edges); loopStart == loopEnd (a valid +// zero-length marker); a loop whose end sits at the file's last frame. Also asserts +// the deserialize-boundary validation rules reject out-of-range input rather than +// storing a bogus value. +static void testSeamFieldBoundaries() { + // rootNote at both MIDI edges + equal-and-end-anchored loop points round-trip. + BankIndex idx; + Sample lo = minimalSample("lo"); lo.contentHash = "h-lo"; + lo.rootNote = 0; + lo.loop = LoopPoints{0, 0}; // zero-length marker at frame 0 + Sample hi = minimalSample("hi"); hi.contentHash = "h-hi"; + hi.rootNote = 127; + hi.loop = LoopPoints{100, 100}; // start == end elsewhere + Sample end = minimalSample("end"); end.contentHash = "h-end"; + end.loop = LoopPoints{0, 9223372036854775807LL}; // end at max int64 frame + CHECK(idx.add(lo) == AddResult::Added); + CHECK(idx.add(hi) == AddResult::Added); + CHECK(idx.add(end) == AddResult::Added); + + auto back = BankIndex::deserialize(idx.serialize()); + CHECK(back.has_value()); + CHECK(back && *back == idx); + if (back) { + CHECK(back->query("min-lo")->rootNote == 0); + CHECK(back->query("min-hi")->rootNote == 127); + // Named local: a brace-init with a comma inside CHECK(...) would be parsed + // as two macro arguments by the preprocessor. + const LoopPoints zeroLen{0, 0}; + CHECK(back->query("min-lo")->loop == zeroLen); + CHECK(back->query("min-end")->loop->end == 9223372036854775807LL); + } + + // Validation rule (chosen for this design, surfaced in the handoff): + // rootNote must be 0..127; loop must satisfy 0 <= start <= end. + // Out-of-range input is rejected at the deserialize boundary (nullopt), mirroring + // the existing enum-range and integer-overflow rejections — never clamped. + const char* head = + "{\"samples\":[{\"id\":\"bad\",\"relativePath\":\"bank/b.wav\"," + "\"displayName\":\"\",\"sourceMode\":0," + "\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100," + "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0," + "\"key\":null,"; + const char* tail = + "\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-bad\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"; + + CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value()); + CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value()); + CHECK(!BankIndex::deserialize( + std::string(head) + "\"loop\":{\"start\":10,\"end\":5}," + tail).has_value()); // start > end + CHECK(!BankIndex::deserialize( + std::string(head) + "\"loop\":{\"start\":-1,\"end\":5}," + tail).has_value()); // negative start +} + +// S2 test case 3: the seam-field addition is purely additive — dedup-by-hash, tier +// moves/filtering, and BankIndex ordering are byte-for-byte unchanged by the +// presence (or absence) of rootNote/loop. Two samples differing ONLY in seam fields +// but sharing a content hash still collapse; a seam-populated sample tiers exactly +// like any other. +static void testSeamFieldsAdditiveInvariant() { + BankIndex idx; + Sample a = fullSample("z"); // has rootNote + loop populated + CHECK(idx.add(a) == AddResult::Added); + + // Same hash, seam fields cleared — dedup keys off contentHash only, so this + // still collapses. Seam fields do NOT enter the dedup identity. + Sample dup = fullSample("z2"); + dup.contentHash = a.contentHash; + dup.rootNote.reset(); + dup.loop.reset(); + CHECK(idx.add(dup) == AddResult::Collapsed); + CHECK(idx.size() == 1); + + // Tier move on a seam-populated sample behaves exactly as before. + CHECK(idx.query("id-z")->tier == Tier::Archive); + CHECK(idx.moveTier("id-z", Tier::Scratch)); + CHECK(idx.query("id-z")->tier == Tier::Scratch); + CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields +} + int main() { testFullFieldRoundTrip(); testDedupByHash(); @@ -430,6 +561,9 @@ int main() { testUnicodeEscapeDecoding(); testIntegerOverflow(); testEnumRangeValidation(); + testLegacyJsonDefaults(); + testSeamFieldBoundaries(); + testSeamFieldsAdditiveInvariant(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; From 74d2e976e719f8336d60dd0d58dcf2ba1f6fb641 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 15:58:30 -0400 Subject: [PATCH 03/49] =?UTF-8?q?S3:=20pure=20sampler=20core=20=E2=80=94?= =?UTF-8?q?=20voices,=20ADSR,=20keymap,=20loop-aware=20repitch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REAPER-free and VST3-free voice engine with bounded stealing, ADSR envelope, key/velocity keymap resolution, and repitch from root note with loop-point sustain. Test target links neither SDK. --- CMakeLists.txt | 23 ++ src/vst/sampler_core.cpp | 309 +++++++++++++++++++++ src/vst/sampler_core.h | 262 ++++++++++++++++++ tests/test_sampler_core.cpp | 528 ++++++++++++++++++++++++++++++++++++ 4 files changed, 1122 insertions(+) create mode 100644 src/vst/sampler_core.cpp create mode 100644 src/vst/sampler_core.h create mode 100644 tests/test_sampler_core.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3025dde..cdd05ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -446,6 +446,22 @@ add_library(card_drag STATIC src/card_drag.cpp) target_include_directories(card_drag PUBLIC src) target_link_libraries(card_drag PUBLIC drag_out bank_grid) +# --------------------------------------------------------------------------- +# 2v) Pure sampler_core library — NO VST3, NO REAPER, NO SWELL. The HEART of the +# Phase S MIDI-playback instrument (S3 / D3): polyphonic voice allocation with +# bounded stealing, an ADSR amplitude envelope, a key/velocity keymap with +# (note, velocity) -> zone resolution, and repitch/interpolation from a root note +# with loop-point-aware sustain. The mirror of bank_model / peaks / bank_book, +# tested hard outside any host. Lives under src/vst/ (it is instrument code) but +# links NEITHER SDK — the plain-data boundary is enforced structurally: the test +# target below links only sampler_core (+ its peaks dep for the AudioSample alias, +# the one house precedent wav_trim also relies on). The VST3 shell (src/vst/ +# reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified. +# --------------------------------------------------------------------------- +add_library(sampler_core STATIC src/vst/sampler_core.cpp) +target_include_directories(sampler_core PUBLIC src src/vst) +target_link_libraries(sampler_core PUBLIC peaks) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -584,6 +600,13 @@ add_executable(card_drag_tests tests/test_card_drag.cpp) target_link_libraries(card_drag_tests PRIVATE card_drag) add_test(NAME card_drag_tests COMMAND card_drag_tests) +# sampler_core: the S3 heart. Links ONLY sampler_core (+ its peaks dep) — NEITHER the +# VST3 SDK nor the REAPER SDK — which is the structural proof of the plain-data +# boundary (a VST3/REAPER type in the core would fail to compile/link here). +add_executable(sampler_core_tests tests/test_sampler_core.cpp) +target_link_libraries(sampler_core_tests PRIVATE sampler_core) +add_test(NAME sampler_core_tests COMMAND sampler_core_tests) + # --------------------------------------------------------------------------- # 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE. # editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp new file mode 100644 index 0000000..2325a3e --- /dev/null +++ b/src/vst/sampler_core.cpp @@ -0,0 +1,309 @@ +// sampler_core — pure sampler engine implementation. See sampler_core.h for the +// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape, +// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes. + +#include "sampler_core.h" + +#include + +namespace reasampler { + +// --------------------------------------------------------------------------- +// pitchRatio +// --------------------------------------------------------------------------- + +double pitchRatio(int note, int rootNote) { + // Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0. + return std::pow(2.0, static_cast(note - rootNote) / 12.0); +} + +// --------------------------------------------------------------------------- +// Keymap +// --------------------------------------------------------------------------- + +ZoneResolution Keymap::resolve(int note, int velocity) const { + (void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1. + for (std::size_t i = 0; i < zones.size(); ++i) { + const KeyZone& z = zones[i]; + if (note >= z.lowNote && note <= z.highNote) { + return ZoneResolution{true, i}; + } + } + return ZoneResolution{false, 0}; +} + +Keymap Keymap::singleSampleChromatic(SampleData sample) { + const int root = sample.rootNote; + Keymap km; + km.samples.push_back(std::move(sample)); + KeyZone zone; + zone.lowNote = 0; + zone.highNote = 127; + zone.rootNote = root; + zone.sampleIndex = 0; + km.zones.push_back(zone); + return km; +} + +// --------------------------------------------------------------------------- +// AdsrEnvelope +// --------------------------------------------------------------------------- + +void AdsrEnvelope::noteOn() { + stage_ = Stage::Attack; + level_ = 0.0; + framesInStage_ = 0; +} + +void AdsrEnvelope::noteOff() { + if (stage_ == Stage::Idle || stage_ == Stage::Finished || + stage_ == Stage::Release) { + return; // already released / not sounding. + } + // Release from the CURRENT level — release-before-sustain releases from the + // partial attack/decay level, not from sustainLevel. + releaseFrom_ = level_; + stage_ = Stage::Release; + framesInStage_ = 0; +} + +double AdsrEnvelope::tick() { + switch (stage_) { + case Stage::Idle: + case Stage::Finished: + level_ = 0.0; + return 0.0; + + case Stage::Attack: { + if (params_.attackFrames <= 0) { + level_ = 1.0; + } else { + level_ = static_cast(framesInStage_) / + static_cast(params_.attackFrames); + if (level_ > 1.0) level_ = 1.0; + } + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.attackFrames) { + stage_ = Stage::Decay; + framesInStage_ = 0; + level_ = 1.0; + } + return out; + } + + case Stage::Decay: { + if (params_.decayFrames <= 0) { + level_ = params_.sustainLevel; + } else { + const double t = static_cast(framesInStage_) / + static_cast(params_.decayFrames); + level_ = 1.0 + (params_.sustainLevel - 1.0) * t; + } + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.decayFrames) { + stage_ = Stage::Sustain; + framesInStage_ = 0; + level_ = params_.sustainLevel; + } + return out; + } + + case Stage::Sustain: + level_ = params_.sustainLevel; + return level_; + + case Stage::Release: { + if (params_.releaseFrames <= 0) { + level_ = 0.0; + stage_ = Stage::Finished; + return 0.0; + } + const double t = static_cast(framesInStage_) / + static_cast(params_.releaseFrames); + level_ = releaseFrom_ * (1.0 - t); + if (level_ < 0.0) level_ = 0.0; + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.releaseFrames) { + stage_ = Stage::Finished; + level_ = 0.0; + } + return out; + } + } + return 0.0; // unreachable; silences a warning. +} + +// --------------------------------------------------------------------------- +// Voice +// --------------------------------------------------------------------------- + +void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, + const AdsrParams& adsr) { + active_ = true; + releasing_ = false; + note_ = note; + // MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively. + int v = velocity; + if (v < 0) v = 0; + if (v > 127) v = 127; + velocityGain_ = static_cast(v) / 127.0; + ratio_ = pitchRatio(note, rootNote); + readPos_ = 0.0; + sample_ = &sample; + env_.configure(adsr); + env_.noteOn(); +} + +void Voice::release() { + if (!active_) return; + releasing_ = true; + env_.noteOff(); +} + +AudioSample Voice::renderFrame() { + if (!active_ || sample_ == nullptr) return 0.0f; + + const std::vector& pcm = sample_->frames; + const std::int64_t frameCount = static_cast(pcm.size()); + + // Loop-aware sustain: if a valid, non-zero-length loop exists and the read head + // has advanced past the loop end, wrap it back into [start, end). A zero-length + // loop (start == end) is treated as "no loop" — the note is allowed to run off + // the sample end and go silent, rather than spinning on a zero span. + const SampleLoop& loop = sample_->loop; + const bool loopUsable = loop.hasLoop && loop.end > loop.start && + loop.start >= 0 && loop.end <= frameCount; + if (loopUsable) { + const std::int64_t loopStart = loop.start; + const std::int64_t loopEnd = loop.end; + const double loopLen = static_cast(loopEnd - loopStart); + while (readPos_ >= static_cast(loopEnd)) { + readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase. + } + } + + // Ran off the end with no usable loop -> voice is done. + if (readPos_ >= static_cast(frameCount)) { + active_ = false; + return 0.0f; + } + + // Linear interpolation between the two bracketing frames. For the loop case, the + // second point wraps to loopStart so the seam is continuous. + const std::int64_t i0 = static_cast(readPos_); + const double frac = readPos_ - static_cast(i0); + std::int64_t i1 = i0 + 1; + if (loopUsable && i1 >= loop.end) { + i1 = loop.start; // seamless wrap for the interpolation partner. + } + const double s0 = (i0 >= 0 && i0 < frameCount) ? static_cast(pcm[i0]) : 0.0; + const double s1 = (i1 >= 0 && i1 < frameCount) ? static_cast(pcm[i1]) : 0.0; + const double interp = s0 + (s1 - s0) * frac; + + const double amp = env_.tick(); + const double out = interp * amp * velocityGain_; + + readPos_ += ratio_; + + if (env_.finished()) { + active_ = false; + } + return static_cast(out); +} + +// --------------------------------------------------------------------------- +// VoiceEngine +// --------------------------------------------------------------------------- + +VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, + const AdsrParams& adsr) + : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr) { + // maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on; + // clamp to a single voice so the engine is always usable (documented degenerate). +} + +std::size_t VoiceEngine::allocateVoice() { + // 1. A free (idle) voice, lowest index for determinism. + for (std::size_t i = 0; i < voices_.size(); ++i) { + if (!voices_[i].active()) return i; + } + // 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail), + // else the oldest voice overall. "Oldest" = smallest startOrder. + std::size_t bestReleasing = kNoVoice; + std::uint64_t bestReleasingOrder = 0; + std::size_t bestOverall = kNoVoice; + std::uint64_t bestOverallOrder = 0; + for (std::size_t i = 0; i < voices_.size(); ++i) { + const std::uint64_t order = voices_[i].startOrder(); + if (voices_[i].releasing()) { + if (bestReleasing == kNoVoice || order < bestReleasingOrder) { + bestReleasing = i; + bestReleasingOrder = order; + } + } + if (bestOverall == kNoVoice || order < bestOverallOrder) { + bestOverall = i; + bestOverallOrder = order; + } + } + return bestReleasing != kNoVoice ? bestReleasing : bestOverall; +} + +std::size_t VoiceEngine::noteOn(int note, int velocity) { + const ZoneResolution res = keymap_.resolve(note, velocity); + if (!res.matched) return kNoVoice; // out-of-zone: defined no-play. + + const KeyZone& zone = keymap_.zones[res.zoneIndex]; + if (zone.sampleIndex >= keymap_.samples.size()) { + return kNoVoice; // zone points at a missing sample — refuse rather than UB. + } + const SampleData& sample = keymap_.samples[zone.sampleIndex]; + + const std::size_t v = allocateVoice(); + voices_[v].start(note, velocity, sample, zone.rootNote, adsr_); + voices_[v].setStartOrder(nextStartOrder_++); + return v; +} + +void VoiceEngine::noteOff(int note) { + // Release the NEWEST active, non-releasing voice on this note (largest startOrder), + // so a re-triggered note releases its newest instance first and older tails ring. + std::size_t target = kNoVoice; + std::uint64_t bestOrder = 0; + for (std::size_t i = 0; i < voices_.size(); ++i) { + if (voices_[i].active() && !voices_[i].releasing() && + voices_[i].note() == note) { + const std::uint64_t order = voices_[i].startOrder(); + if (target == kNoVoice || order > bestOrder) { + target = i; + bestOrder = order; + } + } + } + if (target != kNoVoice) voices_[target].release(); +} + +void VoiceEngine::render(std::vector& out, std::size_t frameCount) { + const std::size_t base = out.size(); + out.resize(base + frameCount, 0.0f); + for (Voice& voice : voices_) { + if (!voice.active()) continue; + for (std::size_t f = 0; f < frameCount; ++f) { + if (!voice.active()) break; + out[base + f] += voice.renderFrame(); + } + } +} + +std::size_t VoiceEngine::activeVoiceCount() const { + std::size_t n = 0; + for (const Voice& v : voices_) { + if (v.active()) ++n; + } + return n; +} + +} // namespace reasampler diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h new file mode 100644 index 0000000..a51bccc --- /dev/null +++ b/src/vst/sampler_core.h @@ -0,0 +1,262 @@ +#pragma once +// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately +// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW +// and outside any plugin host. It owns the pure sampler engine: polyphonic voice +// allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap +// with (note, velocity) -> zone resolution, and repitch/interpolation from a root note +// with loop-point-aware sustain. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL, +// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell +// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from +// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced +// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i). +// +// It shares the `AudioSample` float alias from peaks — the one house precedent for a +// pure module leaning on peaks for the audio-domain type (wav_trim does the same). The +// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the +// core does no file I/O — it is handed decoded sample frames and produces audio frames. + +#include +#include +#include + +#include "peaks.h" // AudioSample (float) + +namespace reasampler { + +// --------------------------------------------------------------------------- +// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that +// govern playback. The shell decodes the on-disk WAV and fills this; the core +// never touches a file. +// --------------------------------------------------------------------------- + +// A loop over [start, end) frames, half-open. A zero-length loop (start == end) +// is the "no sustain loop" marker — a held note past the sample end goes silent +// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false. +struct SampleLoop { + bool hasLoop = false; + std::int64_t start = 0; // first looped frame (inclusive) + std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end +}; + +// One decoded audio sample the engine can voice. `frames` is DEINTERLEAVED-agnostic: +// the core plays a single mono stream per sample (Tier 0-1 scope), so `frames` is one +// channel's PCM at `sampleRate`. `rootNote` is the MIDI note the file was recorded at +// (S2 intrinsic) — the pitch that plays back at unity ratio. +struct SampleData { + std::vector frames; // mono PCM, one value per frame + int sampleRate = 44100; // frames per second (for reference; ratio is + // note-relative, so rate cancels for repitch) + int rootNote = 60; // MIDI note recorded at (plays at unity here) + SampleLoop loop; // sustain loop, if any +}; + +// --------------------------------------------------------------------------- +// Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves +// to at most one zone; a zone names which SampleData to play and the root note to +// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the +// sample's own root. Tier-1: several zones, each a key range with its own root. +// +// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built: +// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone +// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and +// resolve() gains the velocity dimension it already receives but currently ignores +// for selection. The (note, velocity) signature and the "resolve to a zone, then a +// sample within it" shape are already in place — Tier 2 fills in the second step +// without changing callers or the voice engine. See the report note. +// --------------------------------------------------------------------------- + +// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with +// the root note to repitch from (defaults to the sample's own root, overridable in +// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a +// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention). +struct KeyZone { + int lowNote = 0; + int highNote = 127; + int rootNote = 60; // repitch reference for this zone + std::size_t sampleIndex = 0; // index into Keymap::samples +}; + +// Result of resolving a (note, velocity). `matched == false` means the note falls in +// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0. +struct ZoneResolution { + bool matched = false; + std::size_t zoneIndex = 0; // valid only when matched +}; + +// The keymap: the decoded samples plus the zones that map keys onto them. Owns +// resolution. Pure: no host types. Zones are tested first-match in order, so an +// earlier zone wins an overlap (deterministic, documented). +struct Keymap { + std::vector samples; + std::vector zones; + + // Resolves (note, velocity) to a zone. First zone (in order) whose [low,high] + // contains `note` wins. velocity is accepted now (Tier-2 seam) but does not + // affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains + // the note. + ZoneResolution resolve(int note, int velocity) const; + + // Convenience: build the Tier-0 degenerate keymap — one sample mapped + // chromatically across the whole keyboard from its own root note. + static Keymap singleSampleChromatic(SampleData sample); +}; + +// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`: +// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0, +// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed. +double pitchRatio(int note, int rootNote); + +// --------------------------------------------------------------------------- +// ADSR amplitude envelope. Sample-based (times in frames), linear segments. A gate: +// noteOn() enters Attack; noteOff() enters Release from wherever it is. The classic +// four-stage shape, asserted against a known signal in the tests (mirror of peaks). +// +// Segment math (all linear ramps): +// Attack: 0 -> 1 over attackFrames +// Decay: 1 -> sustainLevel over decayFrames +// Sustain: hold sustainLevel until noteOff +// Release: currentLevel -> 0 over releaseFrames +// A zero-length attack jumps straight to 1 on the first frame; zero decay jumps to +// sustain; a noteOff during attack/decay (release-before-sustain) releases from the +// current partial level, not from sustainLevel. +// --------------------------------------------------------------------------- + +struct AdsrParams { + std::int64_t attackFrames = 0; + std::int64_t decayFrames = 0; + double sustainLevel = 1.0; // 0..1 + std::int64_t releaseFrames = 0; +}; + +class AdsrEnvelope { +public: + enum class Stage { Idle, Attack, Decay, Sustain, Release, Finished }; + + void configure(const AdsrParams& params) { params_ = params; } + + // Gate on: (re)start from Attack. + void noteOn(); + // Gate off: enter Release from the current level. + void noteOff(); + + // Advances one frame and returns the amplitude for THIS frame (before advancing). + // Once Release completes the envelope latches Finished and returns 0.0 forever + // (until the next noteOn). A single, monotonic per-frame step — the caller pulls + // one value per output frame. + double tick(); + + Stage stage() const { return stage_; } + bool finished() const { return stage_ == Stage::Finished; } + double level() const { return level_; } + +private: + AdsrParams params_; + Stage stage_ = Stage::Idle; + double level_ = 0.0; + std::int64_t framesInStage_ = 0; + double releaseFrom_ = 0.0; // level at the moment noteOff() was called +}; + +// --------------------------------------------------------------------------- +// A single voice: one active note playing one repitched, enveloped sample. Reads +// the sample by fractional frame position with linear interpolation, advancing by +// the pitch ratio; loops the sustain region for held notes past the loop end. +// --------------------------------------------------------------------------- + +class Voice { +public: + // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference + // the caller must keep alive for the voice's lifetime — the Keymap owns it), + // repitched from `rootNote`, with `adsr` as the amplitude envelope. + void start(int note, int velocity, const SampleData& sample, int rootNote, + const AdsrParams& adsr); + + // Gate off — begins the amplitude release. The voice keeps rendering (and looping, + // if it would) until the release finishes, then goes idle. + void release(); + + // True while this voice is producing (or about to produce) sound. + bool active() const { return active_; } + // The note this voice was started on (for note-off routing). Meaningless if idle. + int note() const { return note_; } + // Monotonic age counter — higher = started earlier relative to others. The voice + // engine uses this for its stealing policy (oldest first). Set by the engine. + std::uint64_t startOrder() const { return startOrder_; } + void setStartOrder(std::uint64_t order) { startOrder_ = order; } + bool releasing() const { return releasing_; } + + // Renders one frame's contribution, advancing the read head and envelope by one + // output frame. Returns 0.0 (and goes idle) once the envelope finishes or the + // sample runs out with no loop. The value is already velocity- and + // envelope-scaled — the engine sums voices directly. + AudioSample renderFrame(); + +private: + bool active_ = false; + bool releasing_ = false; + int note_ = 0; + double velocityGain_ = 1.0; + double ratio_ = 1.0; // fractional frames advanced per output frame + double readPos_ = 0.0; // fractional frame index into the sample + const SampleData* sample_ = nullptr; + AdsrEnvelope env_; + std::uint64_t startOrder_ = 0; +}; + +// --------------------------------------------------------------------------- +// The polyphonic voice engine: a fixed pool of voices, note-on allocation with +// bounded voice stealing, note-off routing, and block rendering (sum of voices). +// +// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a +// new note-on arrives, steal in this priority order: +// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut), +// 2. else the oldest voice overall (longest-held note gives way to the new one). +// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the +// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing +// that, the note that has already had the most time. +// --------------------------------------------------------------------------- + +class VoiceEngine { +public: + // Builds an engine with `maxVoices` voices (the polyphony bound) playing from + // `keymap`. The keymap must outlive the engine (the engine holds a reference — it + // reads zones and sample data through it, never copies PCM). + VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr); + + // MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of + // zone) it is a defined no-op (no voice consumed). Otherwise allocates a free + // voice, or steals one per the policy above. Returns the index of the voice used, + // or kNoVoice for an out-of-zone (unplayed) note. + std::size_t noteOn(int note, int velocity); + + // MIDI note-off. Releases the most-recently-started active, non-releasing voice + // playing `note` (so a re-triggered same note releases the newest first, leaving + // the older tail to ring — matches hardware behavior). No-op if none match. + void noteOff(int note); + + // Renders `frameCount` mono output frames, summing all active voices, appending to + // `out` (does not clear it — the caller owns mixing/clearing). Voices that finish + // mid-block go idle and stop contributing. + void render(std::vector& out, std::size_t frameCount); + + // Count of currently active voices (for tests / diagnostics). + std::size_t activeVoiceCount() const; + + std::size_t maxVoices() const { return voices_.size(); } + + static constexpr std::size_t kNoVoice = static_cast(-1); + +private: + // Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen + // one per the documented policy. Always returns a valid index (maxVoices >= 1). + std::size_t allocateVoice(); + + std::vector voices_; + const Keymap& keymap_; + AdsrParams adsr_; + std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started" +}; + +} // namespace reasampler diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp new file mode 100644 index 0000000..e885c1e --- /dev/null +++ b/tests/test_sampler_core.cpp @@ -0,0 +1,528 @@ +// Standalone tests for reasampler::sampler_core — no VST3, no REAPER, no test +// framework. Same fast build/run loop as bank_model_tests / peaks_tests: feed known +// inputs, assert the engine's behavior. +// +// Covers (PLAN.md S3 / CONTEXT.md §Phase S pure core): +// 1. polyphonic allocation — N notes -> N voices; note-off releases the right voice. +// 2. voice stealing at the bound — deterministic policy (release-first, then oldest). +// 3. ADSR envelope shape vs a known signal, incl. release-before-sustain. +// 4. repitch ratio correctness across +/-1 octave from root incl. unity, asserted on +// the observed period of a synthesized sine. +// 5. loop-point sustain — held note past sample end loops [start,end) seamlessly; +// zero-length loop and absent-loop behavior. +// 6. keymap: chromatic-from-single-root; zoned ranges with boundary notes; velocity +// -> volume; out-of-zone note -> defined no-play. +// +// The plain-data boundary (no VST3/REAPER types in the core) is enforced STRUCTURALLY +// by the CMake target linking neither SDK — this file includes only sampler_core.h + +// the standard library, which is itself the compile-time proof. + +#include "../src/vst/sampler_core.h" + +#include +#include +#include + +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) + +static bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; } + +constexpr double kPi = 3.14159265358979323846; + +// A silent (all-1.0) sample so a rendered voice's output tracks the envelope * velocity +// directly (DC of amplitude 1). Root at note 60 by default. +static SampleData dcSample(std::size_t frames, int rootNote = 60) { + SampleData s; + s.frames.assign(frames, 1.0f); + s.rootNote = rootNote; + return s; +} + +// A mono sine of `cycles` periods over `frames` frames — used to observe repitch by +// measuring the played-back period. +static SampleData sineSample(std::size_t frames, double cycles, int rootNote = 60) { + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * cycles * + static_cast(i) / static_cast(frames))); + } + s.rootNote = rootNote; + return s; +} + +// An ADSR that stays fully open (level 1) forever while held, so voice output equals +// velocity gain — isolates allocation/repitch/loop tests from envelope shaping. +static AdsrParams flatAdsr() { + AdsrParams a; + a.attackFrames = 0; + a.decayFrames = 0; + a.sustainLevel = 1.0; + a.releaseFrames = 0; // note-off -> instant silence. + return a; +} + +// --------------------------------------------------------------------------- +// 6. Keymap resolution. +// --------------------------------------------------------------------------- + +static void testChromaticSingleRoot() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); + CHECK(km.zones.size() == 1); + // Every note in 0..127 resolves to the single zone. + for (int n = 0; n <= 127; ++n) { + ZoneResolution r = km.resolve(n, 100); + CHECK(r.matched); + CHECK(r.zoneIndex == 0); + } +} + +static void testZonedRangesBoundaries() { + Keymap km; + km.samples.push_back(dcSample(100, 48)); // low sample + km.samples.push_back(dcSample(100, 72)); // high sample + // Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the + // correct zone; a first-match order test would catch an off-by-one. + km.zones.push_back(KeyZone{36, 59, 48, 0}); + km.zones.push_back(KeyZone{60, 83, 72, 1}); + + CHECK(km.resolve(36, 100).matched); + CHECK(km.resolve(36, 100).zoneIndex == 0); + CHECK(km.resolve(59, 100).zoneIndex == 0); // last note of zone 0 + CHECK(km.resolve(60, 100).zoneIndex == 1); // first note of zone 1 + CHECK(km.resolve(83, 100).zoneIndex == 1); // last note of zone 1 + + // Out of every zone -> defined no-play (not a match, not zone 0). + CHECK(!km.resolve(35, 100).matched); + CHECK(!km.resolve(84, 100).matched); + CHECK(!km.resolve(127, 100).matched); +} + +static void testFirstMatchOnOverlap() { + // Overlapping zones: the earlier zone wins (documented deterministic rule). + Keymap km; + km.samples.push_back(dcSample(10, 60)); + km.samples.push_back(dcSample(10, 60)); + km.zones.push_back(KeyZone{0, 127, 60, 0}); // catch-all first + km.zones.push_back(KeyZone{60, 60, 60, 1}); // shadowed by the catch-all + CHECK(km.resolve(60, 100).zoneIndex == 0); +} + +// --------------------------------------------------------------------------- +// 4. Repitch ratio correctness. +// --------------------------------------------------------------------------- + +static void testPitchRatioMath() { + CHECK(approx(pitchRatio(60, 60), 1.0, 1e-9)); // unity at root + CHECK(approx(pitchRatio(72, 60), 2.0, 1e-9)); // +1 octave + CHECK(approx(pitchRatio(48, 60), 0.5, 1e-9)); // -1 octave + CHECK(approx(pitchRatio(61, 60), std::pow(2.0, 1.0 / 12.0), 1e-9)); // +1 semitone +} + +// Observe repitch on the rendered signal: a voice played an octave above root should +// advance through the sample twice as fast, so a sine's observed period halves. We +// measure the period by counting the interval between positive-going zero crossings. +static double observedPeriodFrames(const std::vector& out) { + std::vector upCrossings; + for (std::size_t i = 1; i < out.size(); ++i) { + if (out[i - 1] <= 0.0f && out[i] > 0.0f) upCrossings.push_back(i); + } + if (upCrossings.size() < 2) return 0.0; + // Average spacing between crossings. + double sum = 0.0; + for (std::size_t i = 1; i < upCrossings.size(); ++i) { + sum += static_cast(upCrossings[i] - upCrossings[i - 1]); + } + return sum / static_cast(upCrossings.size() - 1); +} + +static void testRepitchObservedPeriod() { + // A sine of 20 cycles over 8000 frames -> native period 400 frames at unity. + const std::size_t frames = 8000; + const double cycles = 20.0; + const double nativePeriod = static_cast(frames) / cycles; // 400 + + // Unity: played at root, observed period ~= native. + { + Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, frames); + double p = observedPeriodFrames(out); + CHECK(approx(p, nativePeriod, 2.0)); + } + // +1 octave: advances 2x, observed period halves. + { + Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(72, 127); + std::vector out; + eng.render(out, frames / 2); // half as many frames covers the whole sample + double p = observedPeriodFrames(out); + CHECK(approx(p, nativePeriod / 2.0, 2.0)); + } + // -1 octave: advances 0.5x, observed period doubles. + { + Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(48, 127); + std::vector out; + eng.render(out, frames); + double p = observedPeriodFrames(out); + CHECK(approx(p, nativePeriod * 2.0, 4.0)); + } +} + +// --------------------------------------------------------------------------- +// 3. ADSR envelope shape vs a known signal. +// --------------------------------------------------------------------------- + +static void testAdsrShape() { + AdsrParams p; + p.attackFrames = 10; + p.decayFrames = 10; + p.sustainLevel = 0.5; + p.releaseFrames = 10; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + + // Attack: 0 -> ramps up. Frame 0 == 0, rising each frame. + double prev = -1.0; + for (int i = 0; i < 10; ++i) { + double v = env.tick(); + CHECK(v >= prev); // monotonic non-decreasing through attack + CHECK(v >= 0.0 && v <= 1.0); + prev = v; + } + // Decay: from 1.0 down toward sustain 0.5, monotonic non-increasing. + prev = 2.0; + for (int i = 0; i < 10; ++i) { + double v = env.tick(); + CHECK(v <= prev + 1e-9); // non-increasing through decay + CHECK(v >= 0.5 - 1e-9); // never below sustain during decay + prev = v; + } + // Sustain: holds 0.5 indefinitely. + for (int i = 0; i < 100; ++i) { + CHECK(approx(env.tick(), 0.5, 1e-9)); + } + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); + + // Release: 0.5 -> 0 over 10 frames, then Finished + latched at 0. + env.noteOff(); + prev = 1.0; + for (int i = 0; i < 10; ++i) { + double v = env.tick(); + CHECK(v <= prev + 1e-9); // non-increasing through release + prev = v; + } + CHECK(env.finished()); + for (int i = 0; i < 10; ++i) CHECK(approx(env.tick(), 0.0, 1e-12)); +} + +static void testAdsrReleaseBeforeSustain() { + // noteOff during the attack ramp releases from the PARTIAL level, not sustain. + AdsrParams p; + p.attackFrames = 100; + p.decayFrames = 10; + p.sustainLevel = 0.8; + p.releaseFrames = 20; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + + // Advance 50 frames into a 100-frame attack -> partial level ~0.5. + double last = 0.0; + for (int i = 0; i < 50; ++i) last = env.tick(); + CHECK(last > 0.3 && last < 0.7); // partway up the attack ramp + CHECK(env.stage() == AdsrEnvelope::Stage::Attack); + + env.noteOff(); + CHECK(env.stage() == AdsrEnvelope::Stage::Release); + // First release frame must be at or below the partial level we left off at — + // NOT jump up to sustain 0.8. This is the release-before-sustain guarantee. + double firstRelease = env.tick(); + CHECK(firstRelease <= last + 1e-9); + CHECK(firstRelease < p.sustainLevel); // proves it did not snap to sustain + // Decays to zero. + double prev = firstRelease; + for (int i = 0; i < 20; ++i) { + double v = env.tick(); + CHECK(v <= prev + 1e-9); + prev = v; + } + CHECK(env.finished()); +} + +static void testAdsrZeroAttackDecay() { + // Zero attack + zero decay -> jumps straight to sustain on the first ticks. + AdsrParams p; + p.attackFrames = 0; + p.decayFrames = 0; + p.sustainLevel = 0.7; + p.releaseFrames = 5; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + // Zero attack emits the attack peak (1.0) on frame 0 and immediately transitions + // through the (also zero-length) decay, so by frame 1 the envelope is holding + // sustain. The peak-at-boundary is the documented single-frame edge, not a bug. + CHECK(approx(env.tick(), 1.0, 1e-9)); // frame 0: attack peak + CHECK(approx(env.tick(), 0.7, 1e-9)); // frame 1: sustain + CHECK(approx(env.tick(), 0.7, 1e-9)); + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); +} + +// --------------------------------------------------------------------------- +// 1. Polyphonic allocation + note-off routing. +// --------------------------------------------------------------------------- + +static void testPolyphonicAllocation() { + Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60)); + VoiceEngine eng(8, km, flatAdsr()); + + // Four simultaneous notes -> four active voices, each on a distinct voice. + std::size_t v60 = eng.noteOn(60, 100); + std::size_t v64 = eng.noteOn(64, 100); + std::size_t v67 = eng.noteOn(67, 100); + std::size_t v72 = eng.noteOn(72, 100); + CHECK(v60 != VoiceEngine::kNoVoice); + CHECK(eng.activeVoiceCount() == 4); + CHECK(v60 != v64 && v64 != v67 && v67 != v72 && v60 != v72); + + // Note-off on 64 releases exactly one voice; with instant release it goes idle + // after the next render frame. + eng.noteOff(64); + std::vector out; + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 3); + + // The still-held notes keep sounding. + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 3); +} + +static void testNoteOffReleasesNewestSameNote() { + Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60)); + // Long release so we can observe which voice entered release. + AdsrParams a = flatAdsr(); + a.releaseFrames = 1000; + VoiceEngine eng(8, km, a); + + std::size_t first = eng.noteOn(60, 100); + std::size_t second = eng.noteOn(60, 100); // same note re-triggered + CHECK(first != second); + CHECK(eng.activeVoiceCount() == 2); + + eng.noteOff(60); // releases the NEWEST (second) + std::vector out; + eng.render(out, 1); + // Both still active (long release), but only the newest is releasing. + CHECK(eng.activeVoiceCount() == 2); + // A second note-off releases the older one too. + eng.noteOff(60); + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 2); // both releasing, not yet finished +} + +static void testOutOfZoneNoteConsumesNoVoice() { + Keymap km; + km.samples.push_back(dcSample(100, 60)); + km.zones.push_back(KeyZone{60, 72, 60, 0}); + VoiceEngine eng(4, km, flatAdsr()); + + std::size_t v = eng.noteOn(30, 100); // below the only zone + CHECK(v == VoiceEngine::kNoVoice); + CHECK(eng.activeVoiceCount() == 0); // no voice consumed +} + +// --------------------------------------------------------------------------- +// 2. Voice stealing at the bound. +// --------------------------------------------------------------------------- + +static void testStealsReleasingVoiceFirst() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); + AdsrParams a = flatAdsr(); + a.releaseFrames = 100000; // long release so a released voice stays "active". + VoiceEngine eng(2, km, a); + + std::size_t vA = eng.noteOn(60, 100); // startOrder 1 + std::size_t vB = eng.noteOn(62, 100); // startOrder 2 + CHECK(eng.activeVoiceCount() == 2); + + // Release the NEWER voice (62) — it becomes the only releasing voice. + eng.noteOff(62); + std::vector out; + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 2); // both still ringing (long release) + + // A new note with the pool full must steal the RELEASING voice (vB), not the + // older held voice (vA) — release-first policy. + std::size_t vC = eng.noteOn(64, 100); + CHECK(vC == vB); + CHECK(eng.activeVoiceCount() == 2); +} + +static void testStealsOldestWhenNoneReleasing() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); + AdsrParams a = flatAdsr(); + a.releaseFrames = 100000; + VoiceEngine eng(2, km, a); + + std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest) + std::size_t vB = eng.noteOn(62, 100); // startOrder 2 + CHECK(vA != vB); + + // No voice released; both held. A new note steals the OLDEST (vA). + std::size_t vC = eng.noteOn(64, 100); + CHECK(vC == vA); + CHECK(eng.activeVoiceCount() == 2); + + // The stolen voice now carries note 64; a note-off on 60 (the stolen-away note) + // finds nothing to release. + std::size_t before = eng.activeVoiceCount(); + eng.noteOff(60); + std::vector out; + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == before); // 60 no longer exists; no-op +} + +// --------------------------------------------------------------------------- +// 5. Loop-point-aware sustain. +// --------------------------------------------------------------------------- + +static void testLoopSustainSeamless() { + // A sample whose [0,20) frames are a distinctive ramp and [20,40) is a flat loop + // region of value 0.5. Held far past the sample end, the voice must keep emitting + // the loop region (0.5) rather than going silent. + SampleData s; + s.frames.resize(40); + for (int i = 0; i < 20; ++i) s.frames[i] = static_cast(i) / 20.0f; // attack + for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body + s.rootNote = 60; + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 40; + + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio, full velocity + + std::vector out; + eng.render(out, 200); // 5x the sample length + + // Voice is still active (looping), not exhausted. + CHECK(eng.activeVoiceCount() == 1); + // Frames well past the loop start must sit at the loop body value 0.5. + for (std::size_t i = 60; i < out.size(); ++i) { + CHECK(approx(out[i], 0.5, 1e-4)); + } +} + +static void testZeroLengthLoopGoesSilent() { + // A zero-length loop (start == end) is the "no sustain" marker: the note runs off + // the sample end and the voice goes idle, rather than spinning on an empty span. + SampleData s = dcSample(50, 60); // 50 frames of 1.0 + s.loop.hasLoop = true; + s.loop.start = 25; + s.loop.end = 25; // zero length + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + + std::vector out; + eng.render(out, 100); // past the 50-frame end + // After frame ~50 the voice should have gone idle (no loop to sustain it). + CHECK(eng.activeVoiceCount() == 0); + // Tail frames are silent. + for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); +} + +static void testAbsentLoopGoesSilent() { + // No loop at all: held note runs off the end and goes idle (same as zero-length). + SampleData s = dcSample(50, 60); + // s.loop.hasLoop stays false. + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 100); + CHECK(eng.activeVoiceCount() == 0); + for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); +} + +// --------------------------------------------------------------------------- +// velocity -> volume. +// --------------------------------------------------------------------------- + +static void testVelocityToVolume() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 + // Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the + // rendered value is exactly velocity/127 on a DC-1 sample). + { + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 1.0, 1e-4)); + } + { + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 64); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 64.0 / 127.0, 1e-4)); + } + { + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 1); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 1.0 / 127.0, 1e-4)); + } +} + +// Two voices summed: polyphony mixes additively. +static void testPolyphonyMixesAdditively() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(60, 127); // gain 1.0 + eng.noteOn(60, 127); // gain 1.0 (second voice, same note) + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 2.0, 1e-4)); // both voices sum +} + +int main() { + testChromaticSingleRoot(); + testZonedRangesBoundaries(); + testFirstMatchOnOverlap(); + testPitchRatioMath(); + testRepitchObservedPeriod(); + testAdsrShape(); + testAdsrReleaseBeforeSustain(); + testAdsrZeroAttackDecay(); + testPolyphonicAllocation(); + testNoteOffReleasesNewestSameNote(); + testOutOfZoneNoteConsumesNoVoice(); + testStealsReleasingVoiceFirst(); + testStealsOldestWhenNoneReleasing(); + testLoopSustainSeamless(); + testZeroLengthLoopGoesSilent(); + testAbsentLoopGoesSilent(); + testVelocityToVolume(); + testPolyphonyMixesAdditively(); + + if (g_fail == 0) { + std::printf("all sampler_core tests passed\n"); + return 0; + } + std::printf("%d sampler_core check(s) failed\n", g_fail); + return 1; +} From a04ef54eec99f63ba2ce2bcd50329a004ebe1787 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 16:05:01 -0400 Subject: [PATCH 04/49] fix(tests): rework testNoteOffReleasesNewestSameNote to actually prove newest-first release; add single-frame-loop test; add two comments in sampler_core.cpp --- src/vst/sampler_core.cpp | 5 ++- tests/test_sampler_core.cpp | 90 +++++++++++++++++++++++++++++++------ 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 2325a3e..ae502bb 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -199,6 +199,9 @@ AudioSample Voice::renderFrame() { if (loopUsable && i1 >= loop.end) { i1 = loop.start; // seamless wrap for the interpolation partner. } + // i0 is always in [0, frameCount) after the early-out above; the guard is purely + // defensive. i1 (the interpolation partner) can exceed frameCount when no loop + // wraps it — only that partner actually needs the clamp. const double s0 = (i0 >= 0 && i0 < frameCount) ? static_cast(pcm[i0]) : 0.0; const double s1 = (i1 >= 0 && i1 < frameCount) ? static_cast(pcm[i1]) : 0.0; const double interp = s0 + (s1 - s0) * frac; @@ -288,7 +291,7 @@ void VoiceEngine::noteOff(int note) { void VoiceEngine::render(std::vector& out, std::size_t frameCount) { const std::size_t base = out.size(); - out.resize(base + frameCount, 0.0f); + out.resize(base + frameCount, 0.0f); // S4: caller must pre-reserve — no allocation allowed under the VST3 process callback. for (Voice& voice : voices_) { if (!voice.active()) continue; for (std::size_t f = 0; f < frameCount; ++f) { diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index e885c1e..4a3a5a7 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -309,26 +309,62 @@ static void testPolyphonicAllocation() { } static void testNoteOffReleasesNewestSameNote() { - Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60)); - // Long release so we can observe which voice entered release. + // Prove that noteOff releases the NEWEST (highest startOrder) instance of a + // re-triggered note, leaving the older voice in sustain. + // + // Two voices at distinct velocities so their output is distinguishable: + // "first" (older) -> velocity 64 -> gain ~0.504 (G_old) + // "second" (newer) -> velocity 127 -> gain 1.0 (G_new) + // + // With a DC-1 sample and sustain=1, while both are held: + // render sum == G_old + G_new. + // + // After noteOff (must release newest), the newer voice enters a short release. + // Render past releaseFrames: newer voice finishes; only the older voice remains. + // Sum then equals G_old, and activeVoiceCount drops to 1. If the WRONG voice + // were released, the older would finish and the remaining sum would equal G_new + // (1.0 vs ~0.504) — the velocities make the error distinguishable. + const int velOld = 64; + const int velNew = 127; + const double gainOld = velOld / 127.0; // ~0.504 + const double gainNew = velNew / 127.0; // 1.0 + + Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); AdsrParams a = flatAdsr(); - a.releaseFrames = 1000; + a.releaseFrames = 10; // short but non-zero so voice stays active through release VoiceEngine eng(8, km, a); - std::size_t first = eng.noteOn(60, 100); - std::size_t second = eng.noteOn(60, 100); // same note re-triggered + std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain + std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain CHECK(first != second); CHECK(eng.activeVoiceCount() == 2); - eng.noteOff(60); // releases the NEWEST (second) - std::vector out; - eng.render(out, 1); - // Both still active (long release), but only the newest is releasing. - CHECK(eng.activeVoiceCount() == 2); - // A second note-off releases the older one too. + // While both are held, combined output equals gainOld + gainNew. + { + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], gainOld + gainNew, 1e-4)); + } + + // Release once — must target the NEWEST voice (second). eng.noteOff(60); - eng.render(out, 1); - CHECK(eng.activeVoiceCount() == 2); // both releasing, not yet finished + + // Render past the release (releaseFrames == 10): newer voice goes Finished. + std::vector out; + eng.render(out, 20); + + // Newer voice must be done; only the older voice remains. + CHECK(eng.activeVoiceCount() == 1); + // Tail frames must equal gainOld (~0.504), NOT gainNew (1.0). + // If the older voice were released instead, the tail would be ~1.0 here. + for (std::size_t i = 15; i < out.size(); ++i) { + CHECK(approx(out[i], gainOld, 1e-4)); + } + + // A second note-off releases the remaining older voice. + eng.noteOff(60); + eng.render(out, 20); + CHECK(eng.activeVoiceCount() == 0); } static void testOutOfZoneNoteConsumesNoVoice() { @@ -444,6 +480,33 @@ static void testZeroLengthLoopGoesSilent() { for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); } +static void testSingleFrameLoop() { + // A loop of exactly one frame [start, start+1) — the narrowest valid loop. + // The path is correct-by-luck (loopLen = 1.0 divides evenly into any integer + // readPos advance at unity ratio), but Tier-2 tight loops make it load-bearing. + SampleData s; + s.frames.resize(10); + for (int i = 0; i < 10; ++i) s.frames[i] = static_cast(i) * 0.1f; + s.rootNote = 60; + s.loop.hasLoop = true; + s.loop.start = 5; + s.loop.end = 6; // single-frame loop: [5, 6) + + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio, full velocity + + std::vector out; + eng.render(out, 50); // well past the sample end + + // Voice must still be active — the single-frame loop keeps it alive. + CHECK(eng.activeVoiceCount() == 1); + // Every frame from the loop-start onward must be the value of frame 5 (0.5). + for (std::size_t i = 10; i < out.size(); ++i) { + CHECK(approx(out[i], 0.5, 1e-4)); + } +} + static void testAbsentLoopGoesSilent() { // No loop at all: held note runs off the end and goes idle (same as zero-length). SampleData s = dcSample(50, 60); @@ -515,6 +578,7 @@ int main() { testStealsOldestWhenNoneReleasing(); testLoopSustainSeamless(); testZeroLengthLoopGoesSilent(); + testSingleFrameLoop(); testAbsentLoopGoesSilent(); testVelocityToVolume(); testPolyphonyMixesAdditively(); From 8dd74c6b3ae68e11aa2b3f6d8c159d1e548dee85 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 15:58:50 -0400 Subject: [PATCH 05/49] docs(plan): tick S1/S2 checkboxes; correct bridge-read text to VST3 mechanism --- PLAN.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/PLAN.md b/PLAN.md index 3afc89e..44243d1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -110,22 +110,21 @@ working hit-test; the VST-host bridge resolves `GetProjExtState` by name and rea known `"reasampler"` value. Nothing plays yet — this is the loading/drawing/bridge proof. -- [ ] CMake second target: a separate VST3 module artifact built alongside +- [x] CMake second target: a separate VST3 module artifact built alongside `reaper_reasampler` (Windows VST3 export/bundle wiring; `GetPluginFactory` + `InitDll`/`ExitDll` — **verify exact export names against the vendored SDK**). -- [ ] `SingleComponentEffect` skeleton: factory + class registration, `initialize` +- [x] `SingleComponentEffect` skeleton: factory + class registration, `initialize` declaring an event-input bus + an audio-output bus (no audio input), `setupProcessing`, `setActive`, empty `process`. Loads silently in REAPER. -- [ ] `IPlugView`↔LICE bridge spike: open a plugin editor window hosting a LICE-drawn +- [x] `IPlugView`↔LICE bridge spike: open a plugin editor window hosting a LICE-drawn surface (window creation/sizing, host→draw/hit-test event routing), reusing the `bank_panel` LICE/SWELL competence. **The decision's one real unknown — prove it here.** (VSTGUI is the noted fallback only if this proves gnarlier than the panel work suggests.) -- [ ] Bridge read spike: resolve `GetProjExtState`/`EnumProjExtState` by name over the - host callback (`hostcb` opcode `0xdeadf00d`), fetch host project context - (`0xdeadf00e`), and read a known `"reasampler"` ext-state value. **Verify opcodes + - marshalling against `reaper_plugin.h` / `video_processor.h` / - `reaper_plugin_functions.h`.** +- [x] Bridge read spike: resolve `GetProjExtState`/`EnumProjExtState` by name via + `IReaperHostApplication::getReaperApi` (host project context via `getReaperParent(3)`) + from `reaper_vst3_interfaces.h`, and read a known `"reasampler"` ext-state value. + (The `0xdeadf00d`/`0xdeadf00e` opcodes are the VST2 path — verified not applicable here.) ## S2 — `Sample` intrinsic fields (root note + loop points; in the *extension*) **Goal:** Add the two bank-intrinsic seam fields to `Sample` — **root note** (MIDI @@ -143,13 +142,13 @@ additive). **Depends on:** nothing in Phase S (extension-only; can land before or in parallel with S1). -- [ ] Add `rootNote` (optional MIDI note) + `loopStart`/`loopEnd` (optional +- [x] Add `rootNote` (optional MIDI note) + `loopStart`/`loopEnd` (optional sample-accurate loop points) to `Sample`; JSON serialize/deserialize with clean defaults for samples lacking them (additive, backward-compatible — mirror of how `provenance` was added). -- [ ] Populate the fields on capture where derivable (root note) / settable (loop +- [x] Populate the fields on capture where derivable (root note) / settable (loop points); leave them cleanly empty otherwise. No existing `Sample` field changes. -- [ ] Tests: full round-trip lossless including the new fields; a legacy `Sample` +- [x] Tests: full round-trip lossless including the new fields; a legacy `Sample` JSON (no new fields) parses with defaults and re-serializes without loss; additive invariant (no change to existing fields, dedup, tier, or `BankIndex` behavior). From 0cde4572249731d3a24a67e0abc56f6596e7039b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 16:43:04 -0400 Subject: [PATCH 06/49] =?UTF-8?q?S4=20Tier=200:=20the=20bank=20plays=20?= =?UTF-8?q?=E2=80=94=20VST3=20marshals=20MIDI=20to=20the=20S3=20core,=20re?= =?UTF-8?q?ads=20the=20live=20bank=20+=20resolves=20WAV=20the=20M4=20way,?= =?UTF-8?q?=20mono=20downmix,=20lock-free=20load=20handoff,=20LICE=20sampl?= =?UTF-8?q?e-pick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 28 +++- PLAN.md | 23 ++- src/capture_paths.cpp | 10 ++ src/capture_paths.h | 8 + src/ext_keys.h | 38 +++++ src/persist.cpp | 8 +- src/persist.h | 96 +++++------ src/vst/bridge_marshal.cpp | 62 ------- src/vst/bridge_marshal.h | 34 ++-- src/vst/editor_geometry.cpp | 19 +++ src/vst/editor_geometry.h | 19 +++ src/vst/reaper_bridge.cpp | 56 +++++-- src/vst/reaper_bridge.h | 16 ++ src/vst/reasampler_editor.cpp | 73 +++++--- src/vst/reasampler_editor.h | 30 +++- src/vst/reasampler_processor.cpp | 244 ++++++++++++++++++++++++--- src/vst/reasampler_processor.h | 96 ++++++++++- src/vst/sample_map.cpp | 129 +++++++++++++++ src/vst/sample_map.h | 110 +++++++++++++ src/vst/sampler_core.cpp | 19 ++- src/vst/sampler_core.h | 17 +- tests/test_bridge_marshal.cpp | 70 +------- tests/test_editor_geometry.cpp | 61 +++++++ tests/test_sample_map.cpp | 275 +++++++++++++++++++++++++++++++ 24 files changed, 1239 insertions(+), 302 deletions(-) create mode 100644 src/ext_keys.h create mode 100644 src/vst/sample_map.cpp create mode 100644 src/vst/sample_map.h create mode 100644 tests/test_sample_map.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index cdd05ac..22857c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -621,6 +621,18 @@ target_include_directories(editor_geometry PUBLIC src/vst) add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp) target_include_directories(bridge_marshal PUBLIC src/vst) +# sample_map (Phase S4) — PURE mapping logic for the Tier-0 instrument: the live bank +# blob -> selected sample (via the SHARED bank_book JSON parse, NOT a second parser), +# interleaved->mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap +# build, and the selected-sample instance-state (de)serialization. Links the three pure +# modules it composes — bank_book (shared JSON), wav_trim (shared WAV parse), and +# sampler_core (the Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell +# (reasampler_processor.cpp) does the bridge read + file I/O off the audio thread, then +# calls these; the process callback stays allocation-free. +add_library(sample_map STATIC src/vst/sample_map.cpp) +target_include_directories(sample_map PUBLIC src/vst src) +target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core) + add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) @@ -629,6 +641,13 @@ add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp) target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal) add_test(NAME bridge_marshal_tests COMMAND bridge_marshal_tests) +# sample_map: the S4 mapping heart. Links ONLY sample_map (+ its pure deps) — NEITHER +# the VST3 SDK nor the REAPER SDK — the same structural plain-data-boundary proof the +# sampler_core test enforces. +add_executable(sample_map_tests tests/test_sample_map.cpp) +target_link_libraries(sample_map_tests PRIVATE sample_map) +add_test(NAME sample_map_tests COMMAND sample_map_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -788,7 +807,14 @@ if(WIN32) ${VST3_SDK}/public.sdk/source/main/moduleinit.cpp ${LICE_SRC} ) - target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal) + # editor_geometry + bridge_marshal: the pure spike helpers. sample_map (S4): the pure + # bank->keymap mapping + state (de)ser the processor drives off the audio thread; + # linking it pulls its pure deps (bank_book, wav_trim, sampler_core, bank_model, + # peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile / + # projectDirOfRpp) the bridge + processor use. Its PUBLIC include dirs (src, src/vst) + # give the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). + target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal + sample_map capture_paths) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/PLAN.md b/PLAN.md index 44243d1..c7149a7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -193,15 +193,22 @@ following the active project works; it never captures and never inserts into the arrange (read-only over the bank). **Depends on:** S1, S2, S3. -- [ ] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus, - drive the S3 core, write per-voice audio to the output bus. -- [ ] Live-state seam: read the bank index + selected sample's root note from +- [x] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus, + drive the S3 core, write per-voice audio to the output bus. (Block-granular event + timing at Tier 0; sample-accurate offset scheduling is a later tier.) +- [x] Live-state seam: read the bank index + selected sample's root note from `"reasampler"` ext-state via the bridge; resolve the WAV audio path the M4 - project-relative way (shared convention with `persist`, not re-implemented). -- [ ] Sample selection UI (minimal, in the `IPlugView` LICE editor or a - parameters-only default view): choose which bank sample this instance plays. -- [ ] Tier-0 playback: chromatic-from-root, basic polyphony, amp envelope, - velocity→volume — plays in REAPER's routing/record/render path like any VSTi. + project-relative way (shared convention with `persist`, not re-implemented — the + parent-of-.rpp derivation is extracted to `capture_paths::projectDirOfRpp`, which both + `persist` and the bridge call). Bank JSON parsed via the shared `bank_book` path (the + spike string-scan reader retired); ext-state key names shared via pure `ext_keys.h`. +- [x] Sample selection UI (minimal, in the `IPlugView` LICE editor): a clickable list + of the bank's samples; the pick is the instance's own VST3 component state + (setState/getState), never written back to the bank. +- [x] Tier-0 playback: chromatic-from-root, basic polyphony (16 voices), amp envelope, + velocity→volume — plays in REAPER's routing/record/render path like any VSTi. Sample + load / decode / keymap build happen off the audio thread and hand to `process` via a + lock-free atomic pointer swap (graveyard-reclaim); `process` never allocates. ## S5 — Tier 1: "a keymap" (zoned multisamples, per-sample root notes) **Goal:** Multiple bank samples zoned across the keyboard (key ranges), each with its diff --git a/src/capture_paths.cpp b/src/capture_paths.cpp index 47336e4..33bfbce 100644 --- a/src/capture_paths.cpp +++ b/src/capture_paths.cpp @@ -5,6 +5,7 @@ #include #include #include // std::memcmp +#include #include namespace reasampler { @@ -213,6 +214,15 @@ std::string resolveBankFile(const std::string& projectDir, return dir + "/" + rel; } +std::string projectDirOfRpp(const std::string& rppPath) { + // An unsaved project reports an empty .rpp path; keep it empty so downstream + // resolution refuses (no default-location fallback). Mirrors persist.cpp's prior + // projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes. + if (rppPath.empty()) return {}; + std::string dir = std::filesystem::path(rppPath).parent_path().string(); + return normalizeSlashes(dir); +} + BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, const std::string& newProjectDir) { BankRelocation r; diff --git a/src/capture_paths.h b/src/capture_paths.h index a83387a..4ab8026 100644 --- a/src/capture_paths.h +++ b/src/capture_paths.h @@ -121,6 +121,14 @@ std::string bankRelativeForName(const std::string& fileName); std::string resolveBankFile(const std::string& projectDir, const std::string& relativePath); +// The project directory that holds a .rpp: its parent directory, forward-slashed, +// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty +// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the +// no-default-location invariant). This is the M4 convention persist uses to place +// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves +// audio paths the SAME way persist does rather than re-implementing the derivation. +std::string projectDirOfRpp(const std::string& rppPath); + // A relocation plan for the physical bank folder on Save-As to a new project // location. The index's relative paths do NOT change (they are relative to the // project dir, which is what moved with the .rpp), so relocation is purely a diff --git a/src/ext_keys.h b/src/ext_keys.h new file mode 100644 index 0000000..66ef9a2 --- /dev/null +++ b/src/ext_keys.h @@ -0,0 +1,38 @@ +#pragma once +// ext_keys — the SINGLE SOURCE OF TRUTH for the "reasampler" project ext-state +// namespace + key names, shared by the extension (writer, via persist.h) and the +// VST3 instrument (reader, via the bridge). Both sides include this header so the +// wire contract cannot drift between the two artifacts (the S4 reviewer flagged the +// spike's duplicated constants as a drift risk). +// +// PURE HEADER: NO REAPER types, NO VST3 types, NO SWELL, NO vendor/ includes. Just +// string constants, so both the REAPER-facing persist shell and the SDK-facing VST +// bridge can include it without pulling either SDK. +// +// FOREVER-STABLE once shipped: these strings key every already-saved project's +// stored state. Changing any of them orphans that state. See persist.h for the +// per-key retirement / migration semantics — this header only owns the spellings. + +namespace reasampler { + +// The ext-state namespace all ReaSampler project state is stored under. +inline constexpr const char* kProjExtNamespace = "reasampler"; + +// The multi-bank key: the whole serialized BankBook (pool + named banks). This is +// the key the VST3 instrument reads to see the live bank (read-only, S4). persist.h +// documents its authority + the legacy-key migration around it. +inline constexpr const char* kProjExtBanksKey = "banks"; + +// The retired legacy single-bank key (read once on load to migrate into the pool). +inline constexpr const char* kProjExtIndexKey = "bank_index"; + +// The Design-View model key. +inline constexpr const char* kProjExtViewKey = "view_state"; + +// The docked panel's tail-setting key. +inline constexpr const char* kProjExtTailKey = "tail_setting"; + +// The per-project minted-GUID identity key. +inline constexpr const char* kProjExtGuidKey = "project_guid"; + +} // namespace reasampler diff --git a/src/persist.cpp b/src/persist.cpp index 1986840..9c8bf3a 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -128,11 +128,11 @@ void* readActiveProject(std::string& rppPathOut) { // Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> // empty out. Mirrors capture.cpp's derivation so the bank sits alongside the // .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp -// for the full rationale). normalizeSlashes lives in capture_paths (pure). +// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths +// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both +// artifacts share one implementation rather than duplicating the parent-of-.rpp step. std::string projectDirOf(const std::string& rppPath) { - if (rppPath.empty()) return {}; - std::string dir = fs::path(rppPath).parent_path().string(); - return normalizeSlashes(dir); + return projectDirOfRpp(rppPath); } // GetProjExtState needs a caller-supplied buffer; the index JSON can be large diff --git a/src/persist.h b/src/persist.h index b4f1c10..9acd066 100644 --- a/src/persist.h +++ b/src/persist.h @@ -22,6 +22,7 @@ #include "app_version.h" #include "bank_book.h" #include "bank_model.h" +#include "ext_keys.h" #include "owned_manifest.h" #include "prune_reconcile.h" #include "tail_control.h" @@ -29,71 +30,50 @@ namespace reasampler { -// The ext-state namespace every ReaSampler key is stored under. CHANNEL-DERIVED (Phase V, -// V4): the pure app_version module owns the one channel-qualified string — "reasampler" on -// stable (byte-identical to the pre-V4 build) or "reasampler_beta" on the isolated beta -// build. FOREVER-STABLE per channel once shipped: changing either orphans every already- -// saved project's state. Beta reads/writes ONLY its own namespace — a project saved by -// stable shows empty/default state in beta and vice versa; that isolation is the accepted -// V4 safety property (no cross-namespace read, migration, or fallback), not a bug. -// Returns const char* (not a constexpr literal) because the string is channel-derived at -// build time; the accessor is the single call point for all persist reads/writes below. +// The ext-state namespace + the WIRE-SHARED key names are the contract between this +// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h +// (pure, REAPER-free) and are included above — not duplicated here. The namespace is +// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace() +// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte- +// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both +// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace +// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project +// saved by stable shows empty/default state in beta and vice versa; that isolation is the +// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug. +// The per-key semantics persist relies on (spellings owned by ext_keys.h): +// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks). +// AUTHORITATIVE going forward; the VST reads this key to see the live bank. +// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared +// on save); READ once on load to migrate a legacy project into the pool. +// * kProjExtViewKey : the Design-View ViewModeModel JSON. +// * kProjExtTailKey : the docked panel's TailSetting JSON. +// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll() +// tells a Save-As from a recycled-pointer project switch by it). +// All are FOREVER-STABLE once shipped: changing any strands every already-saved +// project's stored state under that key. +// +// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this +// is the const char* the SetProjExtState/GetProjExtState calls in persist.cpp pass. Kept +// as an accessor (not a literal) because the string is channel-derived at build time. inline const char* projExtNamespace() { return extStateNamespace().c_str(); } -// The RETIRED legacy ext-state key: pre-multi-bank projects stored the whole -// serialized BankIndex here (single bank). Phase B2 no longer WRITES it — on save -// the key is cleared (SetProjExtState with "" deletes it) and the book is written -// under kProjExtBanksKey instead. It is still READ once, on load of a legacy -// project, to migrate its single index into the pool (BankBook's parse-time -// promotion). FOREVER-STABLE as a read key for that migration path. -inline constexpr const char* kProjExtIndexKey = "bank_index"; - -// The multi-bank ext-state key (Phase B): one key holds the whole serialized -// BankBook — the pool folded in as bank-zero plus every named bank, each with its -// own BankIndex, ordinals, and the active-bank id. AUTHORITATIVE going forward; -// supersedes kProjExtIndexKey. FOREVER-STABLE once shipped: changing it orphans -// every already-saved project's banks. -inline constexpr const char* kProjExtBanksKey = "banks"; - -// The ext-state key the Design-View ViewModeModel JSON is stored under (one key -// holds the whole serialized model: modes + membership + show-both + snapshots + -// active mode). Distinct from kProjExtIndexKey — one namespace, two keys. -// FOREVER-STABLE: changing it orphans every already-saved project's view state. -inline constexpr const char* kProjExtViewKey = "view_state"; - -// The ext-state key the docked panel's TailSetting JSON (mode + manualMs) is stored -// under, so the tail choice travels inside the .rpp and loads per project. Distinct -// from the index/view keys — one namespace, three keys. FOREVER-STABLE: changing it -// orphans every already-saved project's tail setting (which then falls back to the -// default — graceful, but the user's saved choice would be lost). -inline constexpr const char* kProjExtTailKey = "tail_setting"; - -// The ext-state key holding the owned-file manifest JSON (the set of project-relative -// files the capture path itself created — Phase B B-cap seam, consumed by Phase R -// prune to distinguish the bank system's own orphans from hand-dropped files). A -// SIBLING key alongside banks/view_state/tail_setting — NOT folded into the `banks` -// blob, so it stays decoupled from bank membership (removing an index entry is not a -// manifest removal). One namespace, four content keys. FOREVER-STABLE: changing it -// strands every already-saved project's ownership record, so Phase R prune could no -// longer tell the tool's own files apart (it would fall back to an empty manifest — +// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument +// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h: +// +// owned_files — the owned-file manifest JSON (project-relative files the capture path +// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's +// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT +// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it +// strands every saved project's ownership record (prune falls back to an empty manifest — // graceful, but the attribution safety net is lost until the next capture rebuilds it). inline constexpr const char* kProjExtOwnedKey = "owned_files"; -// The ext-state key holding the ReaSampler version that last WROTE this project -// (Phase V, V1). Written on every save alongside the banks/view/tail keys, so every -// saved .rpp records which build produced its state — the seam a future within-channel -// forward migration keys off ("this was written by 0.9.01, I am 0.9.05"). An absent -// key is the explicit pre-versioning case (a project saved before this shipped), read -// silently, never an error. FOREVER-STABLE key string once shipped. +// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on +// every save, so every saved .rpp records which build produced its state — the seam a +// future within-channel forward migration keys off. An absent key is the explicit +// pre-versioning case, read silently, never an error. FOREVER-STABLE key string. inline constexpr const char* kProjExtVersionKey = "version"; -// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED -// project identity (REAPER exposes no stable per-project GUID). poll() uses it to -// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch -// onto a recycled ReaProject* pointer (different GUID). FOREVER-STABLE: changing -// it strands the identity of every already-saved project. See persist.cpp. -inline constexpr const char* kProjExtGuidKey = "project_guid"; - // Owns the session's BankBook (Phase B: pool + named banks) and drives persistence // against the active REAPER project. One instance lives for the extension's // lifetime (main.cpp). It tracks diff --git a/src/vst/bridge_marshal.cpp b/src/vst/bridge_marshal.cpp index 7faf94b..23d908c 100644 --- a/src/vst/bridge_marshal.cpp +++ b/src/vst/bridge_marshal.cpp @@ -13,66 +13,4 @@ std::optional decodeGetProjExtState(int apiReturn, return buffer; } -std::optional extractJsonStringField(const std::string& json, - const std::string& key) { - // Find the member token: "key" followed (after optional whitespace) by ':' then a - // quoted string. Scan for each candidate occurrence of the quoted key so a value - // that happens to contain the key text can't produce a false match. - const std::string needle = "\"" + key + "\""; - size_t searchFrom = 0; - - while (true) { - const size_t keyPos = json.find(needle, searchFrom); - if (keyPos == std::string::npos) return std::nullopt; - - size_t i = keyPos + needle.size(); - searchFrom = i; // next candidate starts after this key token - - // Skip whitespace to the ':'. - while (i < json.size() && - (json[i] == ' ' || json[i] == '\t' || json[i] == '\n' || - json[i] == '\r')) { - ++i; - } - if (i >= json.size() || json[i] != ':') continue; // not a member — keep looking - ++i; - - // Skip whitespace to the value. - while (i < json.size() && - (json[i] == ' ' || json[i] == '\t' || json[i] == '\n' || - json[i] == '\r')) { - ++i; - } - if (i >= json.size() || json[i] != '"') return std::nullopt; // value not a string - ++i; - - // Read the string body, honoring the common JSON escapes. - std::string out; - while (i < json.size()) { - const char c = json[i]; - if (c == '\\') { - if (i + 1 >= json.size()) return std::nullopt; // dangling escape - const char e = json[i + 1]; - switch (e) { - case '"': out.push_back('"'); break; - case '\\': out.push_back('\\'); break; - case '/': out.push_back('/'); break; - case 'n': out.push_back('\n'); break; - case 't': out.push_back('\t'); break; - case 'r': out.push_back('\r'); break; - case 'b': out.push_back('\b'); break; - case 'f': out.push_back('\f'); break; - default: out.push_back(e); break; // pass through unknown escapes - } - i += 2; - continue; - } - if (c == '"') return out; // closing quote — done - out.push_back(c); - ++i; - } - return std::nullopt; // unterminated string - } -} - } // namespace reasampler::vst diff --git a/src/vst/bridge_marshal.h b/src/vst/bridge_marshal.h index 0c55b63..2c75068 100644 --- a/src/vst/bridge_marshal.h +++ b/src/vst/bridge_marshal.h @@ -1,18 +1,21 @@ -// bridge_marshal.h — PURE marshalling helpers for the REAPER VST-host bridge read -// (Phase S1). NO VST3, NO REAPER types at the boundary. +// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read. +// NO VST3, NO REAPER types at the boundary. // // The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the -// host callback and invokes them; the fiddly-and-easy-to-get-wrong parts around those -// calls — interpreting GetProjExtState's int return, walking EnumProjExtState's -// index-until-false contract into a key set, and extracting a single value out of the -// "reasampler" bank JSON — are pure and unit-tested here. Mirror of capture_paths / -// wav_trim splitting the arithmetic out of a REAPER-facing shell. +// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around +// GetProjExtState — interpreting its int return against the buffer it filled — is pure +// and unit-tested here. Mirror of capture_paths / wav_trim splitting the arithmetic out +// of a REAPER-facing shell. +// +// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a +// stand-in until the instrument could parse the bank properly. S4 retired it: the +// instrument now parses the "reasampler" bank blob through the SHARED bank_book / +// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module +// is back to its one honest job — the API-return decode. // // Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h: // int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz); // -- returns the length written (0 when the key is absent). -// bool EnumProjExtState(ReaProject*, extname, idx, keyOut, keyOut_sz, valOut, valOut_sz); -// -- returns false when idx is past the last entry. #pragma once @@ -31,17 +34,4 @@ namespace reasampler::vst { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer); -// Extract the string value for `key` out of a flat one-level JSON object — the shape -// persist.cpp writes under the "reasampler" ext-state (e.g. the bank blob's top-level -// fields). This is a deliberately small, dependency-free reader for the SPIKE's -// "read a known value" proof, NOT a general JSON parser: it finds "key" as an object -// member and returns its string value, handling the common escapes (\" \\ \n \t \/). -// Returns nullopt if the key is absent or its value is not a string. -// -// The real instrument (S4) will read the bank index through the shared bank_model JSON -// path, not this helper; this exists only to give the S1 bridge read a testable, -// REAPER-free decode step. -std::optional extractJsonStringField(const std::string& json, - const std::string& key); - } // namespace reasampler::vst diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp index d5babe1..39919bb 100644 --- a/src/vst/editor_geometry.cpp +++ b/src/vst/editor_geometry.cpp @@ -53,4 +53,23 @@ HitTarget hitTest(const EditorLayout& layout, int x, int y) { return HitTarget::kNone; } +Rect sampleRowRect(const EditorLayout& layout, int index) { + if (index < 0) return Rect{}; + const int top = layout.canvas.top + index * kSampleRowHeight; + return Rect{layout.canvas.left, top, layout.canvas.right, top + kSampleRowHeight}; +} + +int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { + if (rowCount <= 0) return -1; + // Must be within the canvas horizontally and at/below its top. + if (x < layout.canvas.left || x >= layout.canvas.right) return -1; + if (y < layout.canvas.top) return -1; + const int index = (y - layout.canvas.top) / kSampleRowHeight; + if (index < 0 || index >= rowCount) return -1; + // Guard the bottom edge: a click below the last row's canvas bottom is outside. + const Rect r = sampleRowRect(layout, index); + if (y >= r.bottom) return -1; + return index; +} + } // namespace reasampler::vst diff --git a/src/vst/editor_geometry.h b/src/vst/editor_geometry.h index b6bb5b1..2fb3697 100644 --- a/src/vst/editor_geometry.h +++ b/src/vst/editor_geometry.h @@ -56,4 +56,23 @@ enum class HitTarget { // is kNone in the spike. HitTarget hitTest(const EditorLayout& layout, int x, int y); +// --- Sample-selection list (S4 Tier-0 UI) ----------------------------------- +// +// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows +// below the title bar; clicking a row selects that sample. This is the pure geometry: +// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the +// shell draws the names and routes the click into the processor's reloadFromBank. + +// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree. +inline constexpr int kSampleRowHeight = 22; + +// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside +// the layout's canvas. Rows beyond what the canvas can show are still computed (the +// shell clips at paint time); a negative index yields an empty rect. Pure. +Rect sampleRowRect(const EditorLayout& layout, int index); + +// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click +// outside the list (above the first row, past the last, or on the title bar). Pure. +int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y); + } // namespace reasampler::vst diff --git a/src/vst/reaper_bridge.cpp b/src/vst/reaper_bridge.cpp index 8096a92..f58e567 100644 --- a/src/vst/reaper_bridge.cpp +++ b/src/vst/reaper_bridge.cpp @@ -5,6 +5,8 @@ #include #include "bridge_marshal.h" +#include "capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation) +#include "ext_keys.h" // kProjExtNamespace (shared wire contract) // The VST3 base types must be included before REAPER's VST3 interface header, which // uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from @@ -27,21 +29,17 @@ namespace Steinberg { // interface (FUnknownPtr uses the iid), so the definition lives with its sole use. DEF_CLASS_IID(Steinberg::IReaperHostApplication) -// The "reasampler" ext-state namespace + bank key. Kept in sync with persist.h by -// value (the extension writes them); we only READ here, so we duplicate the two string -// constants rather than pull the whole REAPER-facing persist.h into the VST artifact. -// If persist.h's kProjExtNamespace / kProjExtBanksKey ever change, these must follow — -// they are the shared wire contract between the extension (writer) and instrument -// (reader). VERIFY against persist.h. -namespace { -constexpr const char* kReasamplerNamespace = "reasampler"; -} +// The "reasampler" ext-state namespace is the SHARED wire contract between the +// extension (writer) and this instrument (reader); it lives in ext_keys.h (pure, +// REAPER-free) — reasampler::kProjExtNamespace — so the two artifacts read one symbol +// and cannot drift. The S1 spike duplicated it locally; that duplication is retired. namespace reasampler::vst { bool ReaperBridge::connect(Steinberg::FUnknown* context) { getProjExtState_ = nullptr; enumProjExtState_ = nullptr; + enumProjects_ = nullptr; hostApp_ = nullptr; if (!context) return false; @@ -58,6 +56,10 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { reaper->getReaperApi("GetProjExtState")); enumProjExtState_ = reinterpret_cast( reaper->getReaperApi("EnumProjExtState")); + // EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call + // persist.cpp uses, so the instrument derives the project directory identically. + enumProjects_ = reinterpret_cast( + reaper->getReaperApi("EnumProjects")); return getProjExtState_ != nullptr; } @@ -74,15 +76,35 @@ std::optional ReaperBridge::readReasamplerExtState(const std::strin // REAPER treats null as the current project for these calls, so we pass it through // rather than bailing — but if the read yields nothing the caller sees nullopt. - // GetProjExtState writes into a caller buffer; size it generously for a JSON blob - // and let the pure decoder interpret the result. The buffer is NUL-terminated by - // REAPER on success. - std::vector buf(64 * 1024, '\0'); - const int rv = getProjExtState_(proj, kReasamplerNamespace, key.c_str(), buf.data(), - static_cast(buf.size())); + // GetProjExtState writes into a caller buffer; the bank blob can be large (many + // samples), so grow the buffer until the value fits rather than risk a silent + // truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The + // return value is the value length; if it fits strictly inside the buffer it is + // complete, else grow and retry up to a 16 MB ceiling. + for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { + std::vector buf(static_cast(cap), '\0'); + const int rv = getProjExtState_(proj, kProjExtNamespace, key.c_str(), + buf.data(), cap); + if (rv <= 0) return std::nullopt; // absent / empty key + std::string s(buf.data()); + if (static_cast(s.size()) + 1 < cap) { + return decodeGetProjExtState(rv, s); + } + // else: possibly truncated -> grow and retry. + } + return std::nullopt; // pathologically large (>16 MB) — give up rather than loop +} - // Marshal the raw result through the pure decoder (handles the absent-key case). - return decodeGetProjExtState(rv, std::string(buf.data())); +std::string ReaperBridge::activeProjectDir() { + if (!enumProjects_) return {}; + // idx=-1 is the current project tab; the out-buffer receives the full .rpp path, + // EMPTY for a never-saved project. Same call + convention as persist.cpp; the pure + // projectDirOfRpp turns the .rpp path into the project directory (parent, forward- + // slashed) and keeps an unsaved project's empty path empty (no default-location + // fallback — the tool's invariant). + std::vector buf(4096, '\0'); + enumProjects_(-1, buf.data(), static_cast(buf.size())); + return projectDirOfRpp(std::string(buf.data())); } } // namespace reasampler::vst diff --git a/src/vst/reaper_bridge.h b/src/vst/reaper_bridge.h index 8eea7dc..8c5dd80 100644 --- a/src/vst/reaper_bridge.h +++ b/src/vst/reaper_bridge.h @@ -44,8 +44,19 @@ public: // Read a "reasampler" ext-state value by key from the host's active project. // Returns nullopt when unconnected, when the project can't be resolved, or when the // key is absent. This is the S1 read-spike entry point. + // + // NOT REAL-TIME SAFE (it allocates a read buffer and calls into REAPER): callers on + // the audio thread MUST NOT invoke it. The S4 instrument reads on the main/UI thread + // and hands a snapshot to the process path (see reasampler_processor.cpp). std::optional readReasamplerExtState(const std::string& key); + // The active project's directory (the folder holding its .rpp), forward-slashed, + // no trailing slash — the M4 convention persist uses to place the bank alongside + // the .rpp. Empty for an unsaved project or when unconnected. The instrument + // resolves relative sample paths against this the SAME way persist does + // (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe. + std::string activeProjectDir(); + private: // Resolved REAPER API function pointers (by name via getReaperApi). Signatures // verified against reaper_plugin_functions.h. @@ -54,10 +65,15 @@ private: using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx, char* keyOut, int keyOut_sz, char* valOut, int valOut_sz); + // EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path (SDK line + // ~1264). The instrument uses idx=-1 (current tab) so it follows the active project, + // and reads the .rpp path from the out-buffer exactly as persist.cpp does. + using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz); void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp) GetProjExtStateFn getProjExtState_ = nullptr; EnumProjExtStateFn enumProjExtState_ = nullptr; + EnumProjectsFn enumProjects_ = nullptr; }; } // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 0e68a69..3daf183 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -7,7 +7,10 @@ #include #include "editor_geometry.h" +#include "ext_keys.h" #include "reaper_bridge.h" +#include "reasampler_processor.h" +#include "sample_map.h" #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM @@ -47,13 +50,25 @@ void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { #endif } // namespace -ReaSamplerEditor::ReaSamplerEditor(ReaperBridge* bridge) - : CPluginView(nullptr), bridge_(bridge) { +ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) + : CPluginView(nullptr), processor_(processor) { // Default view size; the host may resize (canResize() == true). ViewRect r(0, 0, 420, 260); setRect(r); } +void ReaSamplerEditor::refreshSampleList() { + // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). + if (!processor_) { + samples_.clear(); + selectedId_.clear(); + return; + } + auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banks ? listSamples(*banks) : std::vector{}; + selectedId_ = processor_->selectedSampleId(); +} + ReaSamplerEditor::~ReaSamplerEditor() { #ifdef _WIN32 // Defensive teardown: the host normally calls removed() (which destroys the child) @@ -102,6 +117,9 @@ void ReaSamplerEditor::attachedToParent() { classRegistered = true; } + // Snapshot the live bank so the first paint shows the sample list. + refreshSampleList(); + const ViewRect& r = getRect(); childHwnd_ = CreateWindowExW( 0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(), @@ -144,17 +162,13 @@ void ReaSamplerEditor::paint(HDC hdc) { const EditorLayout layout = layoutEditor(w, h); - // Title band. + // Title band: the plugin name + whether a live bank is linked. LICE_FillRect(&bmp, layout.titleBar.left, layout.titleBar.top, layout.titleBar.width(), layout.titleBar.height(), kColTitleBg, 1.0f, 0); - // Read a known live-state value over the bridge to prove the read spike. Show the - // raw ext-state presence (never the full blob) so the title reflects live project - // state without dumping JSON into the UI. std::string title = "ReaSampler Instrument"; - if (bridge_ && bridge_->isConnected()) { - auto banks = bridge_->readReasamplerExtState("banks"); - title += banks ? " [bank: linked]" : " [bank: none]"; + if (processor_ && processor_->bridge().isConnected()) { + title += samples_.empty() ? " [bank: empty]" : " [pick a sample]"; } else { title += " [host: no bridge]"; } @@ -162,27 +176,42 @@ void ReaSamplerEditor::paint(HDC hdc) { layout.titleBar.right - 8, layout.titleBar.bottom}; drawText(&bmp, titleText, title.c_str(), kRgbText); - // The clickable button — fill reflects the last hit-test (the routing proof). - LICE_FillRect(&bmp, layout.button.left, layout.button.top, layout.button.width(), - layout.button.height(), buttonHit_ ? kColBtnHitBg : kColBtnBg, 1.0f, - 0); - LICE_DrawRect(&bmp, layout.button.left, layout.button.top, layout.button.width(), - layout.button.height(), kColBtnBorder, 1.0f, 0); - Rect btnText{layout.button.left + 8, layout.button.top, layout.button.right, - layout.button.bottom}; - drawText(&bmp, btnText, buttonHit_ ? "clicked" : "click me", kRgbText); + // Sample list: one row per bank sample, the selected one highlighted. Clip drawing + // to rows that fall within the canvas (sampleRowRect computes all; we skip off-screen + // ones so a huge bank doesn't waste paint). + for (int i = 0; i < static_cast(samples_.size()); ++i) { + const Rect row = sampleRowRect(layout, i); + if (row.top >= layout.canvas.bottom) break; // past the visible area + const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_; + LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(), + sel ? kColBtnHitBg : kColBtnBg, 1.0f, 0); + if (sel) { + LICE_DrawRect(&bmp, row.left, row.top, row.width() - 1, row.height() - 1, + kColBtnBorder, 1.0f, 0); + } + Rect textR{row.left + 8, row.top, row.right - 8, row.bottom}; + const std::string& name = samples_[i].displayName; + drawText(&bmp, textR, name.empty() ? samples_[i].id.c_str() : name.c_str(), + kRgbText); + } BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } void ReaSamplerEditor::onClick(int x, int y) { + if (!processor_) return; RECT cr{}; GetClientRect(childHwnd_, &cr); const EditorLayout layout = layoutEditor(cr.right - cr.left, cr.bottom - cr.top); - if (hitTest(layout, x, y) == HitTarget::kButton) { - buttonHit_ = !buttonHit_; - InvalidateRect(childHwnd_, nullptr, FALSE); - } + const int row = sampleRowHitTest(layout, static_cast(samples_.size()), x, y); + if (row < 0) return; + + // Select this sample and rebuild the instrument OFF the audio thread (this WM_ + // handler runs on the UI thread). reloadFromBank reads the id we just set. + processor_->setSelectedSampleId(samples_[row].id); + processor_->reloadFromBank(); + selectedId_ = samples_[row].id; + InvalidateRect(childHwnd_, nullptr, FALSE); } LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 45c4a60..c89d7d8 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -13,21 +13,27 @@ #pragma once +#include +#include + #include "public.sdk/source/common/pluginview.h" +#include "sample_map.h" // SampleChoice (the list the editor draws) + #ifdef _WIN32 #include #endif namespace reasampler::vst { -class ReaperBridge; +class ReaSamplerProcessor; class ReaSamplerEditor : public Steinberg::CPluginView { public: - // `bridge` is owned by the processor and outlives the editor; the editor reads - // (never mutates) it to show a live-state readout. May be null (non-REAPER host). - explicit ReaSamplerEditor(ReaperBridge* bridge); + // `processor` owns this editor's lifetime domain and outlives it; the editor reads + // the live bank through it (the sample list) and drives selection + reload when the + // user clicks a row. May be null (defensive — a real host always supplies one). + explicit ReaSamplerEditor(ReaSamplerProcessor* processor); ~ReaSamplerEditor() override; // Accept only the Windows HWND platform type (D5: Windows-only). @@ -48,7 +54,7 @@ private: #ifdef _WIN32 // Draw the current surface into the child window's DC via a LICE bitmap. void paint(HDC hdc); - // Route a client-space click through editor_geometry::hitTest. + // Route a client-space click: select the sample row under (x, y), if any. void onClick(int x, int y); static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); @@ -56,10 +62,16 @@ private: HWND childHwnd_ = nullptr; #endif - ReaperBridge* bridge_ = nullptr; - // Latched on click so the paint reflects the last hit-test result — the spike's - // proof that host->click->draw routing round-trips. - bool buttonHit_ = false; + // Re-read the bank's sample list from the live bridge into `samples_`. Main/UI + // thread only (reads ext-state); called on attach and after a selection reload. + void refreshSampleList(); + + ReaSamplerProcessor* processor_ = nullptr; + // The bank's samples, snapshotted for the current paint. Refreshed off the audio + // thread; the paint just draws it. + std::vector samples_; + // The currently-selected sample id, mirrored for the paint's highlight. + std::string selectedId_; }; } // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 565ee10..6346b83 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -2,16 +2,64 @@ #include "reasampler_processor.h" +#include +#include +#include + +#include "pluginterfaces/base/ibstream.h" #include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivstevents.h" #include "pluginterfaces/vst/vstspeaker.h" +#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "ext_keys.h" // kProjExtBanksKey (shared wire contract) #include "reasampler_editor.h" +#include "sample_map.h" // selectSample, downmixToMono, buildTier0Keymap, state (de)ser +#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) using namespace Steinberg; using namespace Steinberg::Vst; namespace reasampler::vst { +namespace { + +// Tier-0 fixed instrument shape (Tier 2 makes these editable). A gentle amp envelope so +// notes neither click on nor cut off abruptly; sustain at unity (velocity does the +// dynamics), a short release for a natural tail. Times are in seconds, converted to +// frames against the live sample rate at build time. +constexpr double kAttackSeconds = 0.003; +constexpr double kDecaySeconds = 0.0; +constexpr double kSustainLevel = 1.0; +constexpr double kReleaseSeconds = 0.060; +constexpr std::size_t kMaxVoices = 16; + +AdsrParams tier0Adsr(double sampleRate) { + const double sr = sampleRate > 0.0 ? sampleRate : 44100.0; + AdsrParams p; + p.attackFrames = static_cast(kAttackSeconds * sr); + p.decayFrames = static_cast(kDecaySeconds * sr); + p.sustainLevel = kSustainLevel; + p.releaseFrames = static_cast(kReleaseSeconds * sr); + return p; +} + +// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on +// any failure — the caller treats an unreadable WAV as "nothing to play". +std::vector readFileBytes(const std::string& path) { + std::vector bytes; + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return bytes; + const std::streamoff size = f.tellg(); + if (size <= 0) return bytes; + f.seekg(0, std::ios::beg); + bytes.resize(static_cast(size)); + if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); + return bytes; +} + +} // namespace + FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { // The host owns the returned reference. Cast up to the combined interface the SDK // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. @@ -23,7 +71,7 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { if (result != kResultOk) return result; // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the - // instrument still loads, the editor just shows "no bridge". + // instrument still loads, it just has no live bank to play. bridge_.connect(context); // Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio @@ -35,46 +83,204 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { } tresult PLUGIN_API ReaSamplerProcessor::terminate() { + // process() is not running at terminate. Free the live instrument and drain the + // graveyard. Take the pointer out of the atomic first so nothing else races it. + std::lock_guard lock(reloadMutex_); + delete live_.exchange(nullptr); + graveyard_.clear(); return SingleComponentEffect::terminate(); } -tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool /*state*/) { - // Nothing to allocate/free in the silent skeleton; S4 will size voice buffers here - // against the setupProcessing block size. +tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { + // Activating: build the instrument from the currently-selected sample so the first + // block after activation can play. Deactivating: process is now GUARANTEED stopped by + // the host, so this is the safe point to reclaim the graveyard (the displaced engines + // no reload could free while active). The build/drain are off the audio thread — + // setActive is a main/UI-thread call. + if (state) { + reloadFromBank(); + } else { + std::lock_guard lock(reloadMutex_); + graveyard_.clear(); + } return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { + sampleRate_ = setup.sampleRate; + maxBlockSize_ = setup.maxSamplesPerBlock; return SingleComponentEffect::setupProcessing(setup); } -tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { - // Silent skeleton: emit silence on the output bus so the instrument runs cleanly in - // REAPER's render/record path without a null buffer. S4 marshals MIDI->core->audio. - if (data.numOutputs > 0 && data.outputs && data.numSamples > 0) { - AudioBusBuffers& out = data.outputs[0]; - for (int32 ch = 0; ch < out.numChannels; ++ch) { - if (data.symbolicSampleSize == kSample32) { - if (float* buf = out.channelBuffers32[ch]) { - for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.f; - } - } else if (data.symbolicSampleSize == kSample64) { - if (double* buf = out.channelBuffers64[ch]) { - for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.0; +tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { + if (!state) return kResultFalse; + // Read the whole component-state blob (the selected sample id, versioned). The blob + // is small; read in one shot into a growable buffer. + std::vector bytes; + std::uint8_t chunk[256]; + int32 got = 0; + while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { + bytes.insert(bytes.end(), chunk, chunk + got); + if (got < static_cast(sizeof(chunk))) break; + } + setSelectedSampleId(deserializeSelection(bytes)); + // Rebuild from the restored selection (off-thread — setState is a load-time call). + reloadFromBank(); + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { + if (!state) return kResultFalse; + const std::vector bytes = serializeSelection(selectedSampleId()); + if (!bytes.empty()) { + state->write(const_cast(bytes.data()), + static_cast(bytes.size()), nullptr); + } + return kResultOk; +} + +std::string ReaSamplerProcessor::selectedSampleId() { + std::lock_guard lock(selectionMutex_); + return selectedSampleId_; +} + +void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { + std::lock_guard lock(selectionMutex_); + selectedSampleId_ = id; +} + +std::string ReaSamplerProcessor::reloadFromBank() { + // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so + // the retired-slot free is single-writer. This mutex is NEVER taken on the audio + // thread — process() only touches the atomic. + std::lock_guard lock(reloadMutex_); + + // 1. Read the live bank + resolve the project dir over the bridge (allocates, + // calls REAPER — fine here, off-thread). + std::optional banksJson = + bridge_.readReasamplerExtState(kProjExtBanksKey); + const std::string projectDir = bridge_.activeProjectDir(); + + std::string resolvedId; + std::unique_ptr built; + + if (banksJson) { + // 2. Pick the sample (shared bank_book JSON parse — NOT a second parser). + std::optional sel = + selectSample(*banksJson, selectedSampleId()); + if (sel) { + // 3. Resolve the project-relative WAV path the M4 way persist does, read + + // decode it (file I/O off-thread), downmix to the core's mono contract. + const std::string abs = resolveBankFile(projectDir, sel->relativePath); + if (!abs.empty()) { + const std::vector bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + const std::size_t frames = layout.frameCount(); + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, frames); + std::vector mono = + downmixToMono(interleaved, layout.channelCount); + if (!mono.empty()) { + Keymap km = buildTier0Keymap( + std::move(mono), + static_cast(layout.sampleRate), sel->rootNote, + sel->loop); + built = std::make_unique( + std::move(km), kMaxVoices, tier0Adsr(sampleRate_)); + // Record which id actually resolved so a first-sample fallback + // (empty stored id) becomes the concrete selection. + resolvedId = selectedSampleId(); + } } } } - // Flag output silence so the host can optimize (nothing plays yet). + } + + // 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the + // graveyard (process may still be reading it this block — it is reclaimed only when + // process is stopped, in setActive(false)/terminate). A null `built` (no bank / + // unreadable WAV) installs silence. `built` is heap-owned; release() hands + // ownership to the atomic, and the exchanged pointer is re-owned by the graveyard. + LoadedInstrument* prev = live_.exchange(built.release()); + if (prev) graveyard_.emplace_back(prev); + return resolvedId; +} + +tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { + // REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the + // whole block (a single atomic acquire). + LoadedInstrument* inst = live_.load(std::memory_order_acquire); + + // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps + // events at block granularity (no per-event sample-offset split) — audible timing is + // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. + if (inst && data.inputEvents) { + const int32 count = data.inputEvents->getEventCount(); + for (int32 i = 0; i < count; ++i) { + Event e; + if (data.inputEvents->getEvent(i, e) != kResultOk) continue; + if (e.type == Event::kNoteOnEvent) { + // A note-on with velocity 0 is a note-off by MIDI convention. + const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); + if (vel <= 0) { + inst->engine.noteOff(e.noteOn.pitch); + } else { + inst->engine.noteOn(e.noteOn.pitch, vel); + } + } else if (e.type == Event::kNoteOffEvent) { + inst->engine.noteOff(e.noteOff.pitch); + } + } + } + + if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { + return kResultOk; + } + AudioBusBuffers& out = data.outputs[0]; + const int32 frames = data.numSamples; + + // 64-bit host processing is not supported by the mono float core; emit silence + // rather than mis-render. REAPER runs 32-bit float by default. + if (data.symbolicSampleSize != kSample32) { + for (int32 ch = 0; ch < out.numChannels; ++ch) { + if (double* buf = out.channelBuffers64[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; + } + } out.silenceFlags = (out.numChannels >= 64) ? ~0ULL : ((1ULL << out.numChannels) - 1); + return kResultOk; } + + // Render mono into channel 0's buffer, then replicate to the other channels (the + // core is mono-per-sample). Clear channel 0 first (render ADDS), then mix. + float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; + if (ch0) { + for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; + if (inst) { + inst->engine.render(ch0, static_cast(frames)); + } + // Duplicate the mono render across the remaining output channels. + for (int32 ch = 1; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + } + + // Report silence only when nothing is loaded (lets the host optimize when idle). + // With an instrument loaded we clear the flag so a ringing voice is not skipped. + out.silenceFlags = inst ? 0 : ((out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1)); return kResultOk; } IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { if (name && FIDStringsEqual(name, ViewType::kEditor)) { - return new ReaSamplerEditor(&bridge_); + return new ReaSamplerEditor(this); } return nullptr; } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index c189ec5..a4bad33 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -1,21 +1,53 @@ -// reasampler_processor.h — the VST3 SingleComponentEffect skeleton (Phase S1). THIN -// shell: an instrument that declares an event-input bus + a stereo audio-output bus, -// sets up processing, and runs an empty (silent) process. Nothing plays yet — S4 wires -// the pure sampler core into process(); S1 only proves REAPER hosts it. +// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the +// pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio +// output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the +// engine's audio into the output bus — so a chosen bank sample plays chromatically from +// its root note in REAPER's routing/record/render path. // // SingleComponentEffect is the SDK's combined processor+controller base — sanctioned -// for a non-distributable, REAPER-only plugin under D5/D6 (verified: SDK class -// reference). It gives us addAudioOutput/addEventInput and the IEditController seat, so -// createView() can hand the host our IPlugView LICE editor. +// for a non-distributable, REAPER-only plugin under D5/D6. It gives us +// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own +// state (the selected sample), and the IEditController seat so createView() can hand the +// host our IPlugView LICE editor. +// +// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO +// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — bridge ext-state +// read, WAV decode, path resolve, keymap build, VoiceEngine construction — all happens +// OFF the audio thread (reloadFromBank, driven from the main/UI thread) and is handed to +// process via a single atomic pointer swap. See the LoadedInstrument handoff below. #pragma once +#include +#include +#include +#include +#include +#include + #include "public.sdk/source/vst/vstsinglecomponenteffect.h" #include "reaper_bridge.h" +#include "sampler_core.h" namespace reasampler::vst { +// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice +// engine that plays it. The engine holds a reference into the keymap, so the two MUST +// live and die together at a STABLE address — hence this is heap-allocated and neither +// copyable nor movable. The audio thread only ever reads it through an atomic pointer; +// it is built and destroyed off the audio thread. +struct LoadedInstrument { + Keymap keymap; + VoiceEngine engine; + + LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr) + : keymap(std::move(km)), engine(maxVoices, keymap, adsr) {} + + LoadedInstrument(const LoadedInstrument&) = delete; + LoadedInstrument& operator=(const LoadedInstrument&) = delete; +}; + class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect { public: ReaSamplerProcessor() = default; @@ -30,10 +62,16 @@ public: Steinberg::tresult PLUGIN_API terminate() override; Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override; + // Instance state = the selected bank sample id (D-B: a performance choice the + // instrument owns; NEVER written back to the bank). Component-state, so a saved + // REAPER project restores which sample each instance plays. + Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override; + Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override; + //--- from IAudioProcessor ---------------------------------------------- Steinberg::tresult PLUGIN_API setupProcessing( Steinberg::Vst::ProcessSetup& setup) override; - // Empty in the spike: emits silence (S4 fills it). + // Marshals MIDI -> VoiceEngine -> audio output. Real-time safe (no alloc/IO/lock). Steinberg::tresult PLUGIN_API process( Steinberg::Vst::ProcessData& data) override; @@ -41,8 +79,50 @@ public: // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; + // Called by the editor (main/UI thread) when the user picks a sample, and internally + // on load. Reads the live bank over the bridge, resolves+decodes the selected WAV + // OFF the audio thread, and publishes the built instrument to process() via an + // atomic swap. Safe to call with no bridge / no bank (leaves silence). Returns the + // resolved selection id ("" if nothing was loaded) for the editor to reflect. + std::string reloadFromBank(); + + // The bridge, for the editor's live-state readout + sample list. Owned here; the + // editor borrows it (outlives the editor). + ReaperBridge& bridge() { return bridge_; } + // The current selection id (main/UI thread reads for the editor). Guarded by + // selectionMutex_ — never touched on the audio thread. + std::string selectedSampleId(); + void setSelectedSampleId(const std::string& id); + private: ReaperBridge bridge_; + + // --- The audio-thread handoff (S4 real-time discipline) ----------------- + // process() atomically loads `live_` at block start and marshals/renders against it — + // a single atomic acquire, no lock, no free on the audio thread. + // + // reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new + // LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is + // NOT freed on the reload path: process() may still be mid-block reading it, and two + // rapid reloads could otherwise free a pointer process is using. Instead it is parked + // in `graveyard_` and reclaimed only when process is GUARANTEED stopped — at + // setActive(false) / terminate(), which the host never runs concurrently with + // process. The graveyard grows by one engine per reload during a session (bounded by + // user sample switches — a few objects), a deliberate leak-until-deactivate trade for + // a lock-free, race-free audio thread. Tier 2 can add epoch-based reclaim if needed. + std::atomic live_{nullptr}; + std::vector> graveyard_; // freed only when stopped + std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access + + // The selected sample id (instance state). Off-thread only; a small mutex guards the + // string against a getState/editor race. NOT read on the audio thread. + std::mutex selectionMutex_; + std::string selectedSampleId_; + + // Latched from setupProcessing so setActive/reload can size against it. Read + // off-thread only. + double sampleRate_ = 44100.0; + Steinberg::int32 maxBlockSize_ = 4096; }; } // namespace reasampler::vst diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp new file mode 100644 index 0000000..a87a13f --- /dev/null +++ b/src/vst/sample_map.cpp @@ -0,0 +1,129 @@ +// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL / +// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. + +#include "sample_map.h" + +#include // std::memcpy + +namespace reasampler { + +namespace { + +// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank +// stores loop points as an optional LoopPoints (both-or-neither); the core wants a +// SampleLoop with an explicit hasLoop. Absent -> no loop. +SampleLoop loopFromSample(const Sample& s) { + SampleLoop out; + if (s.loop) { + out.hasLoop = true; + out.start = s.loop->start; + out.end = s.loop->end; + } + return out; +} + +// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C +// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on +// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4). +SelectedSample distill(const Sample& s) { + SelectedSample out; + out.relativePath = s.relativePath; + out.rootNote = s.rootNote ? *s.rootNote : 60; + out.loop = loopFromSample(s); + return out; +} + +} // namespace + +std::optional selectSample(const std::string& banksJson, + const std::string& sampleId) { + if (banksJson.empty()) return std::nullopt; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return std::nullopt; // malformed -> nothing to play (never throw) + + // Search every bank (pool first, then named — banks() is ordinal order) for the + // stored id. A sample lives in exactly one bank, so first hit wins. + if (!sampleId.empty()) { + for (const Bank& b : book->banks()) { + if (const Sample* s = b.index.query(sampleId)) { + return distill(*s); + } + } + } + + // No stored id, or the id no longer resolves (the sample was deleted/moved out): + // fall back to the FIRST sample in ordinal order so a fresh instance plays. + for (const Bank& b : book->banks()) { + if (!b.index.all().empty()) { + return distill(b.index.all().front()); + } + } + return std::nullopt; // bank has zero samples anywhere +} + +std::vector listSamples(const std::string& banksJson) { + std::vector out; + if (banksJson.empty()) return out; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; + for (const Bank& b : book->banks()) { + for (const Sample& s : b.index.all()) { + out.push_back(SampleChoice{s.id, s.displayName}); + } + } + return out; +} + +std::vector downmixToMono(const std::vector& interleaved, + int channelCount) { + std::vector out; + if (channelCount <= 0 || interleaved.empty()) return out; + const std::size_t stride = static_cast(channelCount); + const std::size_t frames = interleaved.size() / stride; + out.resize(frames); + const double inv = 1.0 / static_cast(channelCount); + for (std::size_t f = 0; f < frames; ++f) { + double acc = 0.0; + const std::size_t base = f * stride; + for (std::size_t c = 0; c < stride; ++c) { + acc += static_cast(interleaved[base + c]); + } + out[f] = static_cast(acc * inv); + } + return out; +} + +Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, + int rootNote, const SampleLoop& loop) { + SampleData data; + data.frames = std::move(monoFrames); + data.sampleRate = sampleRate > 0 ? sampleRate : 44100; + data.rootNote = rootNote; + data.loop = loop; + return Keymap::singleSampleChromatic(std::move(data)); +} + +std::vector serializeSelection(const std::string& sampleId) { + std::vector out; + out.resize(4 + sampleId.size()); + const std::uint32_t v = kSelectionStateVersion; + out[0] = static_cast(v & 0xFF); + out[1] = static_cast((v >> 8) & 0xFF); + out[2] = static_cast((v >> 16) & 0xFF); + out[3] = static_cast((v >> 24) & 0xFF); + std::memcpy(out.data() + 4, sampleId.data(), sampleId.size()); + return out; +} + +std::string deserializeSelection(const std::vector& bytes) { + if (bytes.size() < 4) return {}; // no version tag -> no selection + const std::uint32_t v = static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8) | + (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); + if (v != kSelectionStateVersion) return {}; // unknown version -> ignore + return std::string(reinterpret_cast(bytes.data() + 4), + bytes.size() - 4); +} + +} // namespace reasampler diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h new file mode 100644 index 0000000..6d3177a --- /dev/null +++ b/src/vst/sample_map.h @@ -0,0 +1,110 @@ +#pragma once +// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live +// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core +// plays, and (de)serialize the instance's selected-sample choice for VST3 component +// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the +// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable +// arithmetic out of a host-facing shell. +// +// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam +// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV). +// Both of those raw inputs cross the bridge/file boundary in the shell; everything +// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second +// parser; the S1 spike's string-scan reader is retired), pick the selected sample, +// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic +// Keymap — is pure and unit-tested here. +// +// It links bank_book (the shared BankBook::deserialize) and wav_trim (the shared +// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap / +// SampleData it produces). All three are pure; this stays pure. + +#include +#include +#include +#include + +#include "bank_book.h" // BankBook::deserialize (shared bank JSON parse) +#include "sampler_core.h" // Keymap, SampleData, SampleLoop +#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) + +namespace reasampler { + +// The bank sample this instance is bound to, distilled from the live "banks" blob: +// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank +// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet. +struct SelectedSample { + std::string relativePath; // project-relative; the shell resolves it (M4 convention) + int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty + SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty +}; + +// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks" +// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank +// project). `sampleId` is this instance's stored selection. +// +// Precedence, all pure: +// * empty / malformed banksJson -> nullopt (nothing to play) +// * sampleId names a sample in ANY bank -> that sample (searched pool + named) +// * sampleId empty or not found, bank has -> the FIRST sample in ordinal order +// >= 1 sample (a sensible default so a fresh +// instance plays SOMETHING; the UI can +// then pick a specific one) +// * bank has zero samples -> nullopt +// +// The "first sample" fallback is deliberate: Tier 0 is "the bank plays", and a brand- +// new instance with no stored selection should map the bank's first sample rather than +// stay silent until the user opens the editor. +std::optional selectSample(const std::string& banksJson, + const std::string& sampleId); + +// All (id, displayName) pairs across every bank in ordinal order (pool first), for the +// selection UI to list. Empty for an empty / malformed blob. Pure projection over the +// shared parse — the UI never parses JSON itself. +struct SampleChoice { + std::string id; + std::string displayName; +}; +std::vector listSamples(const std::string& banksJson); + +// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields: +// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per +// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0, +// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve +// their source channel count, so a stereo (or N-channel) capture is folded to a single +// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is +// the least-surprising, no-clip default — a centered mono source stays unity, and a +// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride +// in -> empty out. Pure. +std::vector downmixToMono(const std::vector& interleaved, + int channelCount); + +// Build the Tier-0 chromatic keymap for one decoded, mono sample: one zone spanning +// the whole keyboard, repitched from `rootNote`, looped per `loop`. This is the +// single-sample degenerate case (Keymap::singleSampleChromatic) with the S2 intrinsics +// threaded in. `monoFrames` is the downmixed PCM; `sampleRate` is the WAV's rate. +Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, + int rootNote, const SampleLoop& loop); + +// --- Instance state (VST3 setState/getState) -------------------------------- +// +// The instrument's OWN state is which bank sample it plays (D-B: the selection is a +// performance choice, held by the instrument, never written back to the bank). It is a +// single string id. serialize/deserialize keep the on-the-wire form explicit and +// versioned so a future Tier can extend it without breaking already-saved instances. +// +// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No +// length prefix is needed — the id runs to the end of the stream (the host tells us the +// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob +// by returning "" (no selection — the instrument falls back to the bank's first sample), +// never throwing across the host boundary. + +inline constexpr std::uint32_t kSelectionStateVersion = 1; + +// The selected-sample id serialized to bytes for IBStream (getState). +std::vector serializeSelection(const std::string& sampleId); + +// The selected-sample id parsed back from IBStream bytes (setState). Unknown version, +// too-short, or empty -> "" (graceful no-selection). +std::string deserializeSelection(const std::vector& bytes); + +} // namespace reasampler diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index ae502bb..57a7748 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -289,18 +289,29 @@ void VoiceEngine::noteOff(int note) { if (target != kNoVoice) voices_[target].release(); } -void VoiceEngine::render(std::vector& out, std::size_t frameCount) { - const std::size_t base = out.size(); - out.resize(base + frameCount, 0.0f); // S4: caller must pre-reserve — no allocation allowed under the VST3 process callback. +void VoiceEngine::render(AudioSample* out, std::size_t frameCount) { + // Real-time safe: no allocation, no resize — mix straight into the caller's buffer. + // The VST3 process callback hands us the host's output channel buffer here, so the + // audio thread never touches the heap (S4 real-time discipline). + if (out == nullptr || frameCount == 0) return; for (Voice& voice : voices_) { if (!voice.active()) continue; for (std::size_t f = 0; f < frameCount; ++f) { if (!voice.active()) break; - out[base + f] += voice.renderFrame(); + out[f] += voice.renderFrame(); } } } +void VoiceEngine::render(std::vector& out, std::size_t frameCount) { + // Off-thread / test path: grow the buffer (this allocates — never call under + // process), zero-fill the appended span, then delegate to the RT mix loop so both + // overloads share exactly one summation path. + const std::size_t base = out.size(); + out.resize(base + frameCount, 0.0f); + render(out.data() + base, frameCount); +} + std::size_t VoiceEngine::activeVoiceCount() const { std::size_t n = 0; for (const Voice& v : voices_) { diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index a51bccc..5efa837 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -236,9 +236,20 @@ public: // the older tail to ring — matches hardware behavior). No-op if none match. void noteOff(int note); - // Renders `frameCount` mono output frames, summing all active voices, appending to - // `out` (does not clear it — the caller owns mixing/clearing). Voices that finish - // mid-block go idle and stop contributing. + // REAL-TIME render (S4): sums all active voices into the caller-provided buffer + // `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes — + // this never touches memory it does not own and NEVER allocates). This is the + // audio-thread entry point: the VST3 process callback passes the host's own output + // channel buffer, so no allocation, resize, or heap traffic happens under process. + // Voices that finish mid-block go idle and stop contributing. `out` must point at + // at least `frameCount` writable samples; a null `out` or zero count is a no-op. + void render(AudioSample* out, std::size_t frameCount); + + // TEST / off-thread convenience: appends `frameCount` summed frames to `out` + // (grows it — DO NOT call on the audio thread; it allocates). Delegates to the + // real-time overload after sizing the buffer, so both paths share one mix loop. + // Does not clear existing contents — appends, matching the pre-S4 contract the + // unit tests rely on. void render(std::vector& out, std::size_t frameCount); // Count of currently active voices (for tests / diagnostics). diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp index e3beb6d..8c6abbb 100644 --- a/tests/test_bridge_marshal.cpp +++ b/tests/test_bridge_marshal.cpp @@ -1,11 +1,12 @@ // Standalone tests for reasampler::vst::bridge_marshal — no VST3, no REAPER, no test // framework. Same fast assert loop as the sibling pure tests: assert the REAPER -// bridge-read marshalling (GetProjExtState result decode + a small JSON string-field -// reader) directly, so the DAW-facing shell only has to invoke the API. +// bridge-read marshalling (GetProjExtState result decode) directly, so the DAW-facing +// shell only has to invoke the API. // // Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer -// guard); extractJsonStringField present/absent/escapes/whitespace/value-vs-key -// disambiguation/non-string-value/malformed. +// guard). The S1 spike's extractJsonStringField string-scan reader was retired in S4 +// (the instrument now parses the bank through the shared bank_book JSON path), so its +// cases are gone with it. #include "../src/vst/bridge_marshal.h" @@ -44,72 +45,11 @@ static void testDecodeEmptyBuffer() { CHECK(!v.has_value()); } -// --- extractJsonStringField --------------------------------------------------- - -static void testExtractPresent() { - const std::string json = R"({"guid":"ABC-123","name":"kick"})"; - auto g = extractJsonStringField(json, "guid"); - CHECK(g && *g == "ABC-123"); - auto n = extractJsonStringField(json, "name"); - CHECK(n && *n == "kick"); -} - -static void testExtractAbsent() { - const std::string json = R"({"guid":"ABC-123"})"; - CHECK(!extractJsonStringField(json, "missing").has_value()); -} - -static void testExtractWhitespaceTolerant() { - const std::string json = "{ \"guid\" : \"X\" , \"n\":\"y\" }"; - auto g = extractJsonStringField(json, "guid"); - CHECK(g && *g == "X"); -} - -static void testExtractEscapes() { - // \" \\ \/ \n \t all decode. - const std::string json = R"({"path":"a\\b\/c\"d\ne"})"; - auto p = extractJsonStringField(json, "path"); - CHECK(p && *p == "a\\b/c\"d\ne"); -} - -static void testExtractValueContainingKeyText() { - // A VALUE that contains the key text must not be mistaken for the member. Here the - // first "guid" occurrence is inside another value; the real member comes later. - const std::string json = R"({"note":"the guid is here","guid":"REAL"})"; - auto g = extractJsonStringField(json, "guid"); - CHECK(g && *g == "REAL"); -} - -static void testExtractNonStringValue() { - // A numeric/object value is not a string — return nullopt rather than garbage. - const std::string json = R"({"count":42,"name":"ok"})"; - CHECK(!extractJsonStringField(json, "count").has_value()); - // The sibling string field still reads. - auto n = extractJsonStringField(json, "name"); - CHECK(n && *n == "ok"); -} - -static void testExtractMalformed() { - CHECK(!extractJsonStringField(R"({"guid":"unterminated)", "guid").has_value()); - CHECK(!extractJsonStringField(R"({"guid":)", "guid").has_value()); - CHECK(!extractJsonStringField(R"({"guid")", "guid").has_value()); - CHECK(!extractJsonStringField("", "guid").has_value()); - // Dangling escape at end of string. - CHECK(!extractJsonStringField(R"({"guid":"abc\)", "guid").has_value()); -} - int main() { testDecodeHit(); testDecodeAbsentKey(); testDecodeNegativeReturn(); testDecodeEmptyBuffer(); - testExtractPresent(); - testExtractAbsent(); - testExtractWhitespaceTolerant(); - testExtractEscapes(); - testExtractValueContainingKeyText(); - testExtractNonStringValue(); - testExtractMalformed(); if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index 2a18a80..b19db13 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -131,6 +131,63 @@ static void testHitTestMatchesDrawnButton() { } } +// --- sample list (S4) --------------------------------------------------------- + +static void testSampleRowRectStacks() { + const EditorLayout L = layoutEditor(400, 260); + const Rect r0 = sampleRowRect(L, 0); + const Rect r1 = sampleRowRect(L, 1); + // Row 0 starts at the canvas top and spans its full width. + CHECK(r0.top == L.canvas.top); + CHECK(r0.left == L.canvas.left && r0.right == L.canvas.right); + CHECK(r0.height() == kSampleRowHeight); + // Row 1 sits directly below row 0 (no gap, no overlap). + CHECK(r1.top == r0.bottom); + CHECK(r1.height() == kSampleRowHeight); + // A negative index is an empty rect. + CHECK(sampleRowRect(L, -1).width() == 0 && sampleRowRect(L, -1).height() == 0); +} + +static void testSampleRowHitTestMapsClickToRow() { + const EditorLayout L = layoutEditor(400, 260); + const int rows = 5; + // A click in the vertical middle of row 2 resolves to index 2. + const Rect r2 = sampleRowRect(L, 2); + const int midY = (r2.top + r2.bottom) / 2; + CHECK(sampleRowHitTest(L, rows, 200, midY) == 2); + // Row 0's top-left corner hits row 0. + const Rect r0 = sampleRowRect(L, 0); + CHECK(sampleRowHitTest(L, rows, r0.left, r0.top) == 0); +} + +static void testSampleRowHitTestMisses() { + const EditorLayout L = layoutEditor(400, 260); + const int rows = 3; + // Above the first row (in the title bar) -> no row. + CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.top) == -1); + // Below the last row -> no row. + const Rect last = sampleRowRect(L, rows - 1); + CHECK(sampleRowHitTest(L, rows, 200, last.bottom + 1) == -1); + // Left of the canvas -> no row. + CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1); + // Zero rows -> always -1. + CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1); +} + +// The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to +// that row's index (the same load-bearing invariant as the button). +static void testSampleRowHitTestMatchesDrawnRows() { + const EditorLayout L = layoutEditor(320, 200); + const int rows = 4; + for (int i = 0; i < rows; ++i) { + const Rect r = sampleRowRect(L, i); + if (r.top >= L.canvas.bottom) break; // clipped rows aren't clickable targets + const int y = (r.top + r.bottom) / 2; + if (y >= L.canvas.bottom) continue; + CHECK(sampleRowHitTest(L, rows, r.left + 1, y) == i); + } +} + int main() { testContainsHalfOpen(); testContainsDegenerate(); @@ -141,6 +198,10 @@ int main() { testHitTestMissesNonButton(); testHitTestButtonBoundary(); testHitTestMatchesDrawnButton(); + testSampleRowRectStacks(); + testSampleRowHitTestMapsClickToRow(); + testSampleRowHitTestMisses(); + testSampleRowHitTestMatchesDrawnRows(); if (g_fail == 0) std::printf("editor_geometry: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp new file mode 100644 index 0000000..130dc05 --- /dev/null +++ b/tests/test_sample_map.cpp @@ -0,0 +1,275 @@ +// Standalone tests for reasampler::sample_map — no VST3, no REAPER, no test framework. +// Same fast assert loop as the sibling pure tests. This module is the S4 mapping heart: +// bank blob -> selected sample (through the SHARED bank_book JSON parse), interleaved -> +// mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap build, and the +// selected-sample instance-state (de)serialization. +// +// Every assertion is written to FAIL if the mapping were wrong: the bank blobs are built +// by serializing a real BankBook (so we exercise the shared parse, not a fixture string), +// and the selection / downmix / keymap / state values are checked against independently +// computed expectations. +// +// Covers: selectSample by-id hit (across pool + named banks), first-sample fallback for +// an empty / unknown id, empty & malformed blob -> nullopt, zero-samples -> nullopt, +// rootNote/loop intrinsic threading incl. the middle-C default; listSamples ordinal +// order + empty/malformed; downmixToMono mono passthrough / stereo average / 3-ch +// average / zero-stride / empty; buildTier0Keymap single full-keyboard zone with the +// root + loop + rate threaded and rate defaulting; selection state round-trip + empty id +// + wrong-version / truncated -> "". + +#include "../src/vst/sample_map.h" + +#include +#include +#include + +#include "../src/bank_book.h" +#include "../src/bank_model.h" + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// Build a Sample with the fields sample_map reads. Relative path is required by +// BankIndex::add (relative-only invariant); a content hash is set so dedup does not +// collapse distinct entries. +static Sample makeSample(const std::string& id, const std::string& name, + const std::string& rel, std::optional root) { + Sample s; + s.id = id; + s.displayName = name; + s.relativePath = rel; + s.contentHash = "hash-" + id; + s.rootNote = root; + return s; +} + +// A serialized BankBook: the pool carries `poolSamples`, and one named bank "Drums" +// carries `drumSamples`. Returns the JSON the instrument would read from ext-state. +static std::string bookJson(const std::vector& poolSamples, + const std::vector& drumSamples) { + BankBook book; + for (const Sample& s : poolSamples) book.pool().index.add(s); + if (!drumSamples.empty()) { + book.createBank("drums-id", "Drums"); + BankIndex* di = book.index("drums-id"); + for (const Sample& s : drumSamples) di->add(s); + } + return book.serialize(); +} + +// --- selectSample ------------------------------------------------------------- + +static void testSelectByIdHit() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + // A sample in the NAMED bank resolves by id (search spans every bank). + auto sel = selectSample(json, "b"); + CHECK(sel.has_value()); + CHECK(sel && sel->relativePath == "reasampler_bank/b.wav"); + CHECK(sel && sel->rootNote == 38); +} + +static void testSelectFirstSampleFallbackOnEmptyId() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + // No stored selection -> the FIRST sample in ordinal order (pool first). + auto sel = selectSample(json, ""); + CHECK(sel.has_value()); + CHECK(sel && sel->relativePath == "reasampler_bank/a.wav"); + CHECK(sel && sel->rootNote == 36); +} + +static void testSelectFirstSampleFallbackOnUnknownId() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {}); + // A stored id that no longer resolves falls back to the first sample, not silence. + auto sel = selectSample(json, "deleted-id"); + CHECK(sel.has_value()); + CHECK(sel && sel->relativePath == "reasampler_bank/a.wav"); +} + +static void testSelectRootNoteDefault() { + const std::string json = bookJson( + {makeSample("a", "Loop", "reasampler_bank/a.wav", std::nullopt)}, {}); + // A sample with no root-note intrinsic defaults to middle C (60). + auto sel = selectSample(json, "a"); + CHECK(sel.has_value()); + CHECK(sel && sel->rootNote == 60); +} + +static void testSelectLoopThreaded() { + Sample s = makeSample("a", "Pad", "reasampler_bank/a.wav", 60); + s.loop = LoopPoints{100, 500}; + const std::string json = bookJson({s}, {}); + auto sel = selectSample(json, "a"); + CHECK(sel.has_value()); + CHECK(sel && sel->loop.hasLoop); + CHECK(sel && sel->loop.start == 100 && sel->loop.end == 500); +} + +static void testSelectNoLoopIsAbsent() { + const std::string json = bookJson( + {makeSample("a", "OneShot", "reasampler_bank/a.wav", 60)}, {}); + auto sel = selectSample(json, "a"); + CHECK(sel.has_value()); + CHECK(sel && !sel->loop.hasLoop); // absent loop -> hasLoop false (not a zero loop) +} + +static void testSelectEmptyBlob() { + CHECK(!selectSample("", "a").has_value()); +} + +static void testSelectMalformedBlob() { + CHECK(!selectSample("{not valid json", "a").has_value()); +} + +static void testSelectZeroSamples() { + // A valid book with NO samples anywhere -> nothing to play. + const std::string json = bookJson({}, {}); + CHECK(!selectSample(json, "").has_value()); + CHECK(!selectSample(json, "anything").has_value()); +} + +// --- listSamples -------------------------------------------------------------- + +static void testListSamplesOrdinalOrder() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36), + makeSample("c", "Hat", "reasampler_bank/c.wav", 42)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + const std::vector list = listSamples(json); + // Pool samples (insertion order) come before the named bank's. + CHECK(list.size() == 3); + CHECK(list.size() == 3 && list[0].id == "a" && list[0].displayName == "Kick"); + CHECK(list.size() == 3 && list[1].id == "c"); + CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare"); +} + +static void testListSamplesEmptyAndMalformed() { + CHECK(listSamples("").empty()); + CHECK(listSamples("{garbage").empty()); + CHECK(listSamples(bookJson({}, {})).empty()); +} + +// --- downmixToMono ------------------------------------------------------------ + +static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; } + +static void testDownmixMonoPassthrough() { + const std::vector in{0.1f, -0.2f, 0.3f}; + const std::vector out = downmixToMono(in, 1); + CHECK(out.size() == 3); + CHECK(out.size() == 3 && approx(out[0], 0.1) && approx(out[1], -0.2) && + approx(out[2], 0.3)); +} + +static void testDownmixStereoAverages() { + // Two frames, stereo interleaved: frame0 = (1.0, 0.0) -> 0.5; frame1 = (0.4, 0.6) -> 0.5. + const std::vector in{1.0f, 0.0f, 0.4f, 0.6f}; + const std::vector out = downmixToMono(in, 2); + CHECK(out.size() == 2); + CHECK(out.size() == 2 && approx(out[0], 0.5) && approx(out[1], 0.5)); +} + +static void testDownmixThreeChannelAverages() { + // One 3-channel frame (0.3, 0.3, 0.6) -> 0.4. + const std::vector in{0.3f, 0.3f, 0.6f}; + const std::vector out = downmixToMono(in, 3); + CHECK(out.size() == 1); + CHECK(out.size() == 1 && approx(out[0], 0.4)); +} + +static void testDownmixDegenerate() { + CHECK(downmixToMono({}, 2).empty()); // empty input + CHECK(downmixToMono({0.1f, 0.2f}, 0).empty()); // zero stride + CHECK(downmixToMono({0.1f, 0.2f}, -1).empty()); // negative stride +} + +// --- buildTier0Keymap --------------------------------------------------------- + +static void testBuildKeymapSingleFullZone() { + SampleLoop loop; + loop.hasLoop = true; + loop.start = 10; + loop.end = 90; + const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 40, loop); + // One sample, one zone spanning the whole keyboard, rooted at 40. + CHECK(km.samples.size() == 1); + CHECK(km.zones.size() == 1); + CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 0 && km.zones[0].highNote == 127); + CHECK(km.zones.size() == 1 && km.zones[0].rootNote == 40); + CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 48000); + CHECK(km.samples.size() == 1 && km.samples[0].rootNote == 40); + CHECK(km.samples.size() == 1 && km.samples[0].frames.size() == 3); + CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop && + km.samples[0].loop.start == 10 && km.samples[0].loop.end == 90); + // Resolution: any note lands in the single zone. + CHECK(km.resolve(0, 100).matched); + CHECK(km.resolve(127, 100).matched); +} + +static void testBuildKeymapRateDefault() { + // A zero/invalid rate defaults to 44100 rather than producing a divide-by-zero-shaped + // sample rate downstream. + const Keymap km = buildTier0Keymap({0.1f}, 0, 60, SampleLoop{}); + CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 44100); +} + +// --- selection state (setState/getState) -------------------------------------- + +static void testSelectionStateRoundTrip() { + const std::string id = "sample-guid-123"; + const std::vector bytes = serializeSelection(id); + // Versioned: 4-byte tag + the id bytes. + CHECK(bytes.size() == 4 + id.size()); + CHECK(deserializeSelection(bytes) == id); +} + +static void testSelectionStateEmptyId() { + const std::vector bytes = serializeSelection(""); + CHECK(bytes.size() == 4); // just the version tag + CHECK(deserializeSelection(bytes) == ""); +} + +static void testSelectionStateWrongVersion() { + std::vector bytes = serializeSelection("id"); + bytes[0] = 0xEE; // corrupt the version tag + CHECK(deserializeSelection(bytes) == ""); // unknown version -> no selection +} + +static void testSelectionStateTruncated() { + CHECK(deserializeSelection({}) == ""); // empty + CHECK(deserializeSelection({1, 0, 0}) == ""); // fewer than 4 bytes (no tag) +} + +int main() { + testSelectByIdHit(); + testSelectFirstSampleFallbackOnEmptyId(); + testSelectFirstSampleFallbackOnUnknownId(); + testSelectRootNoteDefault(); + testSelectLoopThreaded(); + testSelectNoLoopIsAbsent(); + testSelectEmptyBlob(); + testSelectMalformedBlob(); + testSelectZeroSamples(); + testListSamplesOrdinalOrder(); + testListSamplesEmptyAndMalformed(); + testDownmixMonoPassthrough(); + testDownmixStereoAverages(); + testDownmixThreeChannelAverages(); + testDownmixDegenerate(); + testBuildKeymapSingleFullZone(); + testBuildKeymapRateDefault(); + testSelectionStateRoundTrip(); + testSelectionStateEmptyId(); + testSelectionStateWrongVersion(); + testSelectionStateTruncated(); + + if (g_fail == 0) std::printf("sample_map: all tests passed\n"); + return g_fail != 0; +} From b4b64ce68f69784409e04645090a9e7f468db2eb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 16:57:48 -0400 Subject: [PATCH 07/49] fix(S4): close graveyard epoch ordering race; add canvas-clip + stride integration tests Tag LoadedInstrument with installedAt; process() publishes that field (not a re-read of reloadGeneration_) so the pruner's displacedAt <= seen bound is airtight. Also fixes sampleRowHitTest canvas.bottom over-read and adds interleave-stride + canvas-clip tests. --- src/vst/editor_geometry.cpp | 5 +- src/vst/reasampler_processor.cpp | 49 ++++++++++++--- src/vst/reasampler_processor.h | 41 +++++++++--- tests/test_editor_geometry.cpp | 10 +++ tests/test_sample_map.cpp | 105 +++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 19 deletions(-) diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp index 39919bb..83a9b7d 100644 --- a/src/vst/editor_geometry.cpp +++ b/src/vst/editor_geometry.cpp @@ -64,9 +64,12 @@ int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { // Must be within the canvas horizontally and at/below its top. if (x < layout.canvas.left || x >= layout.canvas.right) return -1; if (y < layout.canvas.top) return -1; + // Clip at the canvas bottom: clicks in the canvas's dead-zone below the last + // visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom. + if (y >= layout.canvas.bottom) return -1; const int index = (y - layout.canvas.top) / kSampleRowHeight; if (index < 0 || index >= rowCount) return -1; - // Guard the bottom edge: a click below the last row's canvas bottom is outside. + // Guard the bottom edge: a click below the last row's bottom is outside. const Rect r = sampleRowRect(layout, index); if (y >= r.bottom) return -1; return index; diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 6346b83..ed3c337 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -2,6 +2,7 @@ #include "reasampler_processor.h" +#include #include #include #include @@ -121,7 +122,6 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { int32 got = 0; while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { bytes.insert(bytes.end(), chunk, chunk + got); - if (got < static_cast(sizeof(chunk))) break; } setSelectedSampleId(deserializeSelection(bytes)); // Rebuild from the restored selection (off-thread — setState is a load-time call). @@ -133,8 +133,9 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; const std::vector bytes = serializeSelection(selectedSampleId()); if (!bytes.empty()) { - state->write(const_cast(bytes.data()), - static_cast(bytes.size()), nullptr); + const tresult wr = state->write(const_cast(bytes.data()), + static_cast(bytes.size()), nullptr); + if (wr != kResultOk) return wr; } return kResultOk; } @@ -155,6 +156,10 @@ std::string ReaSamplerProcessor::reloadFromBank() { // thread — process() only touches the atomic. std::lock_guard lock(reloadMutex_); + // Mint this reload's generation number first so we can stamp the built instrument + // with it before publishing. Under reloadMutex_ no other reload races here. + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + // 1. Read the live bank + resolve the project dir over the bridge (allocates, // calls REAPER — fine here, off-thread). std::optional banksJson = @@ -187,7 +192,7 @@ std::string ReaSamplerProcessor::reloadFromBank() { static_cast(layout.sampleRate), sel->rootNote, sel->loop); built = std::make_unique( - std::move(km), kMaxVoices, tier0Adsr(sampleRate_)); + std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen); // Record which id actually resolved so a first-sample fallback // (empty stored id) becomes the concrete selection. resolvedId = selectedSampleId(); @@ -198,19 +203,43 @@ std::string ReaSamplerProcessor::reloadFromBank() { } // 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the - // graveyard (process may still be reading it this block — it is reclaimed only when - // process is stopped, in setActive(false)/terminate). A null `built` (no bank / - // unreadable WAV) installs silence. `built` is heap-owned; release() hands - // ownership to the atomic, and the exchanged pointer is re-owned by the graveyard. + // graveyard tagged with this generation (process may still be mid-block reading + // it). A null `built` (no bank / unreadable WAV) installs silence. + // `built` is heap-owned; release() hands ownership to the atomic, and the + // exchanged pointer is re-owned by the graveyard. + // + // Bounded reclaim: prune graveyard entries where displacedAt <= seen, where seen + // is the last generation process() published. process() publishes inst->installedAt + // (not a re-read of reloadGeneration_), so seen == D means process holds the + // instrument installed at gen D. An entry with displacedAt == D was displaced by + // reload D, which installed that very successor — process cannot be holding the + // displaced entry. The pruning condition is therefore <= (see header for the full + // proof). Remaining entries drain at setActive(false) / terminate() when process + // is guaranteed stopped. + const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); + graveyard_.erase( + std::remove_if(graveyard_.begin(), graveyard_.end(), + [seen](const GraveyardEntry& e) { return e.displacedAt <= seen; }), + graveyard_.end()); LoadedInstrument* prev = live_.exchange(built.release()); - if (prev) graveyard_.emplace_back(prev); + if (prev) graveyard_.push_back({gen, std::unique_ptr(prev)}); return resolvedId; } tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the - // whole block (a single atomic acquire). + // whole block (a single atomic acquire), then publish inst->installedAt so the off- + // thread graveyard pruner knows exactly which generation this block is holding. + // + // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an + // ordering race: reading reloadGeneration_ after live_ could observe a generation + // newer than the pointer we actually hold, causing the pruner to free an instrument + // process is still reading. installedAt was set on the reload path before the atomic + // exchange that made the instrument visible, so it is always <= the generation of any + // instrument that could have been loaded after our acquire above. LoadedInstrument* inst = live_.load(std::memory_order_acquire); + const std::uint64_t heldGen = inst ? inst->installedAt : 0; + processGeneration_.store(heldGen, std::memory_order_release); // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps // events at block granularity (no per-event sample-offset split) — audible timing is diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index a4bad33..c0bffbc 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -37,12 +37,19 @@ namespace reasampler::vst { // live and die together at a STABLE address — hence this is heap-allocated and neither // copyable nor movable. The audio thread only ever reads it through an atomic pointer; // it is built and destroyed off the audio thread. +// +// installedAt: the reloadGeneration_ value at which this instrument was atomically +// installed into live_. Set on the reload path before the exchange. process() publishes +// this field (not a fresh re-read of reloadGeneration_) so the published generation is +// exactly the generation of the instrument actually in hand for the block. struct LoadedInstrument { Keymap keymap; VoiceEngine engine; + std::uint64_t installedAt = 0; // reload generation at which this was installed - LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr) - : keymap(std::move(km)), engine(maxVoices, keymap, adsr) {} + LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr, + std::uint64_t gen) + : keymap(std::move(km)), engine(maxVoices, keymap, adsr), installedAt(gen) {} LoadedInstrument(const LoadedInstrument&) = delete; LoadedInstrument& operator=(const LoadedInstrument&) = delete; @@ -105,13 +112,31 @@ private: // LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is // NOT freed on the reload path: process() may still be mid-block reading it, and two // rapid reloads could otherwise free a pointer process is using. Instead it is parked - // in `graveyard_` and reclaimed only when process is GUARANTEED stopped — at - // setActive(false) / terminate(), which the host never runs concurrently with - // process. The graveyard grows by one engine per reload during a session (bounded by - // user sample switches — a few objects), a deliberate leak-until-deactivate trade for - // a lock-free, race-free audio thread. Tier 2 can add epoch-based reclaim if needed. + // in `graveyard_` tagged with the reload generation at which it was displaced. + // + // Bounded reclaim: process() publishes inst->installedAt (the generation at which the + // held instrument was installed) via processGeneration_ — a single atomic store, RT- + // safe. The reload path prunes graveyard entries where displacedAt <= seen (where seen + // is the last published processGeneration_). + // + // Safety argument: an entry with displacedAt == D was displaced by reload D, which + // simultaneously installed its successor with installedAt == D. process() publishing + // seen == D means it holds that successor (or a later one). In either case, the + // displaced entry is not the pointer process is using, so freeing it is safe. The + // pruning condition is therefore <= (not strict <): an entry displaced at exactly the + // published generation is also provably unreachable. + // + // The graveyard's upper bound is the number of reloads since process last ran + // (typically 0–1 in normal use). Remaining entries drain at setActive(false) / + // terminate(), when the host guarantees process is stopped. std::atomic live_{nullptr}; - std::vector> graveyard_; // freed only when stopped + std::atomic reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process) + std::atomic processGeneration_{0}; // generation last seen by process (written on audio thread, read off-thread) + struct GraveyardEntry { + std::uint64_t displacedAt = 0; // reloadGeneration_ value when this was displaced + std::unique_ptr instrument; + }; + std::vector graveyard_; // drained on reclaim + setActive(false) + terminate std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access // The selected sample id (instance state). Off-thread only; a small mutex guards the diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index b19db13..d808584 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -172,6 +172,16 @@ static void testSampleRowHitTestMisses() { CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1); // Zero rows -> always -1. CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1); + // At or below canvas.bottom -> always -1, even if rowCount would cover that y. + // This guards paint<->hit-test agreement: sampleRowRect does not clamp to canvas, + // so without this clip a row that extends past canvas.bottom would hit-test but + // never be drawn (or vice versa). + CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom) == -1); + // Use a large rowCount so index arithmetic would return a valid row without the + // canvas.bottom guard — proving the guard fires independently of rowCount. + const int bigRows = 1000; + CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom) == -1); + CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom + 5) == -1); } // The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 130dc05..70e2bc5 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -16,12 +16,17 @@ // average / zero-stride / empty; buildTier0Keymap single full-keyboard zone with the // root + loop + rate threaded and rate defaulting; selection state round-trip + empty id // + wrong-version / truncated -> "". +// wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave- +// stride contract across the seam (that the byte stride wav_trim reports matches the +// channel-count stride downmixToMono divides by). #include "../src/vst/sample_map.h" #include #include +#include #include +#include #include "../src/bank_book.h" #include "../src/bank_model.h" @@ -247,6 +252,104 @@ static void testSelectionStateTruncated() { CHECK(deserializeSelection({1, 0, 0}) == ""); // fewer than 4 bytes (no tag) } +// --- wav_trim -> extractFloatFrames -> downmixToMono integration --------------- +// +// Locks the interleave-stride contract at the seam between wav_trim and sample_map: +// wav_trim reports channelCount, extractFloatFrames yields interleaved samples with +// that stride, and downmixToMono divides by that same stride. If either module +// changed its understanding of the layout (e.g. extractFloatFrames started packing +// differently, or downmixToMono changed its stride divisor), this test catches it. + +static void putU16sm(std::vector& b, std::uint16_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); +} +static void putU32sm(std::vector& b, std::uint32_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); + b.push_back(static_cast((v >> 16) & 0xFF)); + b.push_back(static_cast((v >> 24) & 0xFF)); +} +static void putTagsm(std::vector& b, const char* t) { + for (int i = 0; i < 4; ++i) b.push_back(static_cast(t[i])); +} +static void putFloatsm(std::vector& b, float f) { + std::uint8_t tmp[4]; + std::memcpy(tmp, &f, 4); + for (int i = 0; i < 4; ++i) b.push_back(tmp[i]); +} + +// Build a 32-bit-float WAV byte buffer. Samples: frame f, channel c = value(f, c). +template +static std::vector buildWav(std::uint16_t channels, + std::uint32_t sampleRate, + std::size_t frames, + Fn value) { + const std::uint32_t dataBytes = + static_cast(frames * channels * 4u); + std::vector chunks; + putTagsm(chunks, "fmt "); + putU32sm(chunks, 16); + putU16sm(chunks, 3); // IEEE float + putU16sm(chunks, channels); + putU32sm(chunks, sampleRate); + putU32sm(chunks, sampleRate * channels * 4u); + putU16sm(chunks, static_cast(channels * 4)); + putU16sm(chunks, 32); + putTagsm(chunks, "data"); + putU32sm(chunks, dataBytes); + for (std::size_t f = 0; f < frames; ++f) + for (std::uint16_t c = 0; c < channels; ++c) + putFloatsm(chunks, value(f, c)); + std::vector wav; + putTagsm(wav, "RIFF"); + putU32sm(wav, static_cast(4 + chunks.size())); + putTagsm(wav, "WAVE"); + wav.insert(wav.end(), chunks.begin(), chunks.end()); + return wav; +} + +static void testWavTrimToDownmixPipelineStereo() { + // Stereo WAV: frame f, L = f * 0.1f, R = f * 0.1f + 0.5f. Expected mono average: + // (f * 0.1f + f * 0.1f + 0.5f) / 2 = f * 0.1f + 0.25f. + const std::size_t kFrames = 4; + auto wav = buildWav(2, 48000, kFrames, + [](std::size_t f, std::uint16_t c) { + return static_cast(f) * 0.1f + (c == 1 ? 0.5f : 0.0f); + }); + WavLayout layout = parseWavLayout(wav); + CHECK(layout.valid); + CHECK(layout.channelCount == 2); + CHECK(layout.frameCount() == kFrames); + const std::vector interleaved = + extractFloatFrames(wav, layout, 0, layout.frameCount()); + CHECK(interleaved.size() == kFrames * 2); + const std::vector mono = downmixToMono(interleaved, layout.channelCount); + CHECK(mono.size() == kFrames); + for (std::size_t f = 0; f < kFrames; ++f) { + const float expected = static_cast(f) * 0.1f + 0.25f; + CHECK(approx(mono[f], expected)); + } +} + +static void testWavTrimToDownmixPipelineMono() { + // Mono WAV: extractFloatFrames -> downmixToMono with channelCount==1 is a passthrough. + const std::size_t kFrames = 3; + auto wav = buildWav(1, 44100, kFrames, + [](std::size_t f, std::uint16_t) { + return static_cast(f) * 0.5f; + }); + WavLayout layout = parseWavLayout(wav); + CHECK(layout.valid); + CHECK(layout.channelCount == 1); + const std::vector interleaved = + extractFloatFrames(wav, layout, 0, layout.frameCount()); + CHECK(interleaved.size() == kFrames); + const std::vector mono = downmixToMono(interleaved, layout.channelCount); + CHECK(mono.size() == kFrames); + CHECK(approx(mono[0], 0.0) && approx(mono[1], 0.5) && approx(mono[2], 1.0)); +} + int main() { testSelectByIdHit(); testSelectFirstSampleFallbackOnEmptyId(); @@ -269,6 +372,8 @@ int main() { testSelectionStateEmptyId(); testSelectionStateWrongVersion(); testSelectionStateTruncated(); + testWavTrimToDownmixPipelineStereo(); + testWavTrimToDownmixPipelineMono(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; From bb066fe1ab6b10f5323326809a487f5b06084bac Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 16:42:47 -0400 Subject: [PATCH 08/49] docs(plan): tick S3 checkboxes; resolve VST-host bridge must-verify entry --- PLAN.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/PLAN.md b/PLAN.md index 44243d1..b6139ae 100644 --- a/PLAN.md +++ b/PLAN.md @@ -167,15 +167,15 @@ maps a (note, velocity) to the correct sample/zone; the core takes and returns o plain data (no VST3/REAPER types) — enforced by the test target linking neither SDK. **Depends on:** S2 (consumes `rootNote` / loop points as core inputs). -- [ ] Voice engine: polyphonic voice allocation (note-on/off, bounded voice stealing), +- [x] Voice engine: polyphonic voice allocation (note-on/off, bounded voice stealing), per-voice state, mono-and-basic-polyphony sufficient for Tier 0. -- [ ] Amplitude envelope (ADSR) math — asserted against a known signal. -- [ ] Repitch/interpolation from root note (chromatic pitch ratio across the +- [x] Amplitude envelope (ADSR) math — asserted against a known signal. +- [x] Repitch/interpolation from root note (chromatic pitch ratio across the keyboard); loop-point-aware sustain for held notes. -- [ ] Keymap model + resolution: key ranges/zones (Tier-1 shape) and the +- [x] Keymap model + resolution: key ranges/zones (Tier-1 shape) and the (note, velocity) → sample/zone query; Tier-0 chromatic-from-single-root as the degenerate case. -- [ ] Tests: voice allocation under polyphony + stealing; ADSR envelope shape; +- [x] Tests: voice allocation under polyphony + stealing; ADSR envelope shape; repitch pitch-ratio correctness; keymap resolution (single-root chromatic + zoned); core boundary is plain-data-only (no VST3/REAPER types). @@ -260,9 +260,10 @@ build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle agains factory-macro spellings, Windows module-export symbol names (`InitDll`/`ExitDll`/`GetPluginFactory`), and whether VSTGUI is bundled. Several are §1a experienced-estimates until S1 confirms them against the vendored SDK. -- **VST-host bridge** — opcodes `0xdeadf00d` (resolve-by-name) / `0xdeadf00e` (host - context) and the exact call marshalling, against `reaper_plugin.h` / - `video_processor.h` / `reaper_plugin_functions.h`. +- **VST-host bridge** — ~~opcodes `0xdeadf00d`/`0xdeadf00e`~~ **verified (S1):** VST3 + path is `IReaperHostApplication::getReaperApi` (resolve-by-name) and + `getReaperParent(3)` (host context) via `reaper_vst3_interfaces.h`; the + `0xdeadf00d`/`0xdeadf00e` opcodes are VST2-only and do not apply. - **`IReaperUIEmbedInterface`** — embed contract + message/lifecycle, against `reaper_plugin_fx_embed.h` (needed only at S6). From 235695893030a5ccba089e0a869f0aded62936e5 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 17:28:15 -0400 Subject: [PATCH 09/49] S5 Tier-1: zoned keymap editor + performance-map playback/persistence Zone editor in the IPlugView LICE surface, zoned resolution built off-thread into the LoadedInstrument keymap with Tier-0 fallback, performance map in VST3 component state with v1 back-compat. Pure resolve/build/serialize + geometry with CTest coverage. --- src/vst/editor_geometry.cpp | 86 ++++++++++++ src/vst/editor_geometry.h | 71 ++++++++++ src/vst/reasampler_editor.cpp | 186 +++++++++++++++++++++++--- src/vst/reasampler_editor.h | 21 ++- src/vst/reasampler_processor.cpp | 132 +++++++++++++----- src/vst/reasampler_processor.h | 21 ++- src/vst/sample_map.cpp | 166 ++++++++++++++++++++++- src/vst/sample_map.h | 101 ++++++++++++++ tests/test_editor_geometry.cpp | 119 +++++++++++++++++ tests/test_sample_map.cpp | 223 +++++++++++++++++++++++++++++++ 10 files changed, 1063 insertions(+), 63 deletions(-) diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp index 83a9b7d..f838420 100644 --- a/src/vst/editor_geometry.cpp +++ b/src/vst/editor_geometry.cpp @@ -75,4 +75,90 @@ int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { return index; } +// --- Keymap editor ----------------------------------------------------------- + +KeymapEditorLayout layoutKeymapEditor(int w, int h) { + KeymapEditorLayout out; + out.base = layoutEditor(w, h); + const Rect& canvas = out.base.canvas; + + // Split the canvas vertically: the left column is the bank-sample list, the right + // column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so + // the split point never crosses the canvas edges. + const int canvasW = std::max(0, canvas.width()); + const int splitW = canvasW / kZonePanelFraction; // width of the zone panel + const int splitX = std::max(canvas.left, canvas.right - splitW); + + out.sampleList = Rect{canvas.left, canvas.top, splitX, canvas.bottom}; + out.zonePanel = Rect{splitX, canvas.top, canvas.right, canvas.bottom}; + + // "Add Zone" button spans the top of the zone panel, clamped to its height. + const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height())); + out.addZoneButton = + Rect{out.zonePanel.left, out.zonePanel.top, out.zonePanel.right, + out.zonePanel.top + addH}; + + // Zone rows stack below the button. + out.zoneRowArea = Rect{out.zonePanel.left, out.addZoneButton.bottom, + out.zonePanel.right, out.zonePanel.bottom}; + return out; +} + +Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) { + if (index < 0) return Rect{}; + const int top = layout.sampleList.top + index * kSampleRowHeight; + return Rect{layout.sampleList.left, top, layout.sampleList.right, + top + kSampleRowHeight}; +} + +int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) { + if (rowCount <= 0) return -1; + const Rect& list = layout.sampleList; + if (x < list.left || x >= list.right) return -1; + if (y < list.top || y >= list.bottom) return -1; + const int index = (y - list.top) / kSampleRowHeight; + if (index < 0 || index >= rowCount) return -1; + const Rect r = keymapSampleRowRect(layout, index); + if (y >= r.bottom) return -1; + return index; +} + +Rect zoneRowRect(const KeymapEditorLayout& layout, int index) { + if (index < 0) return Rect{}; + const int top = layout.zoneRowArea.top + index * kZoneRowHeight; + return Rect{layout.zoneRowArea.left, top, layout.zoneRowArea.right, + top + kZoneRowHeight}; +} + +ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) { + if (zoneCount <= 0) return ZoneHit{}; + const Rect& area = layout.zoneRowArea; + if (x < area.left || x >= area.right) return ZoneHit{}; + if (y < area.top || y >= area.bottom) return ZoneHit{}; + const int index = (y - area.top) / kZoneRowHeight; + if (index < 0 || index >= zoneCount) return ZoneHit{}; + const Rect row = zoneRowRect(layout, index); + if (y >= row.bottom) return ZoneHit{}; + + // Seven mini-buttons pinned to the right edge, right-to-left: + // delete, root+, root-, high+, high-, low+, low- + // Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select"). + // The fields laid out LEFT-TO-RIGHT in slot order 0..6. + const ZoneField fields[7] = { + ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, + ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, + ZoneField::kDelete, + }; + const int slots = 7; + const int ctrlBlockLeft = row.right - slots * kZoneCtrlWidth; + if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select + const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth; + if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone}; + return ZoneHit{index, fields[slot]}; +} + +bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) { + return contains(layout.addZoneButton, x, y); +} + } // namespace reasampler::vst diff --git a/src/vst/editor_geometry.h b/src/vst/editor_geometry.h index 2fb3697..0b57487 100644 --- a/src/vst/editor_geometry.h +++ b/src/vst/editor_geometry.h @@ -75,4 +75,75 @@ Rect sampleRowRect(const EditorLayout& layout, int index); // 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); +// --- Keymap editor (S5 Tier-1 UI) ------------------------------------------- +// +// The Tier-1 editor splits the canvas into a LEFT bank-sample list (the same rows as +// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing +// the performance map's zones. An "Add Zone" button sits at the top of the zone panel; +// each zone row carries small nudge/delete controls so the user can set the range and +// root note without a text field (LICE has no native numeric entry). All rectangle math +// is here so the shell only draws + routes — the mirror of the sample-list split above. + +// Fixed metrics for the zone panel, exposed so the shell and tests agree. +inline constexpr int kZoneRowHeight = 24; +inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas +inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button +inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band height + +// The keymap editor's regions, derived from the (w x h) client area. All clamp to the +// canvas so a degenerate view yields in-bounds rects. +struct KeymapEditorLayout { + EditorLayout base; // title bar + canvas (the sample list uses base.canvas.left half) + Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here) + Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows + Rect addZoneButton; // top of the zone panel + Rect zoneRowArea; // below addZoneButton: where zone rows stack +}; + +KeymapEditorLayout layoutKeymapEditor(int w, int h); + +// The rectangle for bank-sample row `index` inside the LEFT sample list column of a +// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside +// sampleList. Negative index -> empty. Pure. +Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index); + +// The bank-sample row a click lands on inside the left list, or -1 outside it. Pure. +int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y); + +// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative +// index -> empty. Pure. +Rect zoneRowRect(const KeymapEditorLayout& layout, int index); + +// A zone row's interactive fields. The row is a horizontal strip: a label on the left, +// then six fixed-width nudge/delete mini-buttons on the right (low-, low+, high-, high+, +// root-, root+) followed by a delete button. kZoneNone means the click missed a control +// (e.g. on the label) — the shell may still treat that as "select this zone". +enum class ZoneField { + kZoneNone, + kLowDown, + kLowUp, + kHighDown, + kHighUp, + kRootDown, + kRootUp, + kDelete, +}; + +// The result of hit-testing a click against the zone rows: which zone row (or -1) and +// which field within it. A click on the "Add Zone" button is reported separately by +// addZoneHitTest — this covers only the zone rows. +struct ZoneHit { + int zoneIndex = -1; + ZoneField field = ZoneField::kZoneNone; +}; + +// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a +// click outside every zone row. Within a row, the six nudges + delete occupy fixed-width +// slots on the right edge (right-to-left: delete, root+, root-, high+, high-, low+, low-); +// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure. +ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y); + +// True if (x, y) lands on the "Add Zone" button. Pure. +bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y); + } // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 3daf183..b21b4fb 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -38,6 +38,9 @@ const LICE_pixel kColBtnHitBg = LICE_RGBA(58, 96, 84, 255); const LICE_pixel kColBtnBorder = LICE_RGBA(120, 200, 160, 255); const COLORREF kRgbText = RGB(210, 230, 220); +const LICE_pixel kColZoneSelBg = LICE_RGBA(48, 72, 64, 255); +const LICE_pixel kColCtrlBg = LICE_RGBA(60, 60, 66, 255); + void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { // LICE has no built-in font handle here; use GDI text into the bitmap DC, matching // bank_panel's drawCenteredText approach (SetTextColor + DrawText on getDC()). @@ -47,6 +50,26 @@ void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { RECT gr{r.left, r.top, r.right, r.bottom}; DrawTextA(dc, s, -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); } + +void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { + HDC dc = bmp->getDC(); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, col); + RECT gr{r.left, r.top, r.right, r.bottom}; + DrawTextA(dc, s, -1, &gr, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); +} + +// Clamp a MIDI note to [0, 127]. +int clampNote(int n) { return n < 0 ? 0 : (n > 127 ? 127 : n); } + +// A short display name for a bank sample id, from the snapshotted list (id if unnamed, +// "?" if the id no longer resolves — e.g. a stale zone). +std::string sampleLabel(const std::vector& samples, const std::string& id) { + for (const SampleChoice& c : samples) { + if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; + } + return "?"; // stale: id not in the live bank +} #endif } // namespace @@ -62,11 +85,26 @@ void ReaSamplerEditor::refreshSampleList() { if (!processor_) { samples_.clear(); selectedId_.clear(); + map_.zones.clear(); + selectedZone_ = -1; return; } auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); samples_ = banks ? listSamples(*banks) : std::vector{}; selectedId_ = processor_->selectedSampleId(); + map_ = processor_->performanceMap(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; +} + +void ReaSamplerEditor::commitMapAndReload() { + // UI thread only. Publish the edited map to the processor, then rebuild the instrument + // off the audio thread (reloadFromBank bakes the zones into the live Keymap). + if (!processor_) return; + processor_->setPerformanceMap(map_); + processor_->reloadFromBank(); +#ifdef _WIN32 + if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); +#endif } ReaSamplerEditor::~ReaSamplerEditor() { @@ -160,28 +198,33 @@ void ReaSamplerEditor::paint(HDC hdc) { LICE_SysBitmap bmp(w, h); LICE_Clear(&bmp, kColBackground); - const EditorLayout layout = layoutEditor(w, h); + const KeymapEditorLayout layout = layoutKeymapEditor(w, h); + const EditorLayout& base = layout.base; - // Title band: the plugin name + whether a live bank is linked. - LICE_FillRect(&bmp, layout.titleBar.left, layout.titleBar.top, - layout.titleBar.width(), layout.titleBar.height(), kColTitleBg, 1.0f, - 0); + // Title band: the plugin name + a live-state / mode readout. + LICE_FillRect(&bmp, base.titleBar.left, base.titleBar.top, base.titleBar.width(), + base.titleBar.height(), kColTitleBg, 1.0f, 0); std::string title = "ReaSampler Instrument"; if (processor_ && processor_->bridge().isConnected()) { - title += samples_.empty() ? " [bank: empty]" : " [pick a sample]"; + if (samples_.empty()) { + title += " [bank: empty]"; + } else if (map_.zones.empty()) { + title += " [Tier 0: pick a sample - Add Zone for a keymap]"; + } else { + title += " [keymap: " + std::to_string(map_.zones.size()) + " zone(s)]"; + } } else { title += " [host: no bridge]"; } - Rect titleText{layout.titleBar.left + 8, layout.titleBar.top, - layout.titleBar.right - 8, layout.titleBar.bottom}; + Rect titleText{base.titleBar.left + 8, base.titleBar.top, base.titleBar.right - 8, + base.titleBar.bottom}; drawText(&bmp, titleText, title.c_str(), kRgbText); - // Sample list: one row per bank sample, the selected one highlighted. Clip drawing - // to rows that fall within the canvas (sampleRowRect computes all; we skip off-screen - // ones so a huge bank doesn't waste paint). + // LEFT column: the bank-sample list. The selected id (fallback + "sample to add" for + // a new zone) is highlighted. Clip rows past the visible list. for (int i = 0; i < static_cast(samples_.size()); ++i) { - const Rect row = sampleRowRect(layout, i); - if (row.top >= layout.canvas.bottom) break; // past the visible area + const Rect row = keymapSampleRowRect(layout, i); + if (row.top >= layout.sampleList.bottom) break; const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_; LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(), sel ? kColBtnHitBg : kColBtnBg, 1.0f, 0); @@ -195,6 +238,44 @@ void ReaSamplerEditor::paint(HDC hdc) { kRgbText); } + // RIGHT column: the zone panel. "Add Zone" button on top, then one row per zone. + LICE_FillRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top, + layout.addZoneButton.width(), layout.addZoneButton.height(), kColBtnBg, + 1.0f, 0); + LICE_DrawRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top, + layout.addZoneButton.width() - 1, layout.addZoneButton.height() - 1, + kColBtnBorder, 1.0f, 0); + drawTextCentered(&bmp, layout.addZoneButton, "+ Add Zone", kRgbText); + + // The seven per-row controls, laid out left-to-right pinned to the right edge. + const char* kCtrlGlyphs[7] = {"-", "+", "-", "+", "-", "+", "x"}; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const Rect row = zoneRowRect(layout, i); + if (row.top >= layout.zoneRowArea.bottom) break; // past the visible panel + const bool sel = (i == selectedZone_); + LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(), + sel ? kColZoneSelBg : kColBtnBg, 1.0f, 0); + + const PerformanceZone& z = map_.zones[i]; + std::string label = sampleLabel(samples_, z.sampleId); + label += " " + std::to_string(z.lowNote) + "-" + std::to_string(z.highNote); + label += " r" + (z.rootOverride ? std::to_string(*z.rootOverride) + "*" + : std::string("(bank)")); + // Label area stops where the control block begins (7 * ctrl width from the right). + const int ctrlBlockLeft = row.right - 7 * kZoneCtrlWidth; + Rect textR{row.left + 6, row.top, ctrlBlockLeft - 4, row.bottom}; + drawText(&bmp, textR, label.c_str(), kRgbText); + + // Draw the 7 mini-buttons. + for (int s = 0; s < 7; ++s) { + const int bx = ctrlBlockLeft + s * kZoneCtrlWidth; + Rect cell{bx, row.top + 2, bx + kZoneCtrlWidth - 1, row.bottom - 2}; + LICE_FillRect(&bmp, cell.left, cell.top, cell.width(), cell.height(), + kColCtrlBg, 1.0f, 0); + drawTextCentered(&bmp, cell, kCtrlGlyphs[s], kRgbText); + } + } + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } @@ -202,16 +283,77 @@ void ReaSamplerEditor::onClick(int x, int y) { if (!processor_) return; RECT cr{}; GetClientRect(childHwnd_, &cr); - const EditorLayout layout = layoutEditor(cr.right - cr.left, cr.bottom - cr.top); - const int row = sampleRowHitTest(layout, static_cast(samples_.size()), x, y); - if (row < 0) return; + const KeymapEditorLayout layout = + layoutKeymapEditor(cr.right - cr.left, cr.bottom - cr.top); - // 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); + // 1. Left list: pick the Tier-0 fallback / the sample a new zone will use. + const int row = + keymapSampleRowHitTest(layout, static_cast(samples_.size()), x, y); + if (row >= 0) { + processor_->setSelectedSampleId(samples_[row].id); + selectedId_ = samples_[row].id; + // A fallback change only affects playback when the map is empty; reload so the + // Tier-0 case updates immediately. + processor_->reloadFromBank(); + InvalidateRect(childHwnd_, nullptr, FALSE); + return; + } + + // 2. "Add Zone": append a full-keyboard zone for the currently-selected sample (root + // from the bank intrinsic — no override until the user nudges it). + if (addZoneHitTest(layout, x, y)) { + if (selectedId_.empty()) return; // nothing selected to add + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + map_.zones.push_back(z); + selectedZone_ = static_cast(map_.zones.size()) - 1; + commitMapAndReload(); + return; + } + + // 3. Zone rows: select a zone, nudge its range/root, or delete it. + const ZoneHit hit = + zoneHitTest(layout, static_cast(map_.zones.size()), x, y); + if (hit.zoneIndex < 0) return; + selectedZone_ = hit.zoneIndex; + + if (hit.field == ZoneField::kZoneNone) { + InvalidateRect(childHwnd_, nullptr, FALSE); // select only, no reload + return; + } + if (hit.field == ZoneField::kDelete) { + map_.zones.erase(map_.zones.begin() + hit.zoneIndex); + selectedZone_ = -1; + commitMapAndReload(); + return; + } + + PerformanceZone& z = map_.zones[hit.zoneIndex]; + switch (hit.field) { + case ZoneField::kLowDown: z.lowNote = clampNote(z.lowNote - 1); break; + case ZoneField::kLowUp: z.lowNote = clampNote(z.lowNote + 1); break; + case ZoneField::kHighDown: z.highNote = clampNote(z.highNote - 1); break; + case ZoneField::kHighUp: z.highNote = clampNote(z.highNote + 1); break; + case ZoneField::kRootDown: { + const int base = z.rootOverride ? *z.rootOverride : 60; + z.rootOverride = clampNote(base - 1); // first nudge establishes the override + break; + } + case ZoneField::kRootUp: { + const int base = z.rootOverride ? *z.rootOverride : 60; + z.rootOverride = clampNote(base + 1); + break; + } + default: break; // kZoneNone/kDelete handled above + } + // Keep low <= high after a range nudge (clamp the moved edge against its partner). + if (z.lowNote > z.highNote) { + if (hit.field == ZoneField::kLowUp) z.lowNote = z.highNote; + else if (hit.field == ZoneField::kHighDown) z.highNote = z.lowNote; + } + commitMapAndReload(); } LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index c89d7d8..3012a75 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -54,7 +54,8 @@ private: #ifdef _WIN32 // Draw the current surface into the child window's DC via a LICE bitmap. void paint(HDC hdc); - // Route a client-space click: select the sample row under (x, y), if any. + // Route a client-space click: bank-sample pick (left list), zone edit (right panel), + // or the "Add Zone" button. void onClick(int x, int y); static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); @@ -62,16 +63,28 @@ private: HWND childHwnd_ = nullptr; #endif - // Re-read the bank's sample list from the live bridge into `samples_`. Main/UI - // thread only (reads ext-state); called on attach and after a selection reload. + // Re-read the bank's sample list from the live bridge into `samples_`, and snapshot the + // instrument's performance map. Main/UI thread only (reads ext-state); called on attach + // and after any edit that reloads the instrument. void refreshSampleList(); + // Push the edited performance map to the processor and rebuild the instrument OFF the + // audio thread. UI thread only. Centralizes the "commit an edit" path so every zone + // mutation reloads identically. + void commitMapAndReload(); + ReaSamplerProcessor* processor_ = nullptr; // The bank's samples, snapshotted for the current paint. Refreshed off the audio // thread; the paint just draws it. std::vector samples_; - // The currently-selected sample id, mirrored for the paint's highlight. + // The currently-selected sample id (Tier-0 fallback + the "sample to add" for a new + // zone), mirrored for the paint's highlight. std::string selectedId_; + // The instrument's performance map, snapshotted for edit + paint. Edits mutate this + // copy then commit it to the processor via commitMapAndReload. + PerformanceMap map_; + // The zone row currently highlighted (for the paint); -1 = none. + int selectedZone_ = -1; }; } // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index ed3c337..682ec63 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include "pluginterfaces/base/ibstream.h" @@ -15,7 +17,7 @@ #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "ext_keys.h" // kProjExtBanksKey (shared wire contract) #include "reasampler_editor.h" -#include "sample_map.h" // selectSample, downmixToMono, buildTier0Keymap, state (de)ser +#include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser #include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) using namespace Steinberg; @@ -59,6 +61,28 @@ std::vector readFileBytes(const std::string& path) { return bytes; } +// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file +// I/O — off-thread only), and downmix to the core's mono contract. Returns nullopt when +// the path fails to resolve, the file is unreadable, the WAV is malformed, or the decode +// yields no frames — the caller drops the zone (Tier 1) or plays silence (Tier 0). Shared +// by the zoned build and the Tier-0 fallback so both decode identically. +std::optional decodeRelative(const std::string& projectDir, + const std::string& relativePath) { + const std::string abs = resolveBankFile(projectDir, relativePath); + if (abs.empty()) return std::nullopt; + const std::vector bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (!layout.valid) return std::nullopt; + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + std::vector mono = downmixToMono(interleaved, layout.channelCount); + if (mono.empty()) return std::nullopt; + DecodedZonePcm out; + out.monoFrames = std::move(mono); + out.sampleRate = static_cast(layout.sampleRate); + return out; +} + } // namespace FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { @@ -115,23 +139,33 @@ tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& 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. + // Read the whole component-state blob (the performance map, versioned). The blob is + // small; read in one shot into a growable buffer. std::vector bytes; std::uint8_t chunk[256]; int32 got = 0; while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { bytes.insert(bytes.end(), chunk, chunk + got); } - setSelectedSampleId(deserializeSelection(bytes)); - // Rebuild from the restored selection (off-thread — setState is a load-time call). + // Component state IS the performance map (Tier 1, D-B). deserializePerformance lifts a + // v1 (S4 single-selection) blob to a one-zone map, so already-saved Tier-0 instances + // restore cleanly. When the restored map is empty, the instrument falls back to the + // Tier-0 first-sample in reloadFromBank; if the v1 lift produced a single zone we also + // seed the fallback selection from it so the editor reflects the restored pick. + const PerformanceMap map = deserializePerformance(bytes); + setPerformanceMap(map); + if (map.zones.size() == 1) setSelectedSampleId(map.zones.front().sampleId); + // Rebuild from the restored state (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; - const std::vector bytes = serializeSelection(selectedSampleId()); + // Persist the performance map (the instrument's own Tier-1 state, D-B — NEVER written + // to the "reasampler" bank ext-state). An empty map serializes to just the version + + // zero-count header (restores as empty -> Tier-0 fallback). + const std::vector bytes = serializePerformance(performanceMap()); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), static_cast(bytes.size()), nullptr); @@ -150,6 +184,16 @@ void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { selectedSampleId_ = id; } +PerformanceMap ReaSamplerProcessor::performanceMap() { + std::lock_guard lock(performanceMutex_); + return performanceMap_; +} + +void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { + std::lock_guard lock(performanceMutex_); + performanceMap_ = map; +} + 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 @@ -170,36 +214,58 @@ std::string ReaSamplerProcessor::reloadFromBank() { std::unique_ptr built; if (banksJson) { - // 2. Pick the sample (shared bank_book JSON parse — NOT a second parser). - std::optional sel = - selectSample(*banksJson, selectedSampleId()); - if (sel) { - // 3. Resolve the project-relative WAV path the M4 way persist does, read + - // decode it (file I/O off-thread), downmix to the core's mono contract. - const std::string abs = resolveBankFile(projectDir, sel->relativePath); - if (!abs.empty()) { - const std::vector bytes = readFileBytes(abs); - const WavLayout layout = parseWavLayout(bytes); - if (layout.valid) { - const std::size_t frames = layout.frameCount(); - std::vector interleaved = - extractFloatFrames(bytes, layout, 0, frames); - std::vector mono = - downmixToMono(interleaved, layout.channelCount); - if (!mono.empty()) { - Keymap km = buildTier0Keymap( - std::move(mono), - static_cast(layout.sampleRate), sel->rootNote, - sel->loop); - built = std::make_unique( - std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen); - // Record which id actually resolved so a first-sample fallback - // (empty stored id) becomes the concrete selection. - resolvedId = selectedSampleId(); - } + // 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its + // zones against the live bank (STALE ids drop cleanly), decode each zone's WAV + // off-thread, and build the ZONED keymap. Each surviving zone plays its bank + // sample repitched from its effective root note (override > bank intrinsic > C4). + // A zone whose WAV fails to decode is dropped (not the whole map). + const PerformanceMap map = performanceMap(); + Keymap km; + bool haveKeymap = false; + + if (!map.empty()) { + const ResolvedPerformance resolved = resolvePerformance(*banksJson, map); + if (!resolved.zones.empty()) { + std::vector decoded; + std::vector kept; + decoded.reserve(resolved.zones.size()); + kept.reserve(resolved.zones.size()); + for (const ResolvedZone& rz : resolved.zones) { + std::optional pcm = + decodeRelative(projectDir, rz.relativePath); + if (!pcm) continue; // unreadable WAV -> drop this zone + kept.push_back(rz); + decoded.push_back(std::move(*pcm)); + } + km = buildZonedKeymap(kept, decoded); + haveKeymap = !km.zones.empty(); + } + } + + // 3. Tier-0 fallback: an empty (or fully-unresolvable) performance map plays the + // selected fallback sample chromatically across the whole keyboard — preserving + // the S4 "the bank plays" behavior for an un-authored instrument. + if (!haveKeymap) { + std::optional sel = + selectSample(*banksJson, selectedSampleId()); + if (sel) { + std::optional pcm = + decodeRelative(projectDir, sel->relativePath); + if (pcm) { + km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, + sel->rootNote, sel->loop); + haveKeymap = true; + // Record which id actually resolved so a first-sample fallback + // (empty stored id) becomes the concrete selection. + resolvedId = selectedSampleId(); } } } + + if (haveKeymap) { + built = std::make_unique( + std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen); + } } // 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index c0bffbc..dd4efce 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -28,6 +28,7 @@ #include "public.sdk/source/vst/vstsinglecomponenteffect.h" #include "reaper_bridge.h" +#include "sample_map.h" // PerformanceMap (the instrument's owned zoned keymap) #include "sampler_core.h" namespace reasampler::vst { @@ -97,10 +98,18 @@ public: // 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. + // selectionMutex_ — never touched on the audio thread. In Tier 1 the selection is the + // Tier-0 FALLBACK sample (played chromatically when the performance map is empty); the + // zoned map, when non-empty, supersedes it. std::string selectedSampleId(); void setSelectedSampleId(const std::string& id); + // The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written + // by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the + // audio thread — reloadFromBank bakes it into the LoadedInstrument's Keymap off-thread. + PerformanceMap performanceMap(); + void setPerformanceMap(const PerformanceMap& map); + private: ReaperBridge bridge_; @@ -139,11 +148,17 @@ private: std::vector graveyard_; // drained on reclaim + setActive(false) + terminate std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access - // The selected sample id (instance state). Off-thread only; a small mutex guards the - // string against a getState/editor race. NOT read on the audio thread. + // The selected sample id (Tier-0 fallback sample). 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_; + // The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only; + // guarded against a getState/editor race. NOT read on the audio thread — reloadFromBank + // bakes it into the LoadedInstrument's Keymap under the reload lock. + std::mutex performanceMutex_; + PerformanceMap performanceMap_; + // Latched from setupProcessing so setActive/reload can size against it. Read // off-thread only. double sampleRate_ = 44100.0; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index a87a13f..6056268 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -3,7 +3,9 @@ #include "sample_map.h" -#include // std::memcpy +#include // std::min +#include // std::memcpy +#include // std::move namespace reasampler { @@ -103,6 +105,168 @@ Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, return Keymap::singleSampleChromatic(std::move(data)); } +// --- Performance map --------------------------------------------------------- + +ResolvedPerformance resolvePerformance(const std::string& banksJson, + const PerformanceMap& map) { + ResolvedPerformance out; + if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0) + if (banksJson.empty()) return out; // no bank -> nothing resolves + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; // malformed -> nothing (never throw) + + for (const PerformanceZone& z : map.zones) { + // Look the id up across every bank (pool + named) — a sample lives in exactly + // one bank, so first hit wins. + const Sample* found = nullptr; + for (const Bank& b : book->banks()) { + if (const Sample* s = b.index.query(z.sampleId)) { + found = s; + break; + } + } + if (!found) { + // STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune). + out.droppedSampleIds.push_back(z.sampleId); + continue; + } + ResolvedZone rz; + rz.relativePath = found->relativePath; + rz.lowNote = z.lowNote; + rz.highNote = z.highNote; + // Effective root: override beats bank intrinsic beats middle-C default. + rz.rootNote = z.rootOverride ? *z.rootOverride + : (found->rootNote ? *found->rootNote : 60); + rz.loop = loopFromSample(*found); + out.zones.push_back(std::move(rz)); + } + return out; +} + +Keymap buildZonedKeymap(const std::vector& zones, + const std::vector& decoded) { + Keymap km; + const std::size_t n = std::min(zones.size(), decoded.size()); + for (std::size_t i = 0; i < n; ++i) { + // An unreadable/empty WAV drops just this zone (not the whole map). + if (decoded[i].monoFrames.empty()) continue; + SampleData data; + data.frames = decoded[i].monoFrames; + data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100; + data.rootNote = zones[i].rootNote; + data.loop = zones[i].loop; + const std::size_t sampleIndex = km.samples.size(); + km.samples.push_back(std::move(data)); + KeyZone zone; + zone.lowNote = zones[i].lowNote; + zone.highNote = zones[i].highNote; + zone.rootNote = zones[i].rootNote; + zone.sampleIndex = sampleIndex; + km.zones.push_back(zone); + } + return km; // empty zones in -> empty Keymap (silence) +} + +// --- Performance-map instance state (setState/getState) ----------------------- + +namespace { + +void putU32le(std::vector& out, std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); +} + +// A bounded little-endian reader over a byte blob. Every read is length-checked; once a +// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated +// blob degrades to a partial/empty parse rather than reading out of bounds. +struct ByteReader { + const std::vector& bytes; + std::size_t pos = 0; + bool ok = true; + + explicit ByteReader(const std::vector& b) : bytes(b) {} + + std::uint32_t u32() { + if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; } + const std::uint32_t v = static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + pos += 4; + return v; + } + std::uint8_t u8() { + if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; } + return bytes[pos++]; + } + std::string str(std::uint32_t len) { + if (!ok || pos + len > bytes.size()) { ok = false; return {}; } + std::string s(reinterpret_cast(bytes.data() + pos), len); + pos += len; + return s; + } + // Signed ints go on the wire as u32 two's-complement (fixed 32-bit width). + int i32() { return static_cast(static_cast(u32())); } +}; + +} // namespace + +std::vector serializePerformance(const PerformanceMap& map) { + std::vector out; + putU32le(out, kPerformanceStateVersion); + putU32le(out, static_cast(map.zones.size())); + for (const PerformanceZone& z : map.zones) { + putU32le(out, static_cast(z.sampleId.size())); + out.insert(out.end(), z.sampleId.begin(), z.sampleId.end()); + putU32le(out, static_cast(static_cast(z.lowNote))); + putU32le(out, static_cast(static_cast(z.highNote))); + out.push_back(z.rootOverride ? 1 : 0); + if (z.rootOverride) { + putU32le(out, + static_cast(static_cast(*z.rootOverride))); + } + } + return out; +} + +PerformanceMap deserializePerformance(const std::vector& bytes) { + PerformanceMap map; + ByteReader r(bytes); + const std::uint32_t version = r.u32(); + if (!r.ok) return map; // no version tag -> empty + + // BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes, + // no length prefix). Lift it to one full-keyboard zone playing that id. + if (version == kSelectionStateVersion) { + const std::string id = deserializeSelection(bytes); + if (!id.empty()) { + PerformanceZone z; + z.sampleId = id; + z.lowNote = 0; + z.highNote = 127; + map.zones.push_back(std::move(z)); + } + return map; + } + if (version != kPerformanceStateVersion) return map; // unknown -> empty + + const std::uint32_t count = r.u32(); + for (std::uint32_t i = 0; i < count && r.ok; ++i) { + PerformanceZone z; + const std::uint32_t idLen = r.u32(); + z.sampleId = r.str(idLen); + z.lowNote = r.i32(); + z.highNote = r.i32(); + const std::uint8_t hasOverride = r.u8(); + if (hasOverride) z.rootOverride = r.i32(); + if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest + map.zones.push_back(std::move(z)); + } + return map; +} + std::vector serializeSelection(const std::string& sampleId) { std::vector out; out.resize(4 + sampleId.size()); diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 6d3177a..6cc794c 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -85,6 +85,107 @@ std::vector downmixToMono(const std::vector& interleav Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, int rootNote, const SampleLoop& loop); +// --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- +// +// The performance map is the keymap the user authors IN the instrument: several bank +// samples zoned across the keyboard, each with a key range and a root note. It is a +// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never +// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but +// OVERRIDABLE here — the override lives on the zone, never on `Sample`. +// +// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM. +// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build +// stitches the decoded frames + this map into a sampler_core Keymap. + +// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range, +// with an optional root-note override. rootOverride absent -> repitch from the bank +// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty). +struct PerformanceZone { + std::string sampleId; // bank sample id this zone plays + int lowNote = 0; // inclusive + int highNote = 127; // inclusive + std::optional rootOverride; // instrument-owned override; absent -> bank intrinsic +}; + +// The instrument's performance map: an ordered list of zones. Order is authoritative for +// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's +// first-match Keymap::resolve — overlaps are neither rejected nor clamped, the earlier +// zone simply takes the contested keys; documented, deterministic). +struct PerformanceMap { + std::vector zones; + + bool empty() const { return zones.empty(); } +}; + +// One resolved zone ready for the shell to decode + the pure build to stitch: the bank +// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats +// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct +// from PerformanceZone (which names an id) — this is the id resolved against the live bank. +struct ResolvedZone { + std::string relativePath; // project-relative; the shell resolves + decodes it + int lowNote = 0; + int highNote = 127; + int rootNote = 60; // effective: override, else bank intrinsic, else 60 + SampleLoop loop; // bank intrinsic +}; + +// The result of resolving a performance map against the live bank blob. `zones` are the +// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is +// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a +// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence +// for the whole map — and its id is reported here so the editor can flag/prune it). +struct ResolvedPerformance { + std::vector zones; + std::vector droppedSampleIds; +}; + +// Resolve a performance map against the live "banks" ext-state blob. Pure: shared +// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank +// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride, +// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends +// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result (the shell +// then falls back to Tier-0 — see reloadFromBank). +ResolvedPerformance resolvePerformance(const std::string& banksJson, + const PerformanceMap& map); + +// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the +// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One +// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is +// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order +// is preserved so first-match overlap resolution matches the map's authored order. A zone +// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the +// map). Empty zones in -> empty Keymap (silence). +struct DecodedZonePcm { + std::vector monoFrames; + int sampleRate = 44100; +}; +Keymap buildZonedKeymap(const std::vector& zones, + const std::vector& decoded); + +// --- Performance-map instance state (VST3 setState/getState) ----------------- +// +// The performance map is the instrument's OWN state (D-B), serialized to the VST3 +// component-state IBStream — NOT written to the "reasampler" bank ext-state (the +// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of +// truncation/wrong-version by design (bounded reads, never throws across the host). +// +// Format (v2): 4-byte LE version tag (== 2), then a 4-byte LE zone count, then per zone: +// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, +// 1 byte hasOverride (0/1), 4-byte LE rootOverride (present only when hasOverride==1). +// BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is +// lifted to a single full-keyboard zone playing that id (no override) — so an instance +// saved under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob +// deserializes to an EMPTY map (the instrument falls back to Tier-0 first-sample). + +inline constexpr std::uint32_t kPerformanceStateVersion = 2; + +// The performance map serialized to bytes for IBStream (getState). +std::vector serializePerformance(const PerformanceMap& map); + +// The performance map parsed back from IBStream bytes (setState). A v2 blob parses +// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map. +PerformanceMap deserializePerformance(const std::vector& bytes); + // --- Instance state (VST3 setState/getState) -------------------------------- // // The instrument's OWN state is which bank sample it plays (D-B: the selection is a diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index d808584..2d363bf 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -198,6 +198,118 @@ static void testSampleRowHitTestMatchesDrawnRows() { } } +// --- keymap editor (S5 Tier-1 UI) -------------------------------------------- + +static void testKeymapLayoutSplitsCanvas() { + const KeymapEditorLayout L = layoutKeymapEditor(600, 300); + // The left sample list and right zone panel partition the canvas with no overlap and + // no gap: the list's right edge is the panel's left edge. + CHECK(L.sampleList.left == L.base.canvas.left); + CHECK(L.sampleList.right == L.zonePanel.left); + CHECK(L.zonePanel.right == L.base.canvas.right); + CHECK(L.sampleList.top == L.base.canvas.top); + CHECK(L.zonePanel.top == L.base.canvas.top); + CHECK(L.sampleList.bottom == L.base.canvas.bottom); + CHECK(L.zonePanel.bottom == L.base.canvas.bottom); + CHECK(L.sampleList.width() > 0 && L.zonePanel.width() > 0); + // Add-Zone button caps the panel; zone rows stack below it. + CHECK(L.addZoneButton.top == L.zonePanel.top); + CHECK(L.addZoneButton.left == L.zonePanel.left && L.addZoneButton.right == L.zonePanel.right); + CHECK(L.zoneRowArea.top == L.addZoneButton.bottom); + CHECK(L.zoneRowArea.bottom == L.zonePanel.bottom); +} + +static void checkNoInversion(const KeymapEditorLayout& L) { + CHECK(L.sampleList.right >= L.sampleList.left); + CHECK(L.zonePanel.right >= L.zonePanel.left); + CHECK(L.addZoneButton.right >= L.addZoneButton.left); + CHECK(L.addZoneButton.bottom >= L.addZoneButton.top); + CHECK(L.zoneRowArea.right >= L.zoneRowArea.left); + CHECK(L.zoneRowArea.bottom >= L.zoneRowArea.top); + // Regions stay within the client area. + CHECK(L.zonePanel.right <= L.base.canvas.right); +} + +static void testKeymapLayoutTinyAndZeroNoInversion() { + checkNoInversion(layoutKeymapEditor(30, 30)); + checkNoInversion(layoutKeymapEditor(0, 0)); + // A click anywhere on a zero layout hits no zone and no Add button. + const KeymapEditorLayout Z = layoutKeymapEditor(0, 0); + CHECK(zoneHitTest(Z, 3, 0, 0).zoneIndex == -1); + CHECK(!addZoneHitTest(Z, 0, 0)); +} + +static void testKeymapSampleRowInLeftColumn() { + const KeymapEditorLayout L = layoutKeymapEditor(600, 300); + const Rect r0 = keymapSampleRowRect(L, 0); + // Rows live in the LEFT column (not the full canvas width). + CHECK(r0.left == L.sampleList.left && r0.right == L.sampleList.right); + CHECK(r0.right < L.base.canvas.right); // strictly left of the zone panel + CHECK(r0.top == L.sampleList.top && r0.height() == kSampleRowHeight); + // Hit-test maps a left-column click to the row and rejects a click in the zone panel. + const int midY = (r0.top + r0.bottom) / 2; + CHECK(keymapSampleRowHitTest(L, 3, r0.left + 2, midY) == 0); + CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.left + 2, midY) == -1); +} + +static void testAddZoneHitTest() { + const KeymapEditorLayout L = layoutKeymapEditor(600, 300); + const int cx = (L.addZoneButton.left + L.addZoneButton.right) / 2; + const int cy = (L.addZoneButton.top + L.addZoneButton.bottom) / 2; + CHECK(addZoneHitTest(L, cx, cy)); + // A click in the zone-row area below the button is NOT the Add button. + CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.top + 2)); + // A click in the left list is NOT the Add button. + CHECK(!addZoneHitTest(L, L.sampleList.left + 2, L.sampleList.top + 2)); +} + +static void testZoneRowStacksAndSelects() { + const KeymapEditorLayout L = layoutKeymapEditor(600, 300); + const Rect z0 = zoneRowRect(L, 0); + const Rect z1 = zoneRowRect(L, 1); + CHECK(z0.top == L.zoneRowArea.top && z0.height() == kZoneRowHeight); + CHECK(z1.top == z0.bottom); // stacked, no gap + CHECK(z0.left == L.zoneRowArea.left && z0.right == L.zoneRowArea.right); + // A click on the LABEL area (left part of a zone row) selects the zone with no field. + const int labelX = z0.left + 2; // far left = label, not a control + const int midY = (z0.top + z0.bottom) / 2; + const ZoneHit h = zoneHitTest(L, 2, labelX, midY); + CHECK(h.zoneIndex == 0 && h.field == ZoneField::kZoneNone); +} + +static void testZoneRowControlsMapToFields() { + const KeymapEditorLayout L = layoutKeymapEditor(600, 300); + const Rect row = zoneRowRect(L, 0); + const int midY = (row.top + row.bottom) / 2; + // The seven controls occupy the rightmost 7*kZoneCtrlWidth px, left-to-right: + // low-, low+, high-, high+, root-, root+, delete. + const int block = row.right - 7 * kZoneCtrlWidth; + const ZoneField expected[7] = { + ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, + ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, + ZoneField::kDelete, + }; + for (int s = 0; s < 7; ++s) { + const int x = block + s * kZoneCtrlWidth + kZoneCtrlWidth / 2; // center of slot s + const ZoneHit h = zoneHitTest(L, 1, x, midY); + CHECK(h.zoneIndex == 0); + CHECK(h.zoneIndex == 0 && h.field == expected[s]); + } +} + +static void testZoneHitTestMisses() { + const KeymapEditorLayout L = layoutKeymapEditor(600, 300); + const Rect row = zoneRowRect(L, 0); + const int midY = (row.top + row.bottom) / 2; + // Zero zones -> always miss. + CHECK(zoneHitTest(L, 0, row.left + 2, midY).zoneIndex == -1); + // Below the last zone row -> miss. + const Rect last = zoneRowRect(L, 2); + CHECK(zoneHitTest(L, 3, row.left + 2, last.bottom + 1).zoneIndex == -1); + // Left of the zone panel (in the sample list) -> miss. + CHECK(zoneHitTest(L, 3, L.sampleList.left + 2, midY).zoneIndex == -1); +} + int main() { testContainsHalfOpen(); testContainsDegenerate(); @@ -212,6 +324,13 @@ int main() { testSampleRowHitTestMapsClickToRow(); testSampleRowHitTestMisses(); testSampleRowHitTestMatchesDrawnRows(); + testKeymapLayoutSplitsCanvas(); + testKeymapLayoutTinyAndZeroNoInversion(); + testKeymapSampleRowInLeftColumn(); + testAddZoneHitTest(); + testZoneRowStacksAndSelects(); + testZoneRowControlsMapToFields(); + testZoneHitTestMisses(); if (g_fail == 0) std::printf("editor_geometry: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 70e2bc5..f35945e 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -350,6 +350,214 @@ static void testWavTrimToDownmixPipelineMono() { CHECK(approx(mono[0], 0.0) && approx(mono[1], 0.5) && approx(mono[2], 1.0)); } +// --- performance map: resolvePerformance -------------------------------------- + +static PerformanceZone zone(const std::string& id, int lo, int hi, + std::optional rootOverride = std::nullopt) { + PerformanceZone z; + z.sampleId = id; + z.lowNote = lo; + z.highNote = hi; + z.rootOverride = rootOverride; + return z; +} + +static void testResolveEmptyMap() { + // An empty performance map resolves to nothing (the shell falls back to Tier 0). + const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); + const ResolvedPerformance r = resolvePerformance(json, PerformanceMap{}); + CHECK(r.zones.empty()); + CHECK(r.droppedSampleIds.empty()); +} + +static void testResolveEmptyBlob() { + PerformanceMap m; + m.zones.push_back(zone("a", 0, 127)); + CHECK(resolvePerformance("", m).zones.empty()); // no bank + CHECK(resolvePerformance("{garbage", m).zones.empty()); // malformed +} + +static void testResolveMultiZoneAcrossBanks() { + const std::string json = bookJson( + {makeSample("a", "Kick", "b/a.wav", 36)}, + {makeSample("b", "Snare", "b/b.wav", 38)}); + PerformanceMap m; + m.zones.push_back(zone("a", 36, 47)); + m.zones.push_back(zone("b", 48, 59)); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 2); + CHECK(r.droppedSampleIds.empty()); + // Order preserved; paths + ranges threaded. + CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav"); + CHECK(r.zones.size() == 2 && r.zones[0].lowNote == 36 && r.zones[0].highNote == 47); + CHECK(r.zones.size() == 2 && r.zones[1].relativePath == "b/b.wav"); + CHECK(r.zones.size() == 2 && r.zones[1].lowNote == 48 && r.zones[1].highNote == 59); +} + +static void testResolveStaleIdDropsZone() { + // STALE-ID POLICY: a zone naming a deleted sample is dropped, its id reported; the + // surviving zone still resolves (the whole map is NOT abandoned). + const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); + PerformanceMap m; + m.zones.push_back(zone("a", 0, 59)); + m.zones.push_back(zone("ghost", 60, 127)); // no such sample + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1); + CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav"); + CHECK(r.droppedSampleIds.size() == 1); + CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost"); +} + +static void testResolveRootPrecedence() { + // Override beats bank intrinsic beats middle-C default. + const std::string json = bookJson( + {makeSample("rooted", "R", "b/r.wav", 40), // bank intrinsic 40 + makeSample("unrooted", "U", "b/u.wav", std::nullopt)}, // no intrinsic + {}); + PerformanceMap m; + m.zones.push_back(zone("rooted", 0, 42)); // no override -> 40 + m.zones.push_back(zone("rooted", 43, 84, /*override=*/72)); // override -> 72 + m.zones.push_back(zone("unrooted", 85, 127)); // no intrinsic -> 60 + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 3); + CHECK(r.zones.size() == 3 && r.zones[0].rootNote == 40); // bank intrinsic + CHECK(r.zones.size() == 3 && r.zones[1].rootNote == 72); // override wins + CHECK(r.zones.size() == 3 && r.zones[2].rootNote == 60); // middle-C default +} + +static void testResolveLoopThreaded() { + Sample s = makeSample("a", "Pad", "b/a.wav", 60); + s.loop = LoopPoints{200, 800}; + const std::string json = bookJson({s}, {}); + PerformanceMap m; + m.zones.push_back(zone("a", 0, 127)); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1); + CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); + CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800); +} + +// --- performance map: buildZonedKeymap ---------------------------------------- + +static void testBuildZonedKeymapMultiZone() { + std::vector zones; + ResolvedZone z0; z0.lowNote = 36; z0.highNote = 47; z0.rootNote = 36; zones.push_back(z0); + ResolvedZone z1; z1.lowNote = 48; z1.highNote = 59; z1.rootNote = 48; zones.push_back(z1); + std::vector decoded; + decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100}); + decoded.push_back(DecodedZonePcm{{0.3f, 0.4f, 0.5f}, 48000}); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 2); + CHECK(km.zones.size() == 2); + // Zone 0 -> sample 0, rooted 36, range 36..47; zone 1 -> sample 1, rooted 48. + CHECK(km.zones.size() == 2 && km.zones[0].sampleIndex == 0 && km.zones[0].rootNote == 36); + CHECK(km.zones.size() == 2 && km.zones[0].lowNote == 36 && km.zones[0].highNote == 47); + CHECK(km.zones.size() == 2 && km.zones[1].sampleIndex == 1 && km.zones[1].rootNote == 48); + CHECK(km.samples.size() == 2 && km.samples[1].sampleRate == 48000); + CHECK(km.samples.size() == 2 && km.samples[1].frames.size() == 3); + // Resolution: a note in each range lands in the right zone. + CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0); + CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1); + // A note outside every zone does not match (no-play, not zone 0). + CHECK(!km.resolve(24, 100).matched); +} + +static void testBuildZonedKeymapDropsEmptyPcm() { + // A zone whose decoded WAV is empty is dropped; the other zone survives, and the + // survivor's sampleIndex points at ITS sample (not the dropped one's slot). + std::vector zones; + ResolvedZone z0; z0.lowNote = 0; z0.highNote = 63; z0.rootNote = 60; zones.push_back(z0); + ResolvedZone z1; z1.lowNote = 64; z1.highNote = 127; z1.rootNote = 72; zones.push_back(z1); + std::vector decoded; + decoded.push_back(DecodedZonePcm{{}, 44100}); // empty -> dropped + decoded.push_back(DecodedZonePcm{{0.9f}, 44100}); // survives + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 1); + CHECK(km.zones.size() == 1); + CHECK(km.zones.size() == 1 && km.zones[0].sampleIndex == 0); // remapped to slot 0 + CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 64 && km.zones[0].rootNote == 72); +} + +static void testBuildZonedKeymapOverlapFirstWins() { + // OVERLAP POLICY: two zones share keys; the FIRST in order wins the contested note + // (mirrors the S3 core's first-match resolve). + std::vector zones; + ResolvedZone z0; z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; zones.push_back(z0); + ResolvedZone z1; z1.lowNote = 60; z1.highNote = 72; z1.rootNote = 48; zones.push_back(z1); + std::vector decoded; + decoded.push_back(DecodedZonePcm{{0.1f}, 44100}); + decoded.push_back(DecodedZonePcm{{0.2f}, 44100}); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.zones.size() == 2); + // Note 64 is in both zones; first-match resolves to zone 0. + CHECK(km.resolve(64, 100).matched && km.resolve(64, 100).zoneIndex == 0); +} + +static void testBuildZonedKeymapEmpty() { + // No zones -> empty keymap (silence). + const Keymap km = buildZonedKeymap({}, {}); + CHECK(km.samples.empty() && km.zones.empty()); + CHECK(!km.resolve(60, 100).matched); +} + +// --- performance-map state: serialize / deserialize --------------------------- + +static void testPerformanceStateRoundTrip() { + PerformanceMap m; + m.zones.push_back(zone("kick", 36, 47)); // no override + m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override + const std::vector bytes = serializePerformance(m); + const PerformanceMap back = deserializePerformance(bytes); + CHECK(back.zones.size() == 2); + CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick"); + CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47); + CHECK(back.zones.size() == 2 && !back.zones[0].rootOverride.has_value()); + CHECK(back.zones.size() == 2 && back.zones[1].sampleId == "snare"); + CHECK(back.zones.size() == 2 && back.zones[1].rootOverride.has_value() && + *back.zones[1].rootOverride == 50); +} + +static void testPerformanceStateEmpty() { + const std::vector bytes = serializePerformance(PerformanceMap{}); + // Just the version + zero-count header. + CHECK(bytes.size() == 8); + CHECK(deserializePerformance(bytes).zones.empty()); +} + +static void testPerformanceStateV1BackCompat() { + // A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone. + const std::vector v1 = serializeSelection("legacy-sample-id"); + const PerformanceMap back = deserializePerformance(v1); + CHECK(back.zones.size() == 1); + CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id"); + CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127); + CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value()); + // A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection"). + CHECK(deserializePerformance(serializeSelection("")).zones.empty()); +} + +static void testPerformanceStateGarbage() { + // Unknown version / truncated / empty -> empty map (never throws). + CHECK(deserializePerformance({}).zones.empty()); + CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}).zones.empty()); // unknown version + // Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty. + std::vector t; + t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2 + t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1 + // (no zone payload) + CHECK(deserializePerformance(t).zones.empty()); +} + +static void testPerformanceStateNegativeNotesRoundTrip() { + // Notes are clamped in the UI, but the wire format must survive the full int range so + // a hand-set/legacy value round-trips without corruption (two's-complement on the wire). + PerformanceMap m; + m.zones.push_back(zone("s", 0, 127, /*override=*/0)); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() && + *back.zones[0].rootOverride == 0); +} + int main() { testSelectByIdHit(); testSelectFirstSampleFallbackOnEmptyId(); @@ -374,6 +582,21 @@ int main() { testSelectionStateTruncated(); testWavTrimToDownmixPipelineStereo(); testWavTrimToDownmixPipelineMono(); + testResolveEmptyMap(); + testResolveEmptyBlob(); + testResolveMultiZoneAcrossBanks(); + testResolveStaleIdDropsZone(); + testResolveRootPrecedence(); + testResolveLoopThreaded(); + testBuildZonedKeymapMultiZone(); + testBuildZonedKeymapDropsEmptyPcm(); + testBuildZonedKeymapOverlapFirstWins(); + testBuildZonedKeymapEmpty(); + testPerformanceStateRoundTrip(); + testPerformanceStateEmpty(); + testPerformanceStateV1BackCompat(); + testPerformanceStateGarbage(); + testPerformanceStateNegativeNotesRoundTrip(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; From a3142bbe3e8743b78bb0c4e36983ca58fbf4c8c3 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 17:35:23 -0400 Subject: [PATCH 10/49] fix(comments): correct ZoneField button count/order; generalize single-zone setState seed comment --- src/vst/editor_geometry.h | 8 ++++---- src/vst/reasampler_processor.cpp | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/vst/editor_geometry.h b/src/vst/editor_geometry.h index 0b57487..f9165d7 100644 --- a/src/vst/editor_geometry.h +++ b/src/vst/editor_geometry.h @@ -115,8 +115,8 @@ int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x Rect zoneRowRect(const KeymapEditorLayout& layout, int index); // A zone row's interactive fields. The row is a horizontal strip: a label on the left, -// then six fixed-width nudge/delete mini-buttons on the right (low-, low+, high-, high+, -// root-, root+) followed by a delete button. kZoneNone means the click missed a control +// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+, +// root-, root+, delete). kZoneNone means the click missed a control // (e.g. on the label) — the shell may still treat that as "select this zone". enum class ZoneField { kZoneNone, @@ -138,8 +138,8 @@ struct ZoneHit { }; // Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a -// click outside every zone row. Within a row, the six nudges + delete occupy fixed-width -// slots on the right edge (right-to-left: delete, root+, root-, high+, high-, low+, low-); +// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width +// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete); // a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure. ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y); diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 682ec63..9eb7a59 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -150,7 +150,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { // Component state IS the performance map (Tier 1, D-B). deserializePerformance lifts a // v1 (S4 single-selection) blob to a one-zone map, so already-saved Tier-0 instances // restore cleanly. When the restored map is empty, the instrument falls back to the - // Tier-0 first-sample in reloadFromBank; if the v1 lift produced a single zone we also + // Tier-0 first-sample in reloadFromBank; if any single-zone restored map is present we // seed the fallback selection from it so the editor reflects the restored pick. const PerformanceMap map = deserializePerformance(bytes); setPerformanceMap(map); From ed2df9c1d520e03ba13e2da082475e42490d67df Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 17:36:27 -0400 Subject: [PATCH 11/49] docs: spec Phase S stereo (S7), bank-ingest (S8), change-detection (S9) Settled 2026-07-26: per-instance mono|stereo mode with automatic bus negotiation (S3-core extension); ingest routes through the bank, extension- owned, instrument stays read-only; bank-generation counter for hands-free recapture refresh. ME + drop-target SDK limits verified; spikes flagged. --- CONTEXT.md | 265 +++++++++++++++++++++++++++++++++- PLAN.md | 250 ++++++++++++++++++++++++++++++++ docs/product/midi-playback.md | 66 ++++++++- 3 files changed, 576 insertions(+), 5 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 66d1a57..69dae11 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1289,6 +1289,109 @@ someday-note. It is polish, not a Tier-0 need, so it sequences last in the phase is on the roadmap. **Must-verify before build:** the `IReaperUIEmbedInterface` contract and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`. +## Channel mode — mono | stereo (D-E, decided 2026-07-26; PLAN.md S7) + +**Decided direction: the instrument gets a per-instance channel-mode toggle — 1 (mono) or +2 (stereo) — that negotiates the REAPER audio bus automatically.** Captures are often +stereo; the current mono downmix is a Tier-0 simplification, not a permanent shape. + +- **Mono mode keeps today's path.** The decode-side downmix stands: a stereo source in + mono mode downmixes (the existing policy), a mono source plays as-is. No engine change + for mono. +- **Stereo mode is an S3-core extension, not a shell hack (honest).** The S3 core is + **mono-per-sample by design today** — `SampleData::frames` is one mono stream, + `Voice::renderFrame` returns a single value, `VoiceEngine::render` writes one channel. + Stereo mode grows the core a **channel dimension**: 2-channel decoded PCM, per-voice + **stereo** render (per-channel fractional read + linear interpolation + loop), and a + per-channel mix in the engine. Mono stays the degenerate (single-channel) case, so + existing mono behavior is unchanged. This is why S7 sequences first after the editor/embed + work: it touches the engine Daniel smoke-tests. +- **Where the toggle lives.** Per-instance component state (setState/getState), alongside + the selected sample — a **performance choice the instrument owns**, never written to the + bank (D-B: not a file fact). Default preserves current behavior (mono). +- **Cross-mode policy (settled).** Mono source + stereo mode → **dual-mono** (same signal + both channels, centered). Stereo source + mono mode → **downmix** (the existing + decode-side policy). The bank's per-sample channel-count intrinsic (already on `Sample`) + tells the shell how many channels to decode into `SampleData`. +- **Bus negotiation (the "works with the REAPER bus automatically" requirement).** The VST3 + implements `setBusArrangements` so the output bus reports mono or stereo per the + instance's channel mode, and REAPER's routing follows without manual channel wiring. + **Must-verify before build:** the `setBusArrangements` / `getBusArrangement` contract and + REAPER's mono/stereo instrument-bus expectations against the vendored Steinberg SDK + + `reaper_vst3_interfaces.h`. + +## Ingest through the bank — the extension owns ingest (decided "option 1", 2026-07-26; PLAN.md S8) + +**Decided: loading a sample into the sampler is ONE gesture — capture/import-into-bank AND +auto-assign to the active sampler instance — and the *extension* owns it.** The instrument +stays a **read-only bank consumer**; it never captures and never imports. The extension is +the right owner: it has arrange access, Media-Explorer access, and the drop-target surface +on its own docked panels. Ingest lives in the *extension* codebase (actions + `bank_panel` + +capture/import add-path), routing through the existing capture add-path and the live +`"reasampler"` seam the instrument already reads. + +**The three ingest surfaces, with the honest SDK reality (verified against the vendored +headers):** + +- **Arrange capture → bank → assign.** A one-click action captures the selected item / + time-selection into the bank (reusing the existing capture request path — + `CountSelectedMediaItems` / `GetSelectedMediaItem` + `GetSet_LoopTimeRange` are already the + capture inputs) and assigns the resulting `Sample` id to the target instance. **It never + inserts a timeline item** — the capture/placement separation is load-bearing; assignment + is a bank-index + instance-selection act, not a placement. +- **Media Explorer import → bank → assign.** The Media-Explorer surface is **thin**: + `OpenMediaExplorer` (open/select a file) and `MediaExplorerGetLastPlayedFileInfo` (read the + *one* last-played/selected file path + its selection range/pitch/vol/rate) are the whole + contract. There is **no** enumerate-selected-files and **no** register-a-drop-handler-on- + the-Media-Explorer API. So ME import is **single-file, pull-on-action** — an action fired + while a file is selected in the ME — not a push/drop from inside the ME. **Spike:** confirm + `MediaExplorerGetLastPlayedFileInfo` returns a usable path+range for a merely-*selected* + (not-yet-played) file, or whether a play is required first. +- **Drag-and-drop onto ReaSampler surfaces.** REAPER exposes **no** drag-drop registration + API. Drop handling is on ReaSampler's *own* HWNDs via SWELL/Win32 (`WM_DROPFILES` / + `IDropTarget` on the docked `bank_panel` HWND — the surface the panel already owns) → ingest + → assign. **Assess-and-flag (spike, not promised):** a drop *onto the VST3 editor window* — + whether the `IPlugView` HWND can accept an OS file drop and **relay it to the extension as + a bank-ingest request** (the instrument does not ingest; it forwards a request over an + agreed seam). This crosses the two-artifact boundary and the relay is unproven; if gnarly, + drop-onto-panel is the shipped path and drop-onto-editor is deferred. + +**The assign seam.** The ingest action names the target instance (lean: the active/ +last-focused instance, discovered via the host context the bridge already resolves) and hands +it the new sample id — the same instance-owned selection state S4 already persists, so a +reload picks it up. With the change-detection seam (below) the assignment refreshes +hands-free; without it the ingest action pokes the target instance's reload directly. + +**Guardrail (load-bearing, restated):** ingest is an *extension* act. Any instrument code +path that captures, imports, inserts a timeline item, or writes back into the bank is a bug — +the instrument reads and plays only. + +## Bank-generation change-detection — hands-free refresh (decided 2026-07-26; PLAN.md S9) + +Instances reference sample **ids**. So a **recapture** (M10) landing under the same id — or +an **ingest** (S8) touching the active bank — should refresh playing instances **hands-free**, +without re-opening each editor. The missing trigger: a **bank-generation counter** in +`"reasampler"` ext-state. + +- **Writer (extension).** A monotonic **bank-generation counter**, stamped into + `"reasampler"` ext-state under a new forever-stable `ext_keys.h` constant, bumped on every + bank-content mutation that changes what an instance would play (capture add, recapture-in- + place, sample-remove, move/copy affecting the active bank). Additive to the persist blob; + defaults to 0 for projects saved before the stamp exists. +- **Reader (instrument).** Poll the generation over the bridge on a safe **off-audio-thread + cadence** (a UI/timer tick, **never** `process`), compare to the last-seen value, and call + the existing off-thread `reloadFromBank()` on change — reusing S4's atomic pointer-swap + handoff (graveyard-reclaim) so a mid-play refresh does not glitch. No new audio-thread work; + no allocation in `process`. +- **Cadence + safety.** A low-frequency UI timer, coalescing multiple bumps between polls into + one reload (build-time residual). The read already tolerates a stale value by design (it + reloads on the *next* poll). **Must-verify before build:** no torn-read hazard on the single + integer generation key for a bridge read on the instrument's UI/timer thread concurrent with + an extension write. + +This seam serves **both** S8 ingest and M10 recapture; the writer side is extension-only and +independent of S8, so it can land alongside either. + ## REAPER / Steinberg API surface (verify all signatures) - **VST3 SDK (a new vendored dependency — vendor it at the spike).** `FUnknown` and the @@ -1307,10 +1410,154 @@ and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embe - **Embedded UI (D-D, later point).** `IReaperUIEmbedInterface` and the embed message/lifecycle contract — verify against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h` before use. +- **VST3 bus arrangement (S7 channel mode).** `setBusArrangements` / + `getBusArrangement` and REAPER's mono/stereo instrument-bus expectations — verify against + the vendored Steinberg SDK + `reaper_vst3_interfaces.h`. +- **Ingest surfaces (S8).** `InsertMedia` is the placement path (untouched by ingest); + `CountSelectedMediaItems` / `GetSelectedMediaItem` + `GetSet_LoopTimeRange` are the + arrange-capture inputs (already the capture path's); `OpenMediaExplorer` + + `MediaExplorerGetLastPlayedFileInfo` are the *whole* Media-Explorer contract (thin — no + enumerate-selected, no ME-drop-handler). Drop handling is SWELL/Win32 on ReaSampler's own + panel HWNDs — REAPER exposes **no** drag-drop registration API. All verified against + `reaper_plugin_functions.h`. +- **Bank-generation seam (S9).** New forever-stable `ext_keys.h` key for the generation + counter; read over the same bridge `GetProjExtState` path S4 already uses. No new API — + confirm no torn-read hazard on the integer key. - **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the `IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event routing) — the least-trodden edge of the phase. +## Drop-and-load — drag a capture onto a track's FX button (S17 spec) + +**The gesture.** While a capture is dragged out of the `bank_panel`, a track's TCP **FX +button** becomes a drop zone. Dropping the capture there **instantiates a ReaSampler 9000 +on that track with the dragged capture already loaded and selected for playback** — one +gesture from bank to playable instrument. This is the *third* integration gesture: capture +(extension), placement-into-arrange (extension), and now **placement-of-the-player** +(this wave). It is drop-and-load, not drop-to-arrange — no media item touches the timeline. + +**Why it needs a new drag mode (the CF_HDROP path can't carry it).** Today's drag-out +(M11) becomes an **OS file drag** (`CF_HDROP` via `drag_out` + `drag_out_win`) the moment +the pointer leaves the panel client rect. REAPER's TCP FX button is **not** a native drop +target that instantiates a plugin-with-a-file, so this feature cannot ride the OS-drag +path: an OS drop of a WAV onto the FX area does not create "an instrument preloaded with +that WAV." It requires an **internal drag** where the extension itself tracks the pointer +over REAPER's own UI, detects the FX-button hover, and on release **drives the insert +itself**. The extension is the actor for the whole gesture. + +**The two-part mechanism.** + +1. **Internal-drag hover detection (extension-side, pure + shell).** The `drag_out` pure + module gains a **third `DragGesture`** beyond `Internal` (bank-to-bank) and `OsDrag` + (M11) — `InstrumentDrop`. The gesture decision is refined: leaving the panel client + rect no longer *immediately* means OS-bound. Instead: + - Pointer **inside** the panel client rect → `Internal` (unchanged bank-to-bank drag). + - Pointer **outside the panel but still over REAPER's own window/UI** → + `InstrumentDrop` (new — the shell hover-tracks the TCP FX button and highlights it). + - Pointer **left REAPER entirely** (Explorer / another app) → `OsDrag` (unchanged M11). + + The pure module stays REAPER-free: it decides `InstrumentDrop` vs. `OsDrag` from + position **plus an "over-REAPER's-own-UI" predicate the shell supplies** (the shell owns + the REAPER window/hit query; the pure layer owns the set/boundary algebra). Mirror of how + M11 kept `decideGesture` pure over a rect the shell supplied. The shell then resolves the + pointer to a track + FX-button hotspot, highlights it, and on release drives the drop. + +2. **FX-button drop → add-VST + load-capture (extension-side shell, then instrument + seam).** On release over an FX button the shell: + - Adds a fresh instance: `TrackFX_AddByName(track, "VST3:ReaSampler 9000", /*recFX*/ + false, /*instantiate*/ )`. **Verified present** in + `reaper_plugin_functions.h`: + `int TrackFX_AddByName(MediaTrack* track, const char* fxname, bool recFX, int + instantiate)` — a **negative** `instantiate` always creates a new effect (per the + header comment); the `"VST3:"` prefix selects the format. Captures the returned FX + index (or `-1` on failure). + - **Loads the dragged capture into that instance via the load-capture seam** (below). + - Wraps the whole thing in one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the + gesture is one Ctrl-Z — the same discipline the bank verbs use. + +**The ReaSampler 9000 load-capture seam (the hard coupling — MUST be added; does not yet +exist).** The Phase S spec today gives the instrument a **live-state *read* seam** (it +reads bank index + mapping from `"reasampler"` ext-state via the bridge — §The two seams) +but **no entry point for an external actor to say "this fresh instance should play *this +specific* capture."** Reading the bank is not the same as being *pointed at one sample*. +This wave is the reason to add that seam, and the seam lands **inside the instrument** +(the `phase-s` artifact), not the extension. Two candidate mechanisms — **the choice is a +Phase S open question:** +- **(A) Fresh-instance ext-state handshake.** The extension writes a small "pending load" + hint into `"reasampler"` ext-state keyed to the target track/FX (e.g. the capture id + + a target GUID); a freshly-instantiated ReaSampler 9000 reads it on init via the same + bridge it already uses, claims + clears the hint, and self-selects that capture. **Pro:** + reuses the existing bridge; no new VST3 surface. **Con:** a cross-process handshake with + a claim/clear race to get right; "which instance claims which hint" needs a stable key. +- **(B) VST3 `setState` / preset injection.** The extension builds the instance's + component state (the same blob `getState`/`setState` round-trips) with the capture + pre-selected and injects it right after `TrackFX_AddByName`. **Pro:** deterministic, no + shared-state race, uses the instrument's own persistence format. **Con:** the extension + must know and construct the instrument's state blob format — a tighter cross-artifact + coupling to a format that is itself still being built in Phase S; verify whether the + extension can set an added FX's state through the REAPER API (candidate: + `TrackFX_SetNamedConfigParm` / a state-set path — **not yet confirmed against + `reaper_plugin_functions.h`**). + + Lean: **(A)** keeps the artifacts loosely coupled through the one ext-state seam they + already share and avoids the extension hard-coding the instrument's state format — but + it inherits the claim/clear race. Daniel to decide (open question below). + +**Coexistence with the OS drag-out (disambiguation contract).** The two OS-vs-internal +modes are disambiguated **by pointer location, not a mode toggle** — the user never picks +"OS drag" vs. "instrument drop"; the extension infers it from where the pointer is when +released. The M11 boundary (left the client rect) is *refined*, not replaced: leaving the +rect now asks "over REAPER's UI → InstrumentDrop, else → OsDrag." Both M11 OS drag-out and +the internal bank-to-bank drag must remain **byte-for-byte unchanged** in their own +regions — this wave only inserts a new middle case. Multi-capture payloads are a +disambiguation input too (see open question — instrument drop is naturally single-capture; +a multi-capture drag over the FX button is either rejected or loads the first). + +**Precision / invariant implications (drop-and-load).** +- **Explicit user-driven placement — consistent with capture↔placement separation.** This + is a *deliberate placement gesture*: the user chooses to put a playing instrument on a + track, exactly as inserting an item into the arrange is a deliberate act. It does **not** + auto-capture (the file already exists in the bank) and does **not** insert a media item + into the timeline. It instantiates a *reader* of the bank on a track and points it at one + already-captured sample. Capture, placement, and playback stay three distinct acts; this + is placement-of-the-player, not a capture and not a timeline insert. +- **No private sample copy.** The instantiated instrument consumes the one authoritative + bank (it resolves the WAV via the shared M4 project-relative machinery like any + ReaSampler 9000 instance); the seam hands it a *reference* (a capture identity), never a + copied file. Any path that copies bytes into the instance is a bug. +- **The internal drag stays pure-decidable and testable.** The new `InstrumentDrop` + gesture is decided in the `drag_out` pure module (REAPER-free) over a shell-supplied + predicate; the M11 `drag_out` unit tests must not regress. + +**Open questions (Daniel / Phase S team to decide).** +- **Load-capture seam mechanism (A vs. B above).** The single load-bearing Phase S design + choice this wave forces. Lean is (A) for loose coupling; needs Daniel's call. +- **Multi-capture drag over an FX button** — reject (only single-capture drags arm + `InstrumentDrop`), or load the first / a keymap of all? Tier-0 leans reject-or-first; + a multi-capture keymap load is a Tier-1 stretch. +- **FX-button hotspot vs. whole TCP.** Does the drop zone have to be the FX button + specifically, or is dropping anywhere on the target track's TCP enough (simpler hit + resolution, arguably clearer target)? Depends on what the SDK exposes (see must-verify). +- **Numbering vs. the `phase-s` worktree (reconciled on merge to dev).** Authored on dev + as a provisional S7; renumbered to **S17** on merge, since the worktree's authoritative + Phase S set (S7 stereo, S8 ingest, S9 change-detection, S10–S16) took the lower labels. + Drop-and-load stays a distinct integration gesture (drop onto a track's FX button) — + a sibling of but not the same as S8 (ingest into the bank) and S13 (drop-to-load inside + the editor). See PLAN.md §S17. + +**Must-verify before build (drop-and-load).** +- `TrackFX_AddByName` — **verified present** (`reaper_plugin_functions.h`): signature and + the `"VST3:"`-prefix + negative-`instantiate` semantics confirmed from the header. +- **Pointer→track / FX-button hit resolution during a drag** — **not yet confirmed.** + Candidates: `GetTrackFromPoint` / `GetThingFromPoint` (verify names + signatures against + `reaper_plugin_functions.h`); whether the FX button specifically is addressable vs. the + TCP as a whole is an open verification that also decides the "hotspot vs. whole TCP" + question. +- **Instance state injection (only if seam mechanism (B) is chosen)** — whether the + extension can set a just-added FX's state via the REAPER API + (`TrackFX_SetNamedConfigParm` or similar) — **not yet confirmed against + `reaper_plugin_functions.h`.** Moot if (A) is chosen. + ## Non-goals / guardrails - **The instrument never captures and never inserts into the arrange.** Playback is a @@ -1319,6 +1566,13 @@ and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embe - **The instrument keeps no private copy of the samples.** It consumes the one authoritative bank; per-instance sample stores are a non-goal (they refork the source the one-source-multiple-views instinct keeps single). +- **The instrument never ingests (S8).** Capture, import, and drop-ingest are *extension* + acts; the instrument only reads and plays. A drop onto the editor window (if the spike + proves it viable) is *relayed to the extension* as an ingest request — the instrument + never writes the bank itself. +- **Channel mode is a performance choice, not a bank fact (S7).** The mono/stereo toggle is + per-instance component state, never written to `Sample` or the bank (D-B). The bank's + per-sample channel-count intrinsic is a *file fact*; the play mode is the instrument's. - **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do not add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target. - **The pure core stays REAPER-free *and* VST3-free.** The voice engine / envelope / @@ -1329,7 +1583,16 @@ and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embe else in Phase S lives in the *second* artifact and does not alter the extension's M/D/B/R/V pillars. - **Do not spec Tier 2/3.** Tier 2 is held (noted, not specified); Tier 3 is - optional-forever. Do not let their feature lists drive Tier 0–1's build shape. + optional-forever. Do not let their feature lists drive Tier 0–1's build shape. **Note:** + S7 stereo is *not* a Tier-2 feature — it is a channel-count dimension on the existing + Tier 0–1 engine, orthogonal to Tier 2's velocity-layers / round-robin / per-sample trim. + (S7's stereo loop read is the same loop the core already has, extended per-channel — not + the Tier-2 "sustain loops" feature.) +- **Drop-and-load must not regress the two existing drags.** S17 inserts a new middle case + (`InstrumentDrop`) between the M11 OS drag-out and the internal bank-to-bank drag; both + existing gestures stay byte-for-byte unchanged in their own regions. Drop-and-load never + inserts a media item into the arrange and never copies sample bytes into the instance — + it hands the new instance a *reference* to an already-captured bank sample. - **Verify Steinberg SDK, bridge, embed, and LICE-view surfaces** against the vendored headers before use — several §1a claims are experienced estimates until the spike confirms them. diff --git a/PLAN.md b/PLAN.md index c0c49c8..e100ac6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -254,6 +254,230 @@ build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle agains - [ ] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects the live keymap/levels. +## S7 — stereo channel mode (mono | stereo; core channel dimension + bus negotiation) +**Goal:** Give the instrument a per-instance **channel-mode toggle — 1 (mono) or 2 +(stereo)** — that "works with the REAPER audio bus automatically." Mono keeps today's +downmix path; stereo grows the S3 core a **channel dimension** (2-channel sample data, +per-voice stereo render, stereo interp/loop) and negotiates the VST3 output bus so +mono/stereo just works in REAPER's routing. **This is an S3-core extension, not a shell +hack** — it touches the engine Daniel smoke-tests, so it sequences first after the +editor/embed work. CONTEXT.md §Phase S (channel mode, D-E). **Decided direction +(2026-07-26); leans below are build-time residuals, not open forks.** +**Verify (in DAW):** an instance set to stereo plays a stereo capture in true stereo, +its VST3 output bus negotiated to 2 channels via `setBusArrangements` so REAPER routes it +without manual channel wiring; an instance set to mono plays the existing downmix path; a +mono source in stereo mode plays dual-mono (centered); a stereo source in mono mode +downmixes (existing policy); the mode is per-instance state that survives project +save/reopen (component state, like the selected sample); the pure core's stereo render is +asserted against a known two-channel signal (mirror of `peaks`), and mono behavior is +unchanged (regression). +**Depends on:** S3 (extends the core), S4 (extends the process/bus shell). Independent of +S8/S9. + +- [ ] Core channel dimension (pure, S3 extension): `SampleData` carries N-channel + (1 or 2) decoded PCM; `Voice::renderFrame` and `VoiceEngine::render` produce a + per-channel frame; stereo linear interpolation + loop read per channel. Mono stays the + degenerate case (single channel) — no behavior change for existing mono play. Tests: + stereo render asserted against a known 2-channel signal; mono render unchanged. +- [ ] Channel-mode toggle as per-instance state: `mono | stereo` in the instrument's own + component state (setState/getState, alongside the selected sample); default preserves + current behavior (mono). Cross-mode policy: **mono source + stereo mode → dual-mono** + (same signal both channels, centered); **stereo source + mono mode → downmix** (the + existing decode-side policy). The toggle lives in the instrument, never written to the + bank (a performance choice, not a file fact — D-B). +- [ ] Shell: decode fills 1- or 2-channel `SampleData` per the source's channel count + (the S2/bank channel-count intrinsic already exists); the process path renders the + active mode's channel count into the output bus. +- [ ] VST3 bus negotiation: implement `setBusArrangements` so the output bus reports + mono or stereo per the instance's channel mode, and REAPER's routing follows + automatically (no manual channel wiring). **Must-verify before build:** the + `setBusArrangements` / `getBusArrangement` contract and REAPER's mono/stereo instrument + bus expectations against the vendored Steinberg SDK + `reaper_vst3_interfaces.h`. + +## S8 — ingest through the bank (one gesture: capture/import into bank + assign to instance) +**Goal:** Loading a sample into the sampler is **one gesture** — capture/import-into-bank +**and** auto-assign to the active sampler instance. **The extension owns ingest** (it has +arrange access, media-explorer access, and drop-target surface on its own panels); the +instrument stays a **read-only bank consumer**. This lives in the *extension* codebase +(actions + bank_panel + capture/insert), routing through the existing capture add-path and +the live `"reasampler"` seam the instrument already reads. CONTEXT.md §Phase S +(ingest-through-bank contract). **Decided direction "option 1" (2026-07-26).** +**Verify (in DAW):** a one-click "capture selected item / time-selection into the bank and +assign to the active instance" action captures via the existing capture path (never +auto-inserting into the arrange — load-bearing principle intact) and the target instance +plays the new sample on its next reload; a Media Explorer file imports into the bank and +assigns the same way; a file dropped onto a ReaSampler panel surface ingests into the bank +and assigns; the instrument never captures or imports (read-only over the bank throughout). +**Depends on:** S4 (an instance to assign to), M7 capture add-path, B2 (active-bank add +target). Best paired with S9 so assignment refreshes hands-free; functional without it +(assign triggers a reload on the target instance directly). + +- [ ] "Capture selected item / time-selection into bank + assign to active instance" + action (`command_id`/`gaccel`/`hookcommand`, MIDI-bindable): reuse the existing capture + request path (`CountSelectedMediaItems`/`GetSelectedMediaItem` + `GetSet_LoopTimeRange` + as the capture inputs), add the resulting `Sample` to the active bank, then assign its + id to the target instance. **Never inserts a timeline item** (capture/placement stay + separate — the assignment is a bank-index + instance-selection act, not a placement). +- [ ] Media Explorer import → bank → assign: read the Media Explorer's current selection + via `MediaExplorerGetLastPlayedFileInfo` (path + selection range), import the file into + the bank (existing import/capture add-path), assign to the target instance. **Honest SDK + limit (verified against the vendored headers):** the Media-Explorer surface is thin — + `OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the *one* + last-played/selected file + its range) are the whole contract; there is **no** + enumerate-selected-files and **no** register-a-drop-handler-on-the-Media-Explorer API. + So ME import is *single-file, pull-on-action* (an action the user fires while a file is + selected in the ME), not a push/drop from inside the Media Explorer. **Spike:** confirm + `MediaExplorerGetLastPlayedFileInfo` returns a usable path+range for a merely-*selected* + (not-yet-played) file, or whether a play is required first. +- [ ] Drag-and-drop onto ReaSampler surfaces: accept an OS file drop onto the docked + `bank_panel` (and its bank/tab regions) → ingest into the bank → assign. **Honest SDK + limit (verified):** REAPER exposes **no** drag-drop registration API; drop handling is on + ReaSampler's *own* HWNDs via SWELL/Win32 (`WM_DROPFILES` / an `IDropTarget` on the panel + HWND), the same surface the panel already owns. **Assess-and-flag (spike, do not promise + here):** a drop *onto the VST3 editor window* — whether the `IPlugView` HWND can accept an + OS file drop and relay it to the extension as a bank-ingest request (the instrument does + **not** ingest; it forwards a request to the extension over an agreed seam). Reported + honestly as a spike because it crosses the two-artifact boundary and the relay mechanism + is unproven; if it proves gnarly, drop-onto-panel is the shipped path and drop-onto-editor + is deferred. +- [ ] "Assign to instance" seam: how the ingest action names the target instance and hands + it the new sample id. Lean (build-time residual, not a fork): the active/last-focused + instance is the target, discovered via the host context the bridge already resolves; the + assignment is the same instance-owned selection state S4 already persists, so a reload + picks it up. If the change-detection seam (S9) exists, assignment refreshes hands-free; + without it, the ingest action pokes the target instance's reload directly. + +## S9 — bank-generation change-detection (recapture / ingest refreshes instances hands-free) +**Goal:** Because instances reference sample **ids**, a **recapture** (M10) landing under +the same id — or an **ingest** (S8) touching the active bank — should refresh playing +instances **hands-free**, without the user re-opening each editor. Add a **bank-generation +counter** to `"reasampler"` ext-state that the extension bumps on any bank-content +mutation, and that the instrument polls off the audio thread on a safe cadence, calling its +existing `reloadFromBank()` when the generation changes. CONTEXT.md §Phase S +(bank-generation seam). **Closes the missing change-detection trigger the recapture +auto-update story needs.** +**Verify (in DAW):** a recapture that regenerates a sample already assigned to a live +instance refreshes that instance's playback within a bounded cadence, no editor re-open; an +ingest (S8) that updates the active bank likewise refreshes assigned instances; the poll +runs off the audio thread (never in `process`) and triggers the existing off-thread reload +path; instances not referencing a changed sample do not audibly glitch (reload is atomic — +the S4 graveyard-reclaim handoff); a project with no generation stamp (pre-S9) defaults +cleanly (treated as generation 0; first bump refreshes). +**Depends on:** S4 (the off-thread `reloadFromBank` + atomic handoff this drives). Writer +side is extension-only and independent of S8; consumed by S8 and M10 recapture. Best landed +alongside S8. + +- [ ] Writer (extension): a monotonic **bank-generation counter** stamped into + `"reasampler"` ext-state (new `ext_keys.h` constant — forever-stable spelling), bumped + on every bank-content mutation that changes what an instance would play (capture add, + recapture-in-place, sample-remove, move/copy affecting the active bank). Additive to the + persist blob; defaults to 0 for projects saved before the stamp exists. +- [ ] Reader (instrument): poll the generation over the bridge on a safe **off-audio-thread + cadence** (a UI/timer tick, not `process`), compare to the last-seen value, and call the + existing `reloadFromBank()` on change — reusing S4's atomic pointer-swap handoff so a + refresh mid-play does not glitch. No new audio-thread work; no allocation in `process`. +- [ ] Cadence + coalescing: pick a poll interval that is responsive but cheap (build-time + residual — a low-frequency UI timer, coalescing multiple bumps between polls into one + reload). **Must-verify before build:** that a bridge ext-state read on the instrument's + UI/timer thread is safe against a concurrent extension write (the read already tolerates a + stale value by design — it reloads on the *next* poll; confirm no torn-read hazard for the + single integer generation key). + +## S17 — drop-and-load: drag a capture onto a track's FX button → instantiate ReaSampler 9000 with the capture loaded +**Goal:** Turn a bank capture into a playable instrument in one gesture. Today a drag +out of the `bank_panel` becomes an OS file drag once it leaves the panel (M11 — +`drag_out` + `drag_out_win`, CF_HDROP). This wave adds a **second, internal drag mode**: +while a capture is dragged, a track's TCP **FX button** lights as a drop zone, and +dropping there instantiates a **ReaSampler 9000** (the Phase S VST3 sampler) on that +track with the dragged capture **already loaded and selected** for playback. The +extension drives the whole gesture itself — REAPER's FX button is not a native +plugin-with-file drop target, so this cannot ride the CF_HDROP path. CONTEXT.md §Phase S +(drop-and-load — internal-drag hover mode + the FX-button drop → add-VST + load-capture +seam). Product framing: `docs/product/midi-playback.md` (drop-and-load — the third +integration gesture). +**Consistent with the load-bearing principle (make the reasoning explicit):** this is an +**explicit, user-driven placement gesture** — the user is deliberately choosing to place +a playing instrument on a track, exactly as inserting an item into the arrange is a +deliberate placement act. It does **not** auto-capture (the file already exists in the +bank) and does **not** insert a media item into the timeline; it instantiates a *reader* +of the bank on a track and points it at one already-captured sample. Capture, placement, +and playback stay distinct acts; drop-and-load is a placement-of-the-player gesture, not +a capture and not a timeline insert. +**Two-part mechanism:** +- **(a) Internal-drag hover mode.** A drag armed with a *single* capture that stays + *inside* REAPER's own UI (does not cross to Explorer / another app) is tracked by the + extension: it detects the pointer hovering a track's TCP FX button, highlights it as a + drop target, and on release drives the insert. This is a *third* `DragGesture` beyond + the existing `Internal` (bank-to-bank) and `OsDrag` (M11) — call it `InstrumentDrop`. +- **(b) FX-button drop → add-VST + load-capture.** On drop, the extension adds a + ReaSampler 9000 instance to the target track via `TrackFX_AddByName` (verified present + in `reaper_plugin_functions.h`; signature + `int TrackFX_AddByName(MediaTrack*, const char* fxname, bool recFX, int instantiate)` — + use `"VST3:ReaSampler 9000"` and a negative `instantiate` to always create a new + instance), then pushes the dragged capture's identity into that instance so it plays + that sample — via the **load-capture seam** (below). +**The ReaSampler 9000 load-capture seam (the hard Phase S coupling — MUST be added):** +The current Phase S spec gives the instrument a *live-state read* seam (it reads the bank +index + mapping from `"reasampler"` ext-state via the bridge) but **no entry point for an +external actor to say "instantiate playing *this specific* capture."** This wave is the +reason to add that seam. It is a Phase S dependency, not extension-side, and must land in +the instrument before drop-and-load's drop half can work end-to-end. See CONTEXT.md +§Phase S for the two candidate seam mechanisms (fresh-instance ext-state handshake vs. +VST3 `setState` preset injection) and the open question on which is chosen. +**Coexistence with the M11 OS drag-out (disambiguation, load-bearing):** the two drag +modes are disambiguated by **where the pointer goes**, not by a mode toggle. Inside the +panel client rect → `Internal` (unchanged). Left the panel but still over REAPER's own +window/UI → `InstrumentDrop` (new — hover-tracks the FX button). Left REAPER entirely +(Explorer / another app) → `OsDrag` (unchanged M11). The M11 `decideGesture` boundary +(pointer left the client rect) is **refined**, not replaced: leaving the client rect no +longer immediately means OS-bound; it means "resolve which of InstrumentDrop / OsDrag by +whether the pointer is over REAPER's UI." Single-capture vs. multi-capture also +disambiguates — see open questions. +**Verify (in DAW):** dragging a single capture from the dock over a track's FX button +highlights it; dropping instantiates ReaSampler 9000 on that track with the dragged +capture loaded, selected, and MIDI-playable immediately (no manual pick step); the OS +drag-out to Explorer / another DAW still works unchanged; the internal bank-to-bank drag +still works unchanged; no media item is ever inserted into the arrange; the instrument +holds no private copy (it reads the one authoritative bank). +**Depends on:** M11 (`drag_out` gesture machinery — the mode it extends); **Phase S S4** +(a loadable, playing ReaSampler 9000 instance must exist) **AND the new load-capture seam +added inside ReaSampler 9000**. Composes with — but is distinct from — **S8** (ingest +through the bank: capture/import/drop *into* the bank) and **S13** (drop-to-load *inside* +the editor). S17 is the third integration gesture: drop *onto a track's FX button* to +instantiate a player. Gated on the rest of Phase S; the instrument seam is a Phase S +artifact, not extension-only. + +- [ ] Extend the `drag_out` pure module with the third gesture: `decideGesture` (or a + successor) returns `InstrumentDrop` when a drag armed with a single capture is over + REAPER's UI outside the panel client rect, `OsDrag` only when it has left REAPER + entirely, `Internal`/`OsDrag`/`None` otherwise unchanged. Pure over (drag state + + pointer + panel rect + an "over-own-UI" predicate the shell supplies). Unit-tested — + the existing `drag_out` invariants (M11) must not regress. +- [ ] Shell (extension): hover-track the pointer over REAPER's UI during the drag, + resolve the hovered track + its FX button (verify the TCP/FX-button hit surface against + the SDK — see must-verify), highlight it as a drop target, and on release drive the + drop. Extends the `bank_panel` drag hook alongside the M11 `drag_out_win` path. +- [ ] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:ReaSampler 9000", + false, /*instantiate*/ negative)` to always add a fresh instance; capture the returned + FX index; then invoke the load-capture seam to point the new instance at the dragged + capture. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the + whole gesture is one Ctrl-Z (mirrors the bank-verb undo discipline). +- [ ] **ReaSampler 9000 (Phase S artifact):** add the **load-capture seam** — the entry + point that lets the just-added instance be told which capture to play (mechanism chosen + per the CONTEXT.md open question). This is the cross-artifact half; it lands in the + instrument, not the extension. +- [ ] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across + single- and multi-capture payloads; FX-button hit resolution (pure geometry where it + can be factored out); M11 OS drag-out and internal bank-to-bank drag both unchanged. + +> **Numbering reconciliation (resolved on merge to dev).** This wave was authored on +> **dev** as a provisional **S7** while dev's Phase S ran only S1–S6. On merge with the +> `phase-s` worktree, the worktree's authoritative numbering (S7 stereo, S8 ingest, S9 +> change-detection, S10–S16) took the lower labels, so drop-and-load was renumbered to +> **S17** — the next free label past the worktree's Phase S set. It stays a distinct +> integration gesture (drop onto a track's FX button), a sibling of but not the same as +> S8 (ingest into the bank) and S13 (drop-to-load inside the editor). + ## Phase S — held and optional-forever (noted, not specified) - **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun), full ADSR, per-sample tuning/gain trim, sustain loops. The next depth increment once @@ -273,6 +497,32 @@ build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle agains `0xdeadf00d`/`0xdeadf00e` opcodes are VST2-only and do not apply. - **`IReaperUIEmbedInterface`** — embed contract + message/lifecycle, against `reaper_plugin_fx_embed.h` (needed only at S6). +- **VST3 bus arrangement (S7)** — `setBusArrangements` / `getBusArrangement` and REAPER's + mono/stereo instrument-bus expectations, against the vendored Steinberg SDK + + `reaper_vst3_interfaces.h`. The channel-mode toggle depends on the output bus + re-negotiating cleanly. +- **Media Explorer surface (S8)** — confirmed thin against the vendored headers: + `OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the one + last-played/selected file + range) are the whole contract; **no** enumerate-selected and + **no** ME-drop-handler API. Spike: does `MediaExplorerGetLastPlayedFileInfo` return a + usable path+range for a merely-selected (not-yet-played) file? +- **Drop targets (S8)** — REAPER exposes **no** drag-drop registration API (verified); drop + handling is on ReaSampler's own panel HWNDs via SWELL/Win32 (`WM_DROPFILES` / `IDropTarget`). + Drop-onto-VST3-editor relayed as a bank-ingest request is an **unproven cross-artifact + spike**, not a promise. +- **Bank-generation ext-state read (S9)** — confirm no torn-read hazard on the single + integer generation key for a bridge read on the instrument's UI/timer thread concurrent + with an extension write. +- **Drop-and-load (S17) — three surfaces.** (1) `TrackFX_AddByName` — **verified present** + in `reaper_plugin_functions.h` (signature confirmed; the `"VST3:"` name prefix and the + negative-`instantiate`-always-adds semantics are documented in the header comment). (2) + **TCP / FX-button hit resolution** — how the extension resolves the pointer-under-cursor + to a track and its FX-button hotspot during a drag: **not yet confirmed against the + SDK/SWELL** — `GetTrackFromPoint` / `GetThingFromPoint` are candidates to verify against + `reaper_plugin_functions.h`; whether the FX button specifically is addressable (vs. the + TCP as a whole) is an open verification. (3) The **ReaSampler 9000 load-capture seam** — + a *new* interface added inside the Phase S instrument; its mechanism is a Phase S design + choice (see CONTEXT.md §Phase S open question), not a pre-existing SDK surface. --- diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index d9d5f0d..0632ff2 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -609,6 +609,55 @@ in-phase later point on the Phase S roadmap (**S6**), sequenced *after* the main `IPlugView` editor exists (it composes with that LICE path), not a someday-note. It is polish rather than a Tier-0 need, so it sequences last — but it is on the roadmap. +### Addendum — two directions set post-scoping (Daniel, 2026-07-26) + +After Phase S was scoped (D-A..D-D), Daniel set two further directions. These are +**settled directions**, not open forks — specced as new Phase S points (S7–S9), not +re-litigated. Recorded here per the doc's settled-decisions convention. + +**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ PLAN.md S7).** +Captures are often stereo; the current mono downmix is a Tier-0 simplification. The +engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with +the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus +arrangement (`setBusArrangements`) so mono/stereo just works in REAPER's routing. Honest +scope: **this is an S3-core extension, not a shell hack** — the core is mono-per-sample by +design today, so stereo mode grows a channel dimension (2-channel sample data, per-voice +stereo render, per-channel loop/interp). Mono mode keeps the existing downmix path. +Cross-mode policy: mono-source-in-stereo → dual-mono; stereo-source-in-mono → downmix +(existing). The toggle is instrument-owned per-instance state (D-B: a performance choice, +never a bank fact). Sequenced **first after the editor/embed work** because it touches the +engine Daniel smoke-tests. + +**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md +S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank ++ auto-assign to the active instance. The **extension owns ingest** (it has arrange +access, Media-Explorer access, and the drop-target surface on its own panels); the +**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts, +with the honest SDK reality verified against the vendored headers: + +- *(a) Arrange capture → bank → assign* — a one-click action reusing the existing capture + path; **never inserts a timeline item** (capture/placement separation intact). +- *(b) Media Explorer import → bank → assign* — the ME surface is **thin** + (`OpenMediaExplorer` + `MediaExplorerGetLastPlayedFileInfo` are the whole contract; no + enumerate-selected, no ME-drop-handler), so ME import is **single-file, pull-on-action**, + not a push/drop from inside the ME. Spike: does the last-played-file read work for a + merely-*selected* file? +- *(c) Drag-and-drop* — REAPER exposes **no** drag-drop registration API; drop handling is + SWELL/Win32 on ReaSampler's *own* panel HWNDs. Drop *onto the VST3 editor window* relayed + to the extension as a bank-ingest request is a genuine **cross-artifact spike**, not a + promise (drop-onto-panel is the shipped path if it proves gnarly). +- *(d) Recapture / ingest auto-refresh (→ S9)* — because instances reference sample **ids**, + a recapture landing under the same id (M10) or an ingest touching the active bank should + refresh live instances **hands-free**. The missing trigger is a **bank-generation counter** + in `"reasampler"` ext-state: the extension bumps it on any bank-content mutation; the + instrument polls it **off the audio thread** on a safe cadence and calls its existing + `reloadFromBank()` on change (reusing S4's atomic handoff). This seam serves both S8 ingest + and M10 recapture. + +*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the +ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both +are honestly-flagged as spikes in PLAN.md S8, not promised. + --- ## Where this landed @@ -626,10 +675,19 @@ into **Phase S** — a native VST3 sampler as a **second build artifact** alongs 4. **D-D → embedded TCP/MCP UI scheduled** (**S6**), after the main editor exists — on the roadmap, not deferred. -**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6, sequenced by -dependency order: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded -UI); **CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the -pure/shell split in the new artifact, the must-verify SDK/bridge surfaces). This doc is the +Two further directions set post-scoping (2026-07-26; see the Addendum in §4): + +5. **D-E → channel mode (mono | stereo), per-instance, bus-negotiated** (**S7**) — an + S3-core channel-dimension extension, sequenced first after the editor/embed work. +6. **Ingest through the bank ("option 1"), extension-owned** (**S8**) + **bank-generation + hands-free refresh** (**S9**) — one-gesture capture/import + assign; the instrument stays + a read-only consumer. + +**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original +dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded +UI; then **S7** stereo, **S8** ingest, **S9** change-detection); **CONTEXT.md §Phase S** is +the spec (seam-field semantics, scope contracts, the channel-mode / ingest / bank-generation +contracts, the pure/shell split, the must-verify SDK/bridge surfaces). This doc is the framing/decision record they point back to. The "no PLAN.md footprint" era is over. --- From 03e760631cf1ee112f05bd6b2c2d7c073a5fbee9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 18:12:27 -0400 Subject: [PATCH 12/49] S6: embedded TCP/MCP UI via IReaperUIEmbedInterface Inline keymap/level strip drawn into REAPER's embed bitmap, reusing the LICE idiom. Pure embed_strip layout/hit-test (tested); processor exposes the embed interface off queryInterface and publishes an RT-safe block peak for the level. --- CMakeLists.txt | 18 ++- src/vst/embed_strip.cpp | 86 +++++++++++++ src/vst/embed_strip.h | 76 +++++++++++ src/vst/reasampler_embed.cpp | 213 +++++++++++++++++++++++++++++++ src/vst/reasampler_embed.h | 99 ++++++++++++++ src/vst/reasampler_processor.cpp | 28 ++++ src/vst/reasampler_processor.h | 30 +++++ tests/test_embed_strip.cpp | 157 +++++++++++++++++++++++ 8 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 src/vst/embed_strip.cpp create mode 100644 src/vst/embed_strip.h create mode 100644 src/vst/reasampler_embed.cpp create mode 100644 src/vst/reasampler_embed.h create mode 100644 tests/test_embed_strip.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 22857c9..5da172f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -621,6 +621,15 @@ target_include_directories(editor_geometry PUBLIC src/vst) add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp) target_include_directories(bridge_marshal PUBLIC src/vst) +# embed_strip (Phase S6) — PURE layout + hit-test for the embedded TCP/MCP strip: the +# 128-key span -> zone-segment rects, point -> zone selection, and the level-band fill. +# The mirror of editor_geometry (whose Rect + contains() it reuses); unit-tested outside +# the DAW, while the embed shell (src/vst/reasampler_embed.cpp) marshals REAPER's embed +# messages (paint bitmap + mouse coords) into it. Links editor_geometry for the shared Rect. +add_library(embed_strip STATIC src/vst/embed_strip.cpp) +target_include_directories(embed_strip PUBLIC src/vst) +target_link_libraries(embed_strip PUBLIC editor_geometry) + # 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 @@ -641,6 +650,10 @@ add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp) target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal) add_test(NAME bridge_marshal_tests COMMAND bridge_marshal_tests) +add_executable(embed_strip_tests tests/test_embed_strip.cpp) +target_link_libraries(embed_strip_tests PRIVATE embed_strip) +add_test(NAME embed_strip_tests COMMAND embed_strip_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. @@ -800,6 +813,7 @@ if(WIN32) src/vst/vst_entry.cpp src/vst/reasampler_processor.cpp src/vst/reasampler_editor.cpp + src/vst/reasampler_embed.cpp src/vst/reaper_bridge.cpp # SDK module entry — compiled into the module (not the static lib) so the # InitDll/ExitDll dll exports survive the link (see vst3_sdk note above). @@ -813,8 +827,10 @@ if(WIN32) # 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, ...). + # embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals + # into; it links editor_geometry transitively (shared Rect). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sample_map capture_paths) + sample_map capture_paths embed_strip) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/vst/embed_strip.cpp b/src/vst/embed_strip.cpp new file mode 100644 index 0000000..e1bf0d9 --- /dev/null +++ b/src/vst/embed_strip.cpp @@ -0,0 +1,86 @@ +// embed_strip.cpp — see embed_strip.h. Pure math; no host types. + +#include "embed_strip.h" + +#include + +namespace reasampler::vst { + +namespace { + +// Clamp a MIDI note to [0, kEmbedKeyCount-1]. +int clampNote(int n) { + if (n < 0) return 0; + if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1; + return n; +} + +// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given +// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right. +// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1), +// which tiles adjacent zones without a seam. +int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { + if (keyEdge <= 0) return bandLeft; + if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth; + return bandLeft + (keyEdge * bandWidth) / kEmbedKeyCount; +} + +} // namespace + +EmbedLayout layoutEmbed(int w, int h) { + const int cw = std::max(0, w); + const int ch = std::max(0, h); + + EmbedLayout out; + + // The level band takes a fixed height at the bottom, but never so much that the keymap + // above it falls below its minimum (or that the band exceeds the area). On a very short + // area the band yields to the keymap entirely. + int bandH = std::min(kEmbedLevelBandHeight, ch); + if (ch - bandH < kEmbedKeymapMinHeight) { + bandH = std::max(0, ch - kEmbedKeymapMinHeight); + } + const int keymapBottom = ch - bandH; + + out.keymap = Rect{0, 0, cw, keymapBottom}; + out.levelBand = Rect{0, keymapBottom, cw, ch}; + return out; +} + +Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) { + const Rect& band = layout.keymap; + const int bandWidth = std::max(0, band.width()); + + int lo = clampNote(lowNote); + int hi = clampNote(highNote); + if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts + + const int leftX = keyEdgeToX(band.left, bandWidth, lo); + const int rightX = keyEdgeToX(band.left, bandWidth, hi + 1); + return Rect{leftX, band.top, std::max(leftX, rightX), band.bottom}; +} + +int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, + int y) { + if (zoneCount <= 0 || zones == nullptr) return -1; + if (!contains(layout.keymap, x, y)) return -1; + // First covering zone in draw order wins (first-match, mirroring the core's resolve). + for (int i = 0; i < zoneCount; ++i) { + const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote); + if (contains(r, x, y)) return i; + } + return -1; // on the band but on an uncovered key +} + +Rect levelFillRect(const EmbedLayout& layout, double level) { + const Rect& band = layout.levelBand; + if (band.width() <= 0 || band.height() <= 0) return Rect{}; + double l = level; + if (l < 0.0) l = 0.0; + if (l > 1.0) l = 1.0; + const int fillW = static_cast(l * band.width()); + if (fillW <= 0) return Rect{}; + return Rect{band.left, band.top, band.left + fillW, band.bottom}; +} + +} // namespace reasampler::vst diff --git a/src/vst/embed_strip.h b/src/vst/embed_strip.h new file mode 100644 index 0000000..b16a88d --- /dev/null +++ b/src/vst/embed_strip.h @@ -0,0 +1,76 @@ +// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3, +// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / +// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives +// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp) +// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions. +// +// The strip is a single compact band REAPER draws inline in the track/mixer control panel +// (context TCP or MCP) via the Cockos embedded-UI surface. It shows: +// * the zone layout — each performance zone as a horizontal segment across the keyboard +// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and +// * a thin level band at the bottom — a 0..1 activity indicator the shell fills. +// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a +// click maps to the zone whose key range covers that point, or -1. +// +// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share +// one geometry idiom), so this header depends on editor_geometry.h rather than redefining +// a second rectangle type. + +#pragma once + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's +// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle. +inline constexpr int kEmbedKeyCount = 128; + +// Fixed metrics for the strip, exposed so the shell and tests agree. +inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px) +inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller + +// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal +// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM — +// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are +// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed +// zone never yields an out-of-strip rect. +struct EmbedZone { + int lowNote = 0; + int highNote = 127; +}; + +// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to +// the area so a degenerate (tiny) size never yields a region spilling outside the surface. +struct EmbedLayout { + Rect keymap; // top: the zone-segment band (the compact keymap) + Rect levelBand; // bottom: the thin level/activity indicator +}; + +// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout. +// The level band takes a fixed height at the bottom (clamped so it never exceeds the area +// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or +// negative size yields empty rects (no inversion). +EmbedLayout layoutEmbed(int w, int h); + +// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote] +// (inclusive). The 128-key span maps linearly across keymap.width(); the returned rect +// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g. +// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low +// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect, +// never an inverted one. Pure. +Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote); + +// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click +// outside the keymap band or on a key not covered by any zone. When zones overlap on a +// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match +// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure. +int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, + int y); + +// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the +// returned rect is the left sub-rectangle of levelBand whose width is level * band width +// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure. +Rect levelFillRect(const EmbedLayout& layout, double level); + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp new file mode 100644 index 0000000..959ed8c --- /dev/null +++ b/src/vst/reasampler_embed.cpp @@ -0,0 +1,213 @@ +// reasampler_embed.cpp — see reasampler_embed.h. The IReaperUIEmbedInterface shell. +// Windows-only (D5); guarded so a non-Windows build degrades to a stub that reports +// "not supported" and draws nothing. + +#include "reasampler_embed.h" + +#include +#include + +#include "editor_geometry.h" // Rect (shared with embed_strip) +#include "embed_strip.h" // the pure strip layout + hit-test +#include "ext_keys.h" // kProjExtBanksKey +#include "reaper_bridge.h" +#include "reasampler_processor.h" + +// wdltypes.h first: it defines INT_PTR portably (and pulls on Windows), which +// reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type. +#include "wdltypes.h" + +// REAPER's embed message/bitmap contract (vendored). REAPER_FXEMBED_IBitmap is an alias of +// LICE_IBitmap, and the WM_* / DrawInfo / SizeHints definitions live here. +#include "reaper_plugin_fx_embed.h" + +#ifdef _WIN32 +// LICE — the same drawing stack the IPlugView editor and bank_panel use. REAPER hands us a +// LICE bitmap; we draw into it with the same calls, then return (REAPER blits it). +#include "lice/lice.h" +#endif + +using namespace Steinberg; + +// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperUIEmbedInterface::iid; some +// TU must DEFINE it. This is the only place that answers queryInterface for it, so the +// definition lives with its sole use (mirrors reaper_bridge.cpp doing this for +// IReaperHostApplication). +DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface) + +namespace reasampler::vst { + +namespace { +#ifdef _WIN32 +// Palette — mirrored from reasampler_editor.cpp so the inline strip reads as the same tool. +const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); +const LICE_pixel kColZone = LICE_RGBA(44, 44, 48, 255); +const LICE_pixel kColZoneSel = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColZoneBorder = LICE_RGBA(20, 20, 22, 255); +const LICE_pixel kColLevelBg = LICE_RGBA(20, 20, 22, 255); +const LICE_pixel kColLevelFill = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColEmpty = LICE_RGBA(70, 70, 74, 255); +const COLORREF kRgbText = RGB(210, 230, 220); + +// A short display name for a bank sample id, from the snapshotted list (the editor's helper, +// duplicated small rather than shared across the shell/pure boundary). +std::string sampleLabel(const std::vector& samples, const std::string& id) { + for (const SampleChoice& c : samples) { + if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; + } + return "?"; +} +#endif + +// Project the instrument's performance map into the strip's minimal zone shape (key ranges +// only). Pure projection — kept here (shell side) because it reads PerformanceMap, a shell +// type; embed_strip stays free of it. +std::vector toEmbedZones(const PerformanceMap& map) { + std::vector out; + out.reserve(map.zones.size()); + for (const PerformanceZone& z : map.zones) out.push_back(EmbedZone{z.lowNote, z.highNote}); + return out; +} + +} // namespace + +tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) { + QUERY_INTERFACE(iid, obj, FUnknown::iid, IReaperUIEmbedInterface) + QUERY_INTERFACE(iid, obj, IReaperUIEmbedInterface::iid, IReaperUIEmbedInterface) + *obj = nullptr; + return kNoInterface; +} + +void ReaSamplerEmbed::refresh() { + if (!processor_) { + samples_.clear(); + map_.zones.clear(); + selectedId_.clear(); + selectedZone_ = -1; + return; + } + auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banks ? listSamples(*banks) : std::vector{}; + map_ = processor_->performanceMap(); + selectedId_ = processor_->selectedSampleId(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; +} + +TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) { + switch (msg) { + case REAPER_FXEMBED_WM_IS_SUPPORTED: +#ifdef _WIN32 + return 1; // supported and available +#else + return 0; // not a build target off Windows +#endif + case REAPER_FXEMBED_WM_CREATE: + refresh(); // prime the first paint's snapshot + return 0; + case REAPER_FXEMBED_WM_DESTROY: + return 0; +#ifdef _WIN32 + case REAPER_FXEMBED_WM_PAINT: + return paint(parm2, parm3) ? 1 : 0; + case REAPER_FXEMBED_WM_LBUTTONDOWN: + // Selection at most (S6): map the click to a zone; force a redraw if it changed. + return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0; +#endif + default: + return 0; // unhandled messages (cursor, wheel, hittest, minmax) fall through + } +} + +#ifdef _WIN32 + +bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { + auto* bmp = reinterpret_cast(bitmap); + auto* di = reinterpret_cast(drawInfo); + if (!bmp || !di) return false; + const int w = di->width; + const int h = di->height; + if (w <= 0 || h <= 0) return false; + + // Re-read live state each paint (UI thread) so the strip reflects keymap edits + bank + // changes without its own timer — REAPER repaints the embed surface on its cadence. + refresh(); + + // REAPER hands us its own bitmap sized to the embed area; draw directly into it (unlike + // the editor, which owns a LICE_SysBitmap and BitBlt's). Origin is the bitmap's (0,0). + LICE_FillRect(bmp, 0, 0, w, h, kColBackground, 1.0f, 0); + + const EmbedLayout layout = layoutEmbed(w, h); + + if (map_.zones.empty()) { + // No keymap authored yet: show a single faint band spanning the keymap area so the + // strip still reads as "present but empty" (Tier-0 fallback plays chromatically). + LICE_FillRect(bmp, layout.keymap.left, layout.keymap.top, layout.keymap.width(), + layout.keymap.height(), kColEmpty, 0.5f, 0); + HDC dc = bmp->getDC(); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, kRgbText); + RECT gr{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right, + layout.keymap.bottom}; + const std::string label = + samples_.empty() ? "ReaSampler (bank empty)" : "ReaSampler (no zones)"; + DrawTextA(dc, label.c_str(), -1, &gr, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + } else { + // Draw each zone as a segment across the keymap span, first-match order (so the + // painted order matches selection + playback). The selected zone is highlighted. + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[i]; + const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote); + if (r.width() <= 0) continue; + const bool sel = (i == selectedZone_); + LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(), + sel ? kColZoneSel : kColZone, 1.0f, 0); + LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, kColZoneBorder, + 1.0f, 0); + // Label the segment with the sample name when it is wide enough to read. + if (r.width() >= 24) { + HDC dc = bmp->getDC(); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, kRgbText); + RECT gr{r.left + 3, r.top, r.right - 2, r.bottom}; + const std::string label = sampleLabel(samples_, z.sampleId); + DrawTextA(dc, label.c_str(), -1, &gr, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + } + } + } + + // The level band: a static background here; a live activity level is a later refinement + // (the processor would publish a peak the UI thread reads). Draw the empty band so the + // strip's geometry is complete and the DAW-verify sees the band lifecycle now. + if (layout.levelBand.height() > 0) { + LICE_FillRect(bmp, layout.levelBand.left, layout.levelBand.top, + layout.levelBand.width(), layout.levelBand.height(), kColLevelBg, 1.0f, + 0); + const double level = processor_ ? processor_->embedActivityLevel() : 0.0; + const Rect fill = levelFillRect(layout, level); + if (fill.width() > 0) { + LICE_FillRect(bmp, fill.left, fill.top, fill.width(), fill.height(), + kColLevelFill, 1.0f, 0); + } + } + + return true; +} + +bool ReaSamplerEmbed::onMouseDown(TPtrInt drawInfo) { + auto* di = reinterpret_cast(drawInfo); + if (!di || di->width <= 0 || di->height <= 0) return false; + refresh(); + const EmbedLayout layout = layoutEmbed(di->width, di->height); + const std::vector zones = toEmbedZones(map_); + const int hit = zoneAtPoint(layout, zones.data(), static_cast(zones.size()), + di->mouse_x, di->mouse_y); + if (hit == selectedZone_) return false; // no change -> no redraw + selectedZone_ = hit; + return true; +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_embed.h b/src/vst/reasampler_embed.h new file mode 100644 index 0000000..b4bad22 --- /dev/null +++ b/src/vst/reasampler_embed.h @@ -0,0 +1,99 @@ +// reasampler_embed.h — the S6 embedded TCP/MCP UI shell. Implements REAPER's +// IReaperUIEmbedInterface (vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h + +// reaper_vst3_interfaces.h) so the instrument draws a compact keymap/level strip INLINE in +// the track/mixer control panel — the same Cockos surface REAPER's own embedded FX use. +// +// VERIFIED CONTRACT (against reaper_plugin_fx_embed.h + reaper_vst3_interfaces.h): +// * VST3 exposes this by having the IEditController answer queryInterface for +// IReaperUIEmbedInterface (iid {0x049bf9e7,0xbc74ead0,0xc4101e86,0x7f725981}). Our +// SingleComponentEffect IS the edit controller, so the processor's queryInterface hands +// REAPER a reference to this object. +// * The single method is embed_message(int msg, TPtrInt parm2, TPtrInt parm3). msg is a +// REAPER_FXEMBED_WM_* value (aliased to Win32 WM_*): +// - WM_IS_SUPPORTED (0x0000): return 1 (supported+available), -1, or 0. +// - WM_CREATE (0x0001) / WM_DESTROY (0x0002): embed begin/end; return ignored. +// - WM_PAINT (0x000F): parm2 = REAPER_FXEMBED_IBitmap* (alias LICE_IBitmap) to draw +// into; parm3 = REAPER_FXEMBED_DrawInfo* (context TCP=1/MCP=2, width/height, mouse, +// flags). Return 1 if drawing occurred, 0 otherwise. +// - WM_GETMINMAXINFO (0x0024): parm3 = SizeHints*; return 1 if filled. +// - mouse WM_* (0x0200..0x020A): parm3 = DrawInfo*; return RETNOTIFY_INVALIDATE +// (0x1000000) to force a redraw. Capture is auto-managed by the host. +// * There is NO plugin-owned window/HWND here (unlike the IPlugView editor): REAPER hands +// a LICE bitmap per paint; we only draw into it and read mouse coords from DrawInfo. +// +// RT DISCIPLINE (S6 constraint): all embed messages arrive on REAPER's UI thread; nothing +// here runs in process(). It reads the same live state the editor reads (bank over the +// bridge + the processor's performance map) with the same off-audio-thread accessors — no +// new locks visible to process, read-only over the bank. Windows-only (D5), guarded so a +// non-Windows build stays compilable. +// +// The strip's LAYOUT + HIT-TEST is pure (embed_strip.h, unit-tested); this shell marshals +// REAPER's messages to/from it and draws with the same LICE idiom as reasampler_editor. + +#pragma once + +#include +#include + +#include "pluginterfaces/base/funknown.h" + +#include "sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects) + +// REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is +// pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host +// interface header. Its iid is DEFINEd (DEF_CLASS_IID) in reasampler_embed.cpp. +namespace Steinberg { +#include "reaper_vst3_interfaces.h" +} // namespace Steinberg + +namespace reasampler::vst { + +class ReaSamplerProcessor; + +// Implements IReaperUIEmbedInterface. Lifetime is OWNED by the processor (the processor +// holds the sole unique_ptr and hands out AddRef'd references from queryInterface); the +// back-pointer to the processor is therefore always valid while this lives. +class ReaSamplerEmbed : public Steinberg::IReaperUIEmbedInterface { +public: + explicit ReaSamplerEmbed(ReaSamplerProcessor* processor) : processor_(processor) {} + + // The one embed entry point. Routes each REAPER_FXEMBED_WM_* message; see the header + // note above for the per-message contract. UI thread only. + Steinberg::TPtrInt embed_message(int msg, Steinberg::TPtrInt parm2, + Steinberg::TPtrInt parm3) override; + + // FUnknown: this object's lifetime is owned by the processor, not the host refcount, so + // AddRef/release are no-ops (the processor's unique_ptr governs destruction) and + // queryInterface answers only FUnknown + IReaperUIEmbedInterface. This mirrors how the + // SDK's OBJ refcount would otherwise churn; here the owning processor guarantees the + // object outlives every borrowed reference REAPER holds during embedding. + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, + void** obj) override; + Steinberg::uint32 PLUGIN_API addRef() override { return 1000; } + Steinberg::uint32 PLUGIN_API release() override { return 1000; } + +private: +#ifdef _WIN32 + // Draw the current strip into REAPER's supplied LICE bitmap. Returns true if it drew. + bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo); + // Handle a mouse-down inside the strip: map to a zone and select it (S6: selection at + // most — no new editing semantics). Returns true if the selection changed (the caller + // then asks REAPER to invalidate). + bool onMouseDown(Steinberg::TPtrInt drawInfo); +#endif + + // Snapshot the live bank + the instrument's performance map for the next paint, exactly + // as the editor's refreshSampleList does (bridge read + processor accessors, UI thread). + void refresh(); + + ReaSamplerProcessor* processor_ = nullptr; + // Snapshotted for the current paint (refreshed each paint off the audio thread). + std::vector samples_; + PerformanceMap map_; + std::string selectedId_; + // The zone the last click selected (mirrored to the processor's editor-shared selection + // where meaningful); -1 = none. Drives the strip's highlight. + int selectedZone_ = -1; +}; + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 9eb7a59..db7febd 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -17,6 +17,7 @@ #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "ext_keys.h" // kProjExtBanksKey (shared wire contract) #include "reasampler_editor.h" +#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) #include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser #include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) @@ -91,6 +92,23 @@ FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { return static_cast(new ReaSamplerProcessor()); } +// Out-of-line so unique_ptr sees the complete type here. +ReaSamplerProcessor::~ReaSamplerProcessor() = default; + +tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { + // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for + // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed + // shell. We own the shell (unique_ptr); the borrowed reference is valid because the + // processor outlives it. All other iids fall through to the SDK's queryInterface. + if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { + if (!embed_) embed_ = std::make_unique(this); + embed_->addRef(); + *obj = static_cast(embed_.get()); + return kResultOk; + } + return SingleComponentEffect::queryInterface(iid, obj); +} + tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { tresult result = SingleComponentEffect::initialize(context); if (result != kResultOk) return result; @@ -357,6 +375,16 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { if (inst) { inst->engine.render(ch0, static_cast(frames)); } + // Block peak (mono, pre-replicate) for the embedded strip's level indicator. A + // single scan of ch0 + one relaxed atomic store — RT-safe (no alloc/IO/lock). The + // UI thread reads it via embedActivityLevel(); a plain store is sufficient because + // the readout is advisory (no ordering dependency on other state). + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + if (a > peak) peak = a; + } + embedPeak_.store(peak, std::memory_order_relaxed); // Duplicate the mono render across the remaining output channels. for (int32 ch = 1; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index dd4efce..67dda1b 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -33,6 +33,8 @@ namespace reasampler::vst { +class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface) + // 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 @@ -59,6 +61,9 @@ struct LoadedInstrument { class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect { public: ReaSamplerProcessor() = default; + // Out-of-line so the owned ReaSamplerEmbed (held by unique_ptr, forward-declared here) + // is a complete type at the destruction point (defined in the .cpp). + ~ReaSamplerProcessor() override; // The factory create function (registered in vst_entry.cpp). static Steinberg::FUnknown* createInstance(void* /*context*/); @@ -87,6 +92,19 @@ public: // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; + // Override queryInterface to additionally expose REAPER's IReaperUIEmbedInterface (S6): + // REAPER queries the IEditController for it to drive the inline TCP/MCP embed surface. + // All other iids delegate to SingleComponentEffect's implementation unchanged. + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, + void** obj) override; + + // The embedded-strip activity level (0..1), read by the S6 embed shell on the UI thread. + // Backed by embedPeak_, the per-block mono peak the audio thread stores relaxed — a + // lock-free advisory readout, never touched with a lock the audio thread could contend. + double embedActivityLevel() const { + return static_cast(embedPeak_.load(std::memory_order_relaxed)); + } + // 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 @@ -163,6 +181,18 @@ private: // off-thread only. double sampleRate_ = 44100.0; Steinberg::int32 maxBlockSize_ = 4096; + + // --- S6 embedded TCP/MCP UI --------------------------------------------- + // The embed shell (IReaperUIEmbedInterface), created lazily on the first queryInterface + // and owned here for the processor's lifetime. REAPER borrows AddRef'd references from + // queryInterface; the shell's refcount is a no-op because THIS unique_ptr governs its + // destruction (the processor always outlives the borrowed references). + std::unique_ptr embed_; + + // The per-block mono peak (0..1+) the audio thread stores relaxed; the embed strip's + // level indicator reads it via embedActivityLevel(). Advisory only — a plain atomic, + // no ordering coupling, never guarded by a lock the audio thread touches. + std::atomic embedPeak_{0.f}; }; } // namespace reasampler::vst diff --git a/tests/test_embed_strip.cpp b/tests/test_embed_strip.cpp new file mode 100644 index 0000000..4ce3c22 --- /dev/null +++ b/tests/test_embed_strip.cpp @@ -0,0 +1,157 @@ +// Standalone tests for reasampler::vst::embed_strip — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests (editor_geometry et al.): assert the +// embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly. +// +// Covers: layoutEmbed splitting a normal area into keymap + level band, a tiny area +// (band yields to the keymap minimum, no inversion), and a zero area (all empty); +// zoneSegmentRect mapping the 128-key span linearly, tiling adjacent zones seamlessly, +// clamping out-of-range/inverted notes; zoneAtPoint hitting the covering zone, first-match +// on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list; +// levelFillRect clamping 0..1 and its endpoints. + +#include "../src/vst/embed_strip.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- layoutEmbed -------------------------------------------------------------- + +static void testLayoutNormalArea() { + // A comfortable inline strip: keymap band on top, thin level band pinned to the bottom. + const EmbedLayout L = layoutEmbed(300, 40); + CHECK(L.keymap.left == 0 && L.keymap.top == 0 && L.keymap.right == 300); + CHECK(L.levelBand.left == 0 && L.levelBand.right == 300); + // Level band is the fixed height at the very bottom; keymap fills the rest, contiguous. + CHECK(L.levelBand.height() == kEmbedLevelBandHeight); + CHECK(L.levelBand.bottom == 40); + CHECK(L.keymap.bottom == L.levelBand.top); + CHECK(L.keymap.height() == 40 - kEmbedLevelBandHeight); +} + +static void testLayoutTinyAreaKeepsKeymap() { + // A very short area: the level band must yield so the keymap keeps its minimum, and no + // rect inverts. + const EmbedLayout L = layoutEmbed(300, 8); + CHECK(L.keymap.height() >= 0); + CHECK(L.levelBand.height() >= 0); + CHECK(L.keymap.bottom == L.levelBand.top); + CHECK(L.levelBand.bottom == 8); + // The keymap is not starved below its floor when the area allows it. + CHECK(L.keymap.height() >= kEmbedKeymapMinHeight || 8 < kEmbedKeymapMinHeight); +} + +static void testLayoutZeroArea() { + const EmbedLayout L = layoutEmbed(0, 0); + CHECK(L.keymap.width() <= 0 && L.keymap.height() <= 0); + CHECK(L.levelBand.width() <= 0 && L.levelBand.height() <= 0); + // Negative dimensions clamp to a zero-area, non-inverted rect. + const EmbedLayout N = layoutEmbed(-50, -50); + CHECK(N.keymap.right >= N.keymap.left && N.keymap.bottom >= N.keymap.top); +} + +// --- zoneSegmentRect ---------------------------------------------------------- + +static void testZoneSegmentFullSpan() { + // A zone covering the whole keyboard spans the entire keymap band width. + const EmbedLayout L = layoutEmbed(256, 40); + const Rect r = zoneSegmentRect(L, 0, 127); + CHECK(r.left == L.keymap.left); + CHECK(r.right == L.keymap.right); + CHECK(r.top == L.keymap.top && r.bottom == L.keymap.bottom); +} + +static void testAdjacentZonesTileSeamlessly() { + // 256px band, 128 keys -> 2px/key. Zones 0..59 and 60..127 must abut with no gap or + // overlap: the low zone's right == the high zone's left. + const EmbedLayout L = layoutEmbed(256, 40); + const Rect lo = zoneSegmentRect(L, 0, 59); + const Rect hi = zoneSegmentRect(L, 60, 127); + CHECK(lo.left == L.keymap.left); + CHECK(hi.right == L.keymap.right); + CHECK(lo.right == hi.left); // seamless tile — the load-bearing assertion + CHECK(lo.right == L.keymap.left + 60 * 2); // 60 keys * 2px +} + +static void testZoneSegmentClampsBadNotes() { + const EmbedLayout L = layoutEmbed(256, 40); + // Out-of-range notes clamp into the band; an inverted zone (low > high) collapses to a + // zero-or-positive-width rect, never inverts. + const Rect over = zoneSegmentRect(L, -10, 200); + CHECK(over.left == L.keymap.left && over.right == L.keymap.right); + const Rect inv = zoneSegmentRect(L, 100, 20); + CHECK(inv.right >= inv.left); +} + +// --- zoneAtPoint -------------------------------------------------------------- + +static void testZoneAtPointHits() { + const EmbedLayout L = layoutEmbed(256, 40); + const EmbedZone zones[2] = {{0, 59}, {60, 127}}; + // A point inside the low zone's segment resolves to zone 0; inside the high zone, 1. + const Rect lo = zoneSegmentRect(L, 0, 59); + const Rect hi = zoneSegmentRect(L, 60, 127); + const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + CHECK(zoneAtPoint(L, zones, 2, lo.left + 1, yMid) == 0); + CHECK(zoneAtPoint(L, zones, 2, hi.right - 1, yMid) == 1); +} + +static void testZoneAtPointFirstMatchOnOverlap() { + const EmbedLayout L = layoutEmbed(256, 40); + // Two overlapping zones; the FIRST in order must win the contested keys. + const EmbedZone zones[2] = {{0, 127}, {40, 80}}; + const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + const Rect contested = zoneSegmentRect(L, 40, 80); + CHECK(zoneAtPoint(L, zones, 2, contested.left + 1, yMid) == 0); // zone 0 wins +} + +static void testZoneAtPointMisses() { + const EmbedLayout L = layoutEmbed(256, 40); + const EmbedZone zones[1] = {{60, 72}}; // a narrow zone; most keys uncovered + const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + // A key left of the zone is uncovered -> -1. + CHECK(zoneAtPoint(L, zones, 1, L.keymap.left + 1, yMid) == -1); + // A point in the level band (below the keymap) is off the keymap -> -1. + CHECK(zoneAtPoint(L, zones, 1, L.levelBand.left + 4, L.levelBand.top) == -1); + // Empty / null list -> -1. + CHECK(zoneAtPoint(L, zones, 0, L.keymap.left + 1, yMid) == -1); + CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.left + 1, yMid) == -1); +} + +// --- levelFillRect ------------------------------------------------------------ + +static void testLevelFillClamps() { + const EmbedLayout L = layoutEmbed(200, 40); + // Zero / negative -> empty. + CHECK(levelFillRect(L, 0.0).width() <= 0); + CHECK(levelFillRect(L, -1.0).width() <= 0); + // Full / over-full -> the whole band width. + CHECK(levelFillRect(L, 1.0).width() == L.levelBand.width()); + CHECK(levelFillRect(L, 5.0).width() == L.levelBand.width()); + // Half -> ~half the band, pinned to the band's left and vertical extent. + const Rect half = levelFillRect(L, 0.5); + CHECK(half.left == L.levelBand.left); + CHECK(half.top == L.levelBand.top && half.bottom == L.levelBand.bottom); + CHECK(half.width() == L.levelBand.width() / 2); +} + +int main() { + testLayoutNormalArea(); + testLayoutTinyAreaKeepsKeymap(); + testLayoutZeroArea(); + testZoneSegmentFullSpan(); + testAdjacentZonesTileSeamlessly(); + testZoneSegmentClampsBadNotes(); + testZoneAtPointHits(); + testZoneAtPointFirstMatchOnOverlap(); + testZoneAtPointMisses(); + testLevelFillClamps(); + + if (g_fail == 0) std::printf("embed_strip: all tests passed\n"); + else std::printf("embed_strip: %d FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} From 5453f4966c9b49f607d9a0e4fbdaf063512f38d0 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 18:20:41 -0400 Subject: [PATCH 13/49] fix(embed): WM_GETMINMAXINFO size hints, remove dead selectedId_, fix selectedZone_ comment, decay embedPeak_ on early returns --- src/vst/reasampler_embed.cpp | 18 +++++++++++++++--- src/vst/reasampler_embed.h | 6 +++--- src/vst/reasampler_processor.cpp | 2 ++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp index 959ed8c..43d6e30 100644 --- a/src/vst/reasampler_embed.cpp +++ b/src/vst/reasampler_embed.cpp @@ -82,14 +82,12 @@ void ReaSamplerEmbed::refresh() { if (!processor_) { samples_.clear(); map_.zones.clear(); - selectedId_.clear(); selectedZone_ = -1; return; } auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); samples_ = banks ? listSamples(*banks) : std::vector{}; map_ = processor_->performanceMap(); - selectedId_ = processor_->selectedSampleId(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; } @@ -106,6 +104,20 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) { return 0; case REAPER_FXEMBED_WM_DESTROY: return 0; + case REAPER_FXEMBED_WM_GETMINMAXINFO: { + auto* hints = reinterpret_cast(parm3); + if (!hints) return 0; + // Minimum usable strip height: the keymap must not collapse below its floor + // (kEmbedKeymapMinHeight) plus the level band. + hints->min_width = 64; + hints->max_width = 0; // 0 = unconstrained + hints->min_height = kEmbedKeymapMinHeight + kEmbedLevelBandHeight; + hints->max_height = 0; // 0 = unconstrained + // Preferred aspect: wide strip, roughly 8:1 (w:h). 16.16 fixed point. + hints->preferred_aspect = (8 << 16) / 1; + hints->minimum_aspect = (4 << 16) / 1; + return 1; + } #ifdef _WIN32 case REAPER_FXEMBED_WM_PAINT: return paint(parm2, parm3) ? 1 : 0; @@ -114,7 +126,7 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) { return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0; #endif default: - return 0; // unhandled messages (cursor, wheel, hittest, minmax) fall through + return 0; // unhandled messages (cursor, wheel, hittest) fall through } } diff --git a/src/vst/reasampler_embed.h b/src/vst/reasampler_embed.h index b4bad22..8db5d0b 100644 --- a/src/vst/reasampler_embed.h +++ b/src/vst/reasampler_embed.h @@ -90,9 +90,9 @@ private: // Snapshotted for the current paint (refreshed each paint off the audio thread). std::vector samples_; PerformanceMap map_; - std::string selectedId_; - // The zone the last click selected (mirrored to the processor's editor-shared selection - // where meaningful); -1 = none. Drives the strip's highlight. + // The zone the last click selected (local/visual only — S6 selection constraint; the + // processor's editor-shared selection is NOT updated from here); -1 = none. + // Drives the strip's highlight. int selectedZone_ = -1; }; diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index db7febd..fc00d58 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -348,6 +348,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { + embedPeak_.store(0.f, std::memory_order_relaxed); return kResultOk; } AudioBusBuffers& out = data.outputs[0]; @@ -356,6 +357,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // 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) { + embedPeak_.store(0.f, std::memory_order_relaxed); for (int32 ch = 0; ch < out.numChannels; ++ch) { if (double* buf = out.channelBuffers64[ch]) { for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; From fdcb2db04c2148311666941835da342227f2c892 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 18:12:05 -0400 Subject: [PATCH 14/49] docs(plan): tick S5 checkboxes; reconcile performance-map persistence wording to component state --- PLAN.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/PLAN.md b/PLAN.md index e100ac6..5702fc6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -223,15 +223,15 @@ instrument (performance map) while root notes come from the bank intrinsics (S2) editing the keymap does not touch the bank. **Depends on:** S4. -- [ ] Keymap editor in the `IPlugView` LICE editor: assign bank samples to key ranges +- [x] Keymap editor in the `IPlugView` LICE editor: assign bank samples to key ranges (low/high note per sample), each with its own root note (from S2 intrinsics, overridable in the performance map). -- [ ] Tier-1 playback: zoned resolution — a note picks its zone's sample and repitches +- [x] Tier-1 playback: zoned resolution — a note picks its zone's sample and repitches from that sample's root note; one sample per key-region. -- [ ] Performance-map persistence: the keymap (zones, per-sample assignment) is the - instrument's own state — held in the instrument (read/written over the live - `"reasampler"` seam per D-B's data-ownership split), never written back as a bank - intrinsic. +- [x] Performance-map persistence: the keymap (zones, per-sample assignment) is the + instrument's own state — held in the instrument as VST3 component state (setState/getState) + per D-B's data-ownership split; the live `"reasampler"` seam is read-only (bank + + intrinsics in, nothing written back), never written back as a bank intrinsic. ## S6 — embedded TCP/MCP UI (D-D — scheduled in-phase, after the editor) **Goal:** Render a compact keymap/level strip **inline in the track/mixer control From e9498c42900a62aa673b0b15455e834563ac7f0a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 18:24:57 -0400 Subject: [PATCH 15/49] docs(plan): tick S6 checkboxes; mark IReaperUIEmbedInterface must-verify as verified-by-S6 --- PLAN.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/PLAN.md b/PLAN.md index 5702fc6..0d618d8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -249,9 +249,9 @@ as the main editor is reused. build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`. -- [ ] Implement `IReaperUIEmbedInterface` on the VST3; draw a compact keymap/level +- [x] Implement `IReaperUIEmbedInterface` on the VST3; draw a compact keymap/level strip inline in the TCP/MCP using the same LICE surface as the editor. -- [ ] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects +- [x] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects the live keymap/levels. ## S7 — stereo channel mode (mono | stereo; core channel dimension + bus negotiation) @@ -495,8 +495,11 @@ artifact, not extension-only. path is `IReaperHostApplication::getReaperApi` (resolve-by-name) and `getReaperParent(3)` (host context) via `reaper_vst3_interfaces.h`; the `0xdeadf00d`/`0xdeadf00e` opcodes are VST2-only and do not apply. -- **`IReaperUIEmbedInterface`** — embed contract + message/lifecycle, against - `reaper_plugin_fx_embed.h` (needed only at S6). +- **`IReaperUIEmbedInterface`** — ~~embed contract + message/lifecycle, against + `reaper_plugin_fx_embed.h` (needed only at S6).~~ **verified (S6):** interface + exposed via `queryInterface` on the controller; inline strip drawn into REAPER's + embed bitmap (LICE idiom, no HWND); `WM_GETMINMAXINFO` size hints confirmed; + embed open/close/resize lifecycle clean. - **VST3 bus arrangement (S7)** — `setBusArrangements` / `getBusArrangement` and REAPER's mono/stereo instrument-bus expectations, against the vendored Steinberg SDK + `reaper_vst3_interfaces.h`. The channel-mode toggle depends on the output bus From dc5d36ff4321c4452731e8a6db005d28706cfa24 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 18:46:17 -0400 Subject: [PATCH 16/49] =?UTF-8?q?fix(vst):=20channel-derive=20the=20shared?= =?UTF-8?q?=20ext-state=20namespace=20(V4=E2=86=94S4=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ext_keys.h's kProjExtNamespace now delegates to app_version::extStateNamespace() so the beta instrument reads "reasampler_beta" — the namespace the beta extension writes — instead of stale stable. Link app_version into reasampler_vst. --- CMakeLists.txt | 5 ++++- src/ext_keys.h | 20 +++++++++++++++----- src/vst/reaper_bridge.cpp | 12 +++++++----- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5da172f..0cd069d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -829,8 +829,11 @@ if(WIN32) # give the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). # embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals # into; it links editor_geometry transitively (shared Rect). + # app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so + # the instrument reads the SAME namespace the extension writes; its PUBLIC include dir + # (build/generated) carries version_generated.h for the channel bit. target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sample_map capture_paths embed_strip) + sample_map capture_paths embed_strip app_version) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/ext_keys.h b/src/ext_keys.h index 66ef9a2..9461094 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -5,18 +5,28 @@ // 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. +// PURE HEADER: NO REAPER types, NO VST3 types, NO SWELL, NO vendor/ includes. The key +// spellings are string constants; the NAMESPACE is channel-derived (Phase V, V4) so it +// delegates to the pure app_version module (also REAPER-free / VST3-free). Both the +// REAPER-facing persist shell and the SDK-facing VST bridge include this 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. +#include "app_version.h" + namespace reasampler { -// The ext-state namespace all ReaSampler project state is stored under. -inline constexpr const char* kProjExtNamespace = "reasampler"; +// The ext-state namespace all ReaSampler project state is stored under. CHANNEL-DERIVED +// (Phase V, V4): delegates to the ONE app_version symbol so the extension (writer) and the +// VST3 instrument (reader) resolve the SAME namespace per channel — "reasampler" on stable, +// "reasampler_beta" on the isolated beta build. An accessor (not a constexpr literal) +// because the value is fixed by the channel bit at build time. This is the wire-contract +// reconciliation between S4 (shared ext_keys) and V4 (channel-isolated namespace): without +// it a beta instrument would read the stable namespace and see empty state. +inline const char* kProjExtNamespace() { return extStateNamespace().c_str(); } // 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 diff --git a/src/vst/reaper_bridge.cpp b/src/vst/reaper_bridge.cpp index f58e567..5df2429 100644 --- a/src/vst/reaper_bridge.cpp +++ b/src/vst/reaper_bridge.cpp @@ -29,10 +29,12 @@ namespace Steinberg { // interface (FUnknownPtr uses the iid), so the definition lives with its sole use. DEF_CLASS_IID(Steinberg::IReaperHostApplication) -// The "reasampler" ext-state namespace is the SHARED wire contract between the -// extension (writer) and this instrument (reader); it lives in ext_keys.h (pure, -// REAPER-free) — reasampler::kProjExtNamespace — so the two artifacts read one symbol -// and cannot drift. The S1 spike duplicated it locally; that duplication is retired. +// The ext-state namespace is the SHARED wire contract between the extension (writer) +// and this instrument (reader); it lives in ext_keys.h (pure, REAPER-free) — +// reasampler::kProjExtNamespace() — so the two artifacts read one symbol and cannot +// drift. Channel-derived (Phase V, V4): the accessor returns "reasampler" (stable) or +// "reasampler_beta" (beta), matching whatever the extension wrote. The S1 spike +// duplicated it locally; that duplication is retired. namespace reasampler::vst { @@ -83,7 +85,7 @@ std::optional ReaperBridge::readReasamplerExtState(const std::strin // complete, else grow and retry up to a 16 MB ceiling. for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { std::vector buf(static_cast(cap), '\0'); - const int rv = getProjExtState_(proj, kProjExtNamespace, key.c_str(), + 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()); From 8c9cf38780f76bfff14635447d856cd9f5840c4f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 19:03:26 -0400 Subject: [PATCH 17/49] docs(product): scope ReaSampler 9000 UX overhaul (S10-S13) + record instrument name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-DAW-test: name the instrument ReaSampler 9000; spec the better-than-RS5K overhaul — S10 keyboard-strip keymap editor, S11 waveform + draggable loop points, S12 scroll/search/direct-entry/ADSR, S13 drop-to-load via the S8 relay. VST3 class UID locked unchangeable; S-NAME-1 (binary filename) is Daniel's call. --- CONTEXT.md | 98 ++++++++++++++++++++ PLAN.md | 162 ++++++++++++++++++++++++++++++++++ docs/product/midi-playback.md | 73 +++++++++++++-- 3 files changed, 328 insertions(+), 5 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 69dae11..9e85295 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1392,6 +1392,104 @@ without re-opening each editor. The missing trigger: a **bank-generation counter This seam serves **both** S8 ingest and M10 recapture; the writer side is extension-only and independent of S8, so it can land alongside either. +## ReaSampler 9000 — the UX overhaul (S10–S13; DAW-tested S1–S6, "the UX is awful") + +**The bar is set: better than ReaSamplOMatic5000.** Daniel DAW-tested the S1–S6 +instrument and the verdict was that it *works* but the UX is unacceptable — "this is +supposed to be better than ReaSamplOMatic5000." The S1–S6 editor was a spike-grade LICE +panel: a clickable sample list, zone rows each carrying **seven tiny ±1 nudge/delete +mini-buttons** (low-/low+/high-/high+/root-/root+/delete), text-only labels, **no keyboard +visualization, no waveform, no drag interaction of any kind, no scrolling** for long lists. +Setting a zone from C1 to C4 by clicking "+" thirty-six times is the catastrophe; the rest +(no way to *see* a sample, no loop editing by eye, unreachable rows past the panel bottom, +a fixed envelope) compound it. The overhaul is scoped as **S10–S13**, sequenced so the +friction Daniel feels every test pass is removed first. + +**What "better than RS5K" means, specifically (not vibes).** RS5K's genuine strengths — +match or beat each: (1) **drag a file straight onto it** loads the sample (our S13 relay); +(2) **note-start / note-end** range with a visual sense of the keyboard (our S10 keyboard +strip — RS5K's own range UI is two number fields, so a *draggable* strip beats it); (3) a +**waveform** with draggable start/end/loop markers (our S11); (4) **ADSR** sliders (our +S12); (5) velocity layers / round-robin (Tier 2 — held, not in this overhaul). RS5K's real +**weaknesses are our opening:** its **one-sample-per-instance** model forces track sprawl +(one RS5K per drum) and it has **no multi-zone view in a single instance** — ReaSampler +9000 is multi-zone in one instrument by design (S5), so the keyboard-strip editor showing +*all* zones at once is a capability RS5K structurally lacks. "Better than RS5K" = +direct-manipulation where RS5K uses number fields, multi-zone where RS5K is one-shot, and +bank-integrated ingest where RS5K is file-at-a-time. + +**Constraints (unchanged — settled, do not re-open):** LICE/SWELL drawing only (no toolkit +change — D-A settled); **all layout/hit-test math in pure geometry modules** (mirror of +`mode_switch` / `editor_geometry` / `embed_strip`), the draw + drag-state machine in the +shell; RT discipline untouched (every edit commits **off** the audio thread via the +existing `commitMapAndReload` → off-thread `reloadFromBank` → atomic swap); the instrument +stays a **read-only bank consumer** (loop/root/ADSR edits are the instrument's *performance +map*, D-B — never written back to the bank); component-state persistence and +read-only-over-bank stay settled. + +- **S10 — direct-manipulation keymap editor.** A **piano-keyboard strip** along the top; + each zone a bar over the keys it covers. **Drag an edge** → change low/high note; **drag + the bar body** → move the zone (span preserved); **click a key** → set/relocate the + zone's root. Retires the seven ±1 nudge buttons per row. New pure `keyboard_strip` + geometry module (key-span↔pixel reusing the `embed_strip` idiom; edge-grab vs. body-move + hit regions; `pixel→note`; drag-delta resolver). Shell extends the click-only `wndProc` + to a `WM_MOUSEMOVE`/`WM_LBUTTONUP` drag-state machine with live feedback, one coherent + edit on release. +- **S11 — waveform view + draggable loop points.** Selecting a zone shows its sample's + **waveform** (peaks via the existing `peaks` module over the shell's already-decoded PCM + — no new decode/WAV path) with draggable **start/end/loop-start/loop-end** markers that + **snap to zero-crossings** (the S2 zero-crossing-aware requirement). A dragged loop is a + **per-zone loop override** (additive on `PerformanceZone`, same shape as `rootOverride`; + seeded from the S2 bank intrinsic, never written back). Marker/waveform geometry pure + (`frame↔pixel`, marker grab regions, clamp start≤end, zero-crossing snap helper). +- **S12 — scale + ergonomics.** The sample list **scrolls** (wheel + scrollbar) so a long + bank is fully reachable, and has a **type-to-filter search**. **Direct numeric entry** + for zone low/high/root (a click-to-type field over the strip, for precision the drag + can't hit). An **ADSR editor** — four draggable controls over the S3 `AdsrParams` (the + math already exists and is wired into the voice engine; today the envelope is a fixed + default). Scroll/search/slider/entry layout pure; ADSR + (implicitly) any exposed + parameters become per-instance component state (additive, version-bumped, back-compat). +- **S13 — drop-to-load (the S8 relay, in the editor).** Dropping an OS file / media item + **onto the editor window** ingests into the bank + assigns to this instance — the RS5K + "drop a file straight on it" affordance. **The instrument does not ingest:** the editor's + drop handler **relays a bank-ingest request to the extension** (S8's `option 1`), which + performs the capture/import + assign; refresh is hands-free via S9 (or a direct reload + without it). **Cross-artifact relay is the S8-flagged spike** — proven-and-shipped or + degrade to the docked-`bank_panel` drop path with a clear affordance. Never inserts a + timeline item (capture/placement separation intact). + +**Sequencing (recommendation, argued below in this section's tail).** S10 first — the +nudge-button zone editor is the friction Daniel hits on *every* test pass, so removing it +buys the most felt improvement per unit work and de-risks the drag-state machine S11/S12 +reuse. S11 and S12 follow (both lean on S10's drag machine). S13 depends on S8's ingest +seam, so it sequences after S8. Against the queued engine work: **S10 should land before or +interleaved with S7 (stereo).** S7 is a real engine capability (stereo capture in true +stereo) and touches the DSP Daniel smoke-tests — but the *reason* he'll keep smoke-testing +is the editor, and today every test pass is taxed by the nudge-button UX. Fixing what he +feels first (S10) makes every subsequent S7 test less painful; there is no hard dependency +either way (S7 is engine/bus, S10 is editor/geometry — orthogonal). Honest counter: if the +stereo *sound* is the thing blocking real use, S7 first is defensible — but "it works, the +UX is awful" points at the editor as the live wound, so **S10 leads.** + +## Product name — ReaSampler 9000 (Daniel, 2026-07-26) + +The MIDI-playback instrument's product name is **ReaSampler 9000**. The extension stays +**ReaSampler** (capture + organization); the instrument is **ReaSampler 9000** (playback). +Set by Daniel on DAW-testing the S1–S6 instrument, alongside the UX-overhaul directive. + +- **Propagate the display name** across user-visible surfaces: the VST3 class **display + name** string in the factory registration, the `IPlugView` editor **title band** + (currently "ReaSampler Instrument"), the **S6 embed-strip label**, and the Phase S docs. +- **Do NOT change the VST3 class UID.** Instances in already-saved projects key off the + class UID; changing it orphans every existing instance in every saved project. The UID is + a forever-stable contract (mirror of the command-id / ext-state-namespace forever-stable + strings). The name change is **display-string-only** on the code side. +- **Fork S-NAME-1 (Daniel's call): binary filename.** Renaming the built module (e.g. + `reasampler_9000.vst3`) carries compat weight — REAPER partly keys a saved project's + plugin reference by filename, so a rename can force users to re-select the plugin on + existing instances. **Lean: keep the filename, change only display strings** (zero compat + weight; the name shows everywhere the user reads it). Flagged for Daniel, not decided. + ## REAPER / Steinberg API surface (verify all signatures) - **VST3 SDK (a new vendored dependency — vendor it at the spike).** `FUnknown` and the diff --git a/PLAN.md b/PLAN.md index 0d618d8..5db07fc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -383,6 +383,168 @@ alongside S8. stale value by design — it reloads on the *next* poll; confirm no torn-read hazard for the single integer generation key). +## S10 — direct-manipulation keymap editor ("ReaSampler 9000" UX overhaul, part 1) +**Goal:** Replace the ±1 nudge-button zone editor with a **piano-keyboard-strip zone +editor** where the keymap is edited by direct manipulation: a horizontal keyboard span +along the top, each zone drawn as a bar over the keys it covers; **drag a zone's edge to +change its low/high note, drag the bar's body to move the whole zone, click a key (or the +zone's root marker) to set the root note.** No number is ever set by clicking a button 40 +times. This is the friction Daniel feels every test pass — the current row of seven +mini-buttons per zone is the catastrophe. All layout/hit-test math is a **new pure +geometry module** (`keyboard_strip` — mirror of `mode_switch`/`editor_geometry`); the LICE +draw + drag-state machine is the editor shell. RT discipline untouched (edits still commit +off-thread via `commitMapAndReload`). CONTEXT.md §Phase S (ReaSampler 9000 UX — keymap +editor). +**Verify (in DAW):** a zone's range is set by **dragging its edges on the keyboard strip** +(not by nudge clicks); dragging a zone's body moves the whole range preserving its span; +clicking a key sets/relocates the zone's root (with the repitched result audible on the +next held note); overlapping zones render legibly and resolve first-match as before; +zone add/select/delete still work; the pure geometry module is CTest-green (edge-grab +regions, body-drag delta, key→note mapping, root hit-test) with no host types at its +boundary; the ±1 nudge-button row is gone. +**Depends on:** S5 (the `PerformanceMap`/zone model it edits), S1 (the LICE `IPlugView` +drag/event routing — extends the click-only `wndProc` to `WM_MOUSEMOVE`/`WM_LBUTTONUP`). + +- [ ] Pure `keyboard_strip` geometry module: map a MIDI key span across a strip width + (128 keys → pixels, reusing the S6 `embed_strip` key-span idiom); per-zone bar rect from + `[lowNote,highNote]`; **edge-grab hit regions** (a few px inside each end = resize handles, + the middle = move handle); `pixel→note` and a `keyAtPoint` for click-to-set-root; a + drag-delta resolver `(grabbedField, startNote, dxPixels) → newNote`. No VST3/REAPER/LICE + types at the boundary; unit-tested outside the DAW (edge grabs, body-move delta, key + mapping, clamps low≤high, boundary rounding). Mirror of `mode_switch`/`editor_geometry`. +- [ ] Editor shell: draw the keyboard strip + zone bars in LICE (house palette), and drive + a **drag-state machine** — `WM_LBUTTONDOWN` grabs an edge/body/key, `WM_MOUSEMOVE` + updates the in-flight zone against the pure resolver, `WM_LBUTTONUP` commits via the + existing `commitMapAndReload` (off-thread reload; RT path untouched). Live visual feedback + while dragging; a single undo-coherent edit on release. +- [ ] Click-to-set-root + zone move/resize replace the seven per-row nudge/delete + mini-buttons; delete stays as one affordance (a small × on the zone bar or a keystroke). + The `zoneHitTest`/±1 nudge path in `editor_geometry` is retired for the new strip (kept + only if a numeric fallback is wanted for accessibility — build-time residual, not a fork). + +## S11 — waveform view with draggable loop points (UX overhaul, part 2) +**Goal:** Give each sample/zone a **waveform display** with **draggable start/end/loop +markers** — the S2 loop-point intrinsics and the S5 performance map already carry the data; +today there is no way to *see* a sample or *set* its loop by eye. Selecting a zone (or a +bank sample) shows its waveform (peaks via the existing `peaks` module, fed the decoded +PCM the shell already loads); drag the **loop-start / loop-end** markers to set the sustain +loop, snapping to zero-crossings (the S2 spec's zero-crossing-aware requirement). Loop +points are a **performance-map override on the zone** where set, seeded from the bank +intrinsic (D-B split: the bank carries the file-fact default; the instrument's drag is the +performance choice). All marker/waveform layout + hit-test is pure geometry; peaks compute +reuses `peaks`; the draw + drag is the shell. CONTEXT.md §Phase S (ReaSampler 9000 UX — +waveform view). +**Verify (in DAW):** selecting a zone shows its sample's waveform; dragging the loop-start +and loop-end markers sets the sustain loop and a held note audibly loops that region; +markers snap to the nearest zero-crossing; a sample with no loop shows the "no loop" state +and a held note past the end goes silent (existing core behavior); the waveform peaks match +the audio (mirror of the `peaks` envelope assertion); the marker geometry module is +CTest-green (px↔frame mapping, marker grab regions, clamp start≤end). +**Depends on:** S2 (loop-point intrinsics), S3 (loop-aware sustain the markers drive), S5 +(the zone the loop attaches to), S10 (shares the editor's drag-state machine + shell). The +zero-crossing snap is a small pure helper over the decoded PCM. + +- [ ] Pure waveform/marker geometry: `frame↔pixel` mapping across the waveform rect, marker + x-position from a frame index, marker grab regions (start/end/loop-start/loop-end), + drag-delta `(grabbedMarker, dxPixels) → newFrame` with clamps (start≤end, in-bounds). A + **zero-crossing snap** helper: nearest sign-change frame to a target (pure, over the + decoded mono PCM). No host types; unit-tested. +- [ ] Waveform draw: compute peaks with the existing `peaks` module from the shell's already- + decoded PCM (no new decode path, no new WAV reader); draw the envelope in LICE in the + house style; draw the loop markers over it. Reuses the S10 drag-state machine. +- [ ] Loop-point edit → performance-map override: a dragged loop writes a per-zone loop + override (seeded from the S2 bank intrinsic, D-B), committed off-thread via + `commitMapAndReload`; the bank intrinsic is never written back (instrument is a read-only + bank consumer). Extends `PerformanceZone` with an optional loop override (additive, same + shape as `rootOverride`) + its component-state (de)serialize (version bump, back-compat + with S5's v2 map blob — a truncated/older blob defaults the override absent). + +## S12 — editor scale + ergonomics (UX overhaul, part 3; scrollable/searchable list, direct entry) +**Goal:** Make the editor usable **at bank scale** and close the remaining RS5K-parity +gaps: the sample list **scrolls** (today a long bank's rows run off the panel with no way +to reach them) and has a **type-to-filter search**; add **direct numeric entry** for a +zone's low/high/root (a click-to-type field over the strip, for precision the drag can't +hit) and an **ADSR control** for the amp envelope (S3 already has the ADSR math; today it +is fixed — expose attack/decay/sustain/release as draggable sliders, per-instance state). +This is the "sensible list handling + direct manipulation of the parameters that exist" +tier. All slider/scroll/search-box layout + hit-test is pure geometry; the shell draws + +routes; ADSR/scroll/filter state is instrument-owned (component state / transient UI +state). CONTEXT.md §Phase S (ReaSampler 9000 UX — scale + ergonomics). +**Verify (in DAW):** a bank with more samples than fit **scrolls** (wheel + drag) and every +sample is reachable; typing filters the list to matching names; a zone's low/high/root can +be **typed** (not only dragged) via a click-to-edit field; the amp envelope's ADSR is +**adjustable** (four draggable controls) and the change is audible + persists across project +save/reopen (component state); the scroll/search/slider geometry is CTest-green. +**Depends on:** S10 (the editor shell + drag-state machine), S3 (the `AdsrParams` the ADSR +sliders drive — already wired into the voice engine; today they are fixed defaults), S5 +(the map the numeric fields edit). + +- [ ] Scrollable, searchable sample list: a scroll offset (wheel + scrollbar drag) so long + banks are fully reachable; a type-to-filter search that narrows the drawn rows to matching + display names. Scroll/filter layout + hit-test is pure geometry (visible-row window, + scrollbar thumb rect, search-box rect); filter/scroll state is transient UI state. +- [ ] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip + (LICE text-entry idiom or a SWELL edit control on the child HWND) so a precise note can be + typed, not only dragged. Commits via `commitMapAndReload` like every other edit. +- [ ] ADSR editor: four draggable controls (attack/decay/sustain/release) over the S3 + `AdsrParams`; per-instance component state (additive to the map/selection blob, version- + bumped, back-compat defaults to the current fixed envelope). Slider layout/hit-test pure; + the audible envelope change goes through the same off-thread reload. + +## S13 — drop-to-load: the S8 ingest story, folded into the editor UX (UX overhaul, part 4) +**Goal:** Make "load a sample into the sampler" **one gesture from the editor**: dropping +an OS file (or REAPER media item) **onto the editor window** ingests it into the bank and +assigns it to this instance — the RS5K "drag a file straight onto it" affordance, which is +the single biggest first-impression win RS5K has and we currently lack. This is the +**editor-window end of S8's `option 1` ingest** — the instrument does **not** ingest itself +(it stays a read-only bank consumer); the editor's drop handler **relays a bank-ingest +request to the extension** over the agreed cross-artifact seam, and the extension performs +the capture/import-into-bank + assign. S8 already flags drop-onto-editor as an unproven +cross-artifact **spike** — this point is where that spike is either proven and shipped or +falls back to the docked-panel drop path. CONTEXT.md §Phase S (ReaSampler 9000 UX — drop to +load / S8 relay). **Cross-artifact relay is a spike, not a promise — sequence after S8.** +**Verify (in DAW):** an OS file dropped onto the ReaSampler 9000 editor window ingests into +the active bank and this instance plays it (via the S8 capture/import add-path + S9 refresh, +or a direct reload if S9 absent) — **never** inserting a timeline item (capture/placement +separation intact); the instrument itself performs no capture/import/write (the editor only +*relays* the request to the extension); if the cross-artifact relay proves unworkable, the +drop-onto-docked-`bank_panel` path (S8) is the shipped ingest and this degrades cleanly with +a clear affordance pointing there. +**Depends on:** S8 (owns the extension-side capture/import + assign, and the relay seam), S1 +(the editor HWND that accepts the drop), S9 (hands-free refresh after assign; functional +without it via a direct reload). **Spike — do not promise the drop-onto-editor path until +the relay is proven.** + +- [ ] Editor-window drop target: accept `WM_DROPFILES`/`IDropTarget` on the editor child + HWND (the same SWELL/Win32 surface `bank_panel` owns), extracting the dropped file + path(s). Windows-only (D5). This is the *acceptance* half; the ingest is the extension's. +- [ ] Cross-artifact ingest relay (the S8-flagged spike): the editor hands the dropped + path + this instance's identity to the extension as a **bank-ingest request** over the + agreed seam (the instrument never writes the bank). **Prove the relay mechanism before + promising it**; if gnarly, fall back to the S8 docked-panel drop path and mark + drop-onto-editor deferred. +- [ ] UX degrade path: when the relay is unavailable/unproven, the editor shows a clear + "drop files on the ReaSampler panel to add" affordance rather than silently swallowing the + drop — the shipped ingest gesture stays discoverable either way. + +## Phase S — product name (ReaSampler 9000) +The MIDI-playback instrument's product name is **ReaSampler 9000** (Daniel, 2026-07-26, +on DAW-testing the S1–S6 instrument). The extension remains **ReaSampler**; the instrument +is **ReaSampler 9000**. Framing + propagation surfaces: +`docs/product/midi-playback.md` §Product name. + +- [ ] Propagate the display name **ReaSampler 9000** across user-visible surfaces: the VST3 + class **display name** string (in the factory registration), the `IPlugView` editor title + band (currently "ReaSampler Instrument"), the S6 embed-strip label, and the Phase S docs. + **Do NOT change the VST3 class UID** — instances in already-saved projects key off it; a + UID change orphans every existing instance (fork below). +- [ ] **Fork S-NAME-1 (Daniel's call): the binary filename.** Renaming the built VST3 + module (e.g. `reasampler_9000.vst3`) is a compat break — REAPER keys a saved project's + plugin reference partly by filename, so a rename can require users to re-select the plugin + on existing instances. Lean: **keep the current filename, change only display strings** + (zero compat weight, the name shows everywhere the user reads), unless Daniel wants the + on-disk name to match. Flag, do not decide. + ## S17 — drop-and-load: drag a capture onto a track's FX button → instantiate ReaSampler 9000 with the capture loaded **Goal:** Turn a bank capture into a playable instrument in one gesture. Today a drag out of the `bank_panel` becomes an OS file drag once it leaves the panel (M11 — diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index 0632ff2..ba9ed41 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -7,7 +7,10 @@ the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN *why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled decision record). -Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r4)**. The +Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r5)**. r5 records +the post-DAW-test directives on the S1–S6 instrument: the product name **ReaSampler 9000** +and the **"better than RS5K" UX overhaul** (Phase S points S10–S13) — see the Addendum in +§4. r4 (below) settled the four residual forks D-A..D-D. The "no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D" collides with the existing Design View phase). **PLAN.md §Phase S is now the @@ -658,6 +661,56 @@ with the honest SDK reality verified against the vendored headers: ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both are honestly-flagged as spikes in PLAN.md S8, not promised. +### Addendum — product name + UX overhaul (Daniel, 2026-07-26, post-S1–S6 DAW test) + +Daniel DAW-tested the S1–S6 instrument and set two directives. These are **settled +directions**, specced as new Phase S points (S10–S13) and a product-name convention — not +open forks (the two flagged forks below are the only calls left to Daniel). + +**The instrument's product name is `ReaSampler 9000`.** The extension stays **ReaSampler** +(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is +a checklist item (PLAN.md §Phase S — product name; CONTEXT.md §Product name): the VST3 +class **display name** string, the `IPlugView` editor title band (today "ReaSampler +Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-bearing):** the +**VST3 class UID must NOT change** — instances in saved projects key off it; a UID change +orphans every existing instance. The name change is **display-string-only** on the code +side. *Fork S-NAME-1 (Daniel's call):* the on-disk **binary filename** — renaming it +(`reasampler_9000.vst3`) carries compat weight (REAPER keys a saved project's plugin +reference partly by filename), so the lean is **keep the filename, change only display +strings**; flagged, not decided. + +**The UX bar is "better than ReaSamplOMatic5000."** Verdict verbatim: "okay it works, but +the UX is awful." The S1–S6 editor was spike-grade — a clickable list, zone rows with +**seven ±1 nudge/delete mini-buttons** each, text labels, **no keyboard, no waveform, no +drag, no scroll.** Setting a zone range by ±1 clicks is the catastrophe. The overhaul +(S10–S13) makes "better than RS5K" *specific*: + +- **RS5K's strengths, matched or beaten.** Drag-a-file-onto-it load (→ S13 relay); + note-range + a visual keyboard (→ S10 — RS5K uses two *number fields*, so a **draggable + keyboard strip** beats it); waveform with draggable start/end/loop markers (→ S11); ADSR + sliders (→ S12). Velocity layers / round-robin stay Tier 2 (held). +- **RS5K's weaknesses, our opening.** RS5K is **one-sample-per-instance** (forcing track + sprawl — one instance per drum) with **no multi-zone view in a single instance**. + ReaSampler 9000 is **multi-zone in one instrument** (S5), so the keyboard-strip editor + showing *every* zone at once is a capability RS5K structurally lacks. That is the + sharpest "better than RS5K" claim, and it's free — it falls out of the existing model. + +The overhaul honors every settled constraint: **LICE/SWELL only** (D-A), **pure geometry +modules** for all layout/hit-test (mirror of `mode_switch`/`editor_geometry`/`embed_strip`), +**RT discipline untouched** (edits commit off-thread), the instrument stays a **read-only +bank consumer** (loop/root/ADSR edits are the instrument's performance map, D-B — never +written to the bank). Component-state persistence and read-only-over-bank stay settled. + +*Sequencing (product recommendation).* **S10 leads** — the nudge-button zone editor is the +friction Daniel hits on every test pass, so removing it buys the most felt improvement per +unit of work and de-risks the drag-state machine S11/S12 reuse. Against the queued **S7 +(stereo)**: S10 should land **before or interleaved with** S7 — S7 is real engine work but +the *reason* Daniel keeps smoke-testing is the editor, and every test pass is currently +taxed by the UX; the two are orthogonal (S7 = engine/bus, S10 = editor/geometry), so there +is no hard ordering, but the live wound is the editor. Honest counter: if the stereo +*sound* is what blocks real use, S7-first is defensible — but "it works, the UX is awful" +names the editor as the wound. + --- ## Where this landed @@ -683,12 +736,22 @@ Two further directions set post-scoping (2026-07-26; see the Addendum in §4): hands-free refresh** (**S9**) — one-gesture capture/import + assign; the instrument stays a read-only consumer. +Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Addendum in §4): + +7. **Product name → `ReaSampler 9000`** (display-string-only; VST3 class UID unchanged; + binary filename = fork S-NAME-1, Daniel's call). +8. **UX overhaul → "better than RS5K"** (**S10–S13**): direct-manipulation keyboard-strip + keymap editor (S10), waveform + draggable loop points (S11), scale/ergonomics — scroll, + search, direct numeric entry, ADSR (S12), and drop-to-load folding in the S8 relay (S13). + **Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded -UI; then **S7** stereo, **S8** ingest, **S9** change-detection); **CONTEXT.md §Phase S** is -the spec (seam-field semantics, scope contracts, the channel-mode / ingest / bank-generation -contracts, the pure/shell split, the must-verify SDK/bridge surfaces). This doc is the -framing/decision record they point back to. The "no PLAN.md footprint" era is over. +UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the ReaSampler +9000 UX overhaul); **CONTEXT.md §Phase S** is the spec (seam-field semantics, scope +contracts, the channel-mode / ingest / bank-generation contracts, the UX-overhaul spec, the +product-name convention, the pure/shell split, the must-verify SDK/bridge surfaces). This +doc is the framing/decision record they point back to. The "no PLAN.md footprint" era is +over. --- From 3d6c0ab66abcaa1d887972c8bc558e593d6c0573 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 19:16:58 -0400 Subject: [PATCH 18/49] docs(product): system-wide visual design language (S0-DS kit + S14 panel refresh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LICE verified sufficient from vendored WDL — current editor draws at its floor (flat fills, raw GDI text); no framework shift needed, D-A holds. Adds design-language doc (palette, three directions, motion policy), S0-DS shared drawing kit before S10, S14 panel refresh. --- CONTEXT.md | 90 ++++++++++++++++++++++++++++++++++- PLAN.md | 90 ++++++++++++++++++++++++++++++++++- docs/product/midi-playback.md | 7 +++ 3 files changed, 184 insertions(+), 3 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 9e85295..2416043 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1392,6 +1392,83 @@ without re-opening each editor. The missing trigger: a **bank-generation counter This seam serves **both** S8 ingest and M10 recapture; the writer side is extension-only and independent of S8, so it can land alongside either. +## Design-system foundation (S0-DS + S14; "the VST is dogshit / temple os", 2026-07-26) + +**The diagnosis, from the vendored source, not lore.** Daniel DAW-tested the instrument and +called the look "temple os … the VST is dogshit," and asked whether Cockos ships a toolkit for +this. It does, and it's already vendored — the problem is the current editor draws at the +**floor** of it. The S1–S6 editor renders flat opaque `LICE_FillRect` blocks and pipes text +through raw GDI **`DrawTextA`** (reasampler_editor.cpp) — no gradients, no anti-aliasing, no +rounded corners, no hover, aliased system text. That is the entire "temple os" look. The +toolkit ceiling is not the constraint. + +**The toolkit (verified against `vendor/WDL`).** Two layers, both in hand: +- **LICE** (`vendor/WDL/WDL/lice/`) — Cockos's 2D rasterizer, the same one REAPER's themed UI + and SWS draw with. Verified surface beyond the flat `LICE_FillRect` the editor limits itself + to: `LICE_GradRect` (gradient fills), `LICE_Line`/`LICE_FLine`/`LICE_ThickFLine` (AA lines, + `aa=true` default), `LICE_RoundRect` (AA rounded rects), `LICE_Circle`/`LICE_FillCircle` (AA), + `LICE_DrawCBezier` (AA curves — envelope draw), `LICE_FillTriangle`/`FillTrapezoid`/ + `FillConvexPolygon`, per-primitive `alpha`+blend `mode`, and a real font engine + `LICE_CachedFont`/`LICE_IFont` (`lice_text.h`: AA glyph cache from an `HFONT`, `SetTextColor`, + `DrawText`, and shadow/outline/glow FX flags). **This is a sufficient modern-dark-UI toolkit + with no new dependency.** +- **WDL VirtualWnd (vwnd)** (`vendor/WDL/WDL/wingui/virtwnd*.{h,cpp}`) — the retained-mode + LICE-drawn *widget* layer REAPER/SWS use: `virtwnd-iconbutton` (buttons), `virtwnd-slider` + (sliders + `vwnd_slider_drawknobstack` knobs), `virtwnd-listbox` (scroll lists), + `virtwnd-skin` (image skins), `WDL_STYLE_*` gradient/knob hooks. **Fork DS-1 settled A: + crib vwnd's drawing craft (read `virtwnd-slider.cpp` as the reference for a good LICE + slider/knob), do NOT adopt its object model** — its controls own their hit-test internally, + which would move geometry back into untestable shell code and undercut the pure/shell split + the codebase relies on. (Narrow build-time exception: vwnd's *listbox* for the S12 scroll + list only.) **iPlug/IPlug (VST2-era, dated — reject), iPlug2/JUCE (a framework shift that + re-opens settled D-A to solve a non-ceiling problem — reject), VSTGUI (the D-A fallback rung, + now moot since S1's LICE bridge landed).** Full toolkit assessment + palette + the three + visual directions + all forks: `docs/product/visual-design-language.md`. + +**S0-DS — shared LICE drawing kit (BEFORE S10).** One shared kit — palette + type + component +draw — both the VST editor and the bank panel draw through, so "scope it for the whole system" +is one source, not two divergent restyles. **Sequenced before S10** so the keyboard-strip +editor is built in the new language, not restyled after. Honors the house split: the *geometry* +(a role→color `theme` module — direction selectable via one constants block — and component +layout/hit-test helpers) is **pure and CTest-covered** (mirror of `mode_switch`/`embed_strip`/ +`editor_geometry`); the *draw* (LICE `fillSurface` with micro-gradient + inner highlight, +`drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`/segment draw, and shared `LICE_CachedFont` +text) is shell. The kit defines the **interaction state model** the current editor lacks +entirely — rest / **hover** / active / pressed / dragging / focus / disabled — and the +double-buffered zero-jank draw discipline (draw to `LICE_SysBitmap`, single `BitBlt`) already in +both artifacts is preserved. + +**Palette + "punch" rule (Daniel's standing taste).** Dark, modern, **visual punch over +conservative contrast**: for each text-on-surface pair, take the *most* vibrant accent that +still clears its WCAG floor (AA 4.5:1 body / 3:1 large + state indicators) — the floor +approached from the vibrant side, never a retreat to safe gray. Palette is defined by **role** +(base/panel/cell/text-primary/text-dim/accent/accent-hot/warn), not hardcoded hue, so the visual +direction (fork DS-2) is a single constants block. Surfaces gain a micro-gradient + 1px inner +highlight/shadow (the vwnd trick) instead of flat fills; text moves to AA cached fonts (the +single biggest "temple os → modern" lever). + +**"Speed is the selling point" — read as a design constraint, not a tagline.** The UI must +*feel* instant: sub-frame hover/press/drag feedback repainted immediately on the input message +(instant acknowledgment *is* the perception of speed); zero-jank via the preserved double-buffer; +region-scoped `InvalidateRect` during a drag (build-time residual). **No decorative animation** — +no tweens/fades/pulses; the only permitted motion is a level/meter readout following the audio +directly (as the embed strip already does). Animation that costs a frame of latency violates the +speed promise. The "fast" feeling is typography + hover + no-jank, not motion. + +**S14 — dock-panel refresh (extension-side, lighter).** The panel is "not terrible" — this is +kit-application, not redesign (fork DS-3 settled light): route its text through the shared +cached font (retire GDI text), re-draw buttons/tab-strip/segmented-switch/toggles through the +kit, adopt palette roles + micro-gradient surfaces, add hover to tabs/rows. **Structure +untouched** — the vertical-split / tab / grid layout and the pure `bank_grid`/`tab_strip`/ +`mode_switch` hit-test sources of truth stay exactly as landed; only draw upgrades. +Extension-side, independent of the VST S10–S13 chain, parallelizable after the kit. + +**Sequencing:** `S0-DS` gates both S10 and S14. The prior "S10 leads the UX overhaul" becomes +"**S0-DS leads, then S10**" — the kit is a small foundation, not a detour. Constraints all +hold: **LICE/SWELL only** (D-A reinforced, not re-opened), **pure geometry modules** for all +layout/hit-test, RT discipline untouched (draw-thread only), instrument stays a read-only bank +consumer (a visual refresh is not a data-ownership or compat event — VST3 class UID unchanged). + ## ReaSampler 9000 — the UX overhaul (S10–S13; DAW-tested S1–S6, "the UX is awful") **The bar is set: better than ReaSamplOMatic5000.** Daniel DAW-tested the S1–S6 @@ -1434,7 +1511,9 @@ read-only-over-bank stay settled. geometry module (key-span↔pixel reusing the `embed_strip` idiom; edge-grab vs. body-move hit regions; `pixel→note`; drag-delta resolver). Shell extends the click-only `wndProc` to a `WM_MOUSEMOVE`/`WM_LBUTTONUP` drag-state machine with live feedback, one coherent - edit on release. + edit on release. **Built in the S0-DS kit's language** (drawn through the shared component + kit, not the flat `LICE_FillRect`/GDI path); the drag machine's `WM_MOUSEMOVE` tracking + also lights the kit's hover states at near-zero marginal cost. - **S11 — waveform view + draggable loop points.** Selecting a zone shows its sample's **waveform** (peaks via the existing `peaks` module over the shell's already-decoded PCM — no new decode/WAV path) with draggable **start/end/loop-start/loop-end** markers that @@ -1524,6 +1603,15 @@ Set by Daniel on DAW-testing the S1–S6 instrument, alongside the UX-overhaul d - **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the `IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event routing) — the least-trodden edge of the phase. +- **LICE design-kit surfaces (S0-DS).** Verified *present* in `vendor/WDL/WDL/lice/lice.h` + + `lice_text.h`: `LICE_GradRect`, `LICE_RoundRect`, AA `LICE_Line`/`LICE_FLine`/ + `LICE_ThickFLine`/`LICE_Circle`/`LICE_FillCircle`/`LICE_DrawCBezier`, `LICE_FillTriangle`/ + `FillTrapezoid`/`FillConvexPolygon`, and the `LICE_CachedFont`/`LICE_IFont` font engine + (`SetFromHFont`, AA `DrawText`, shadow/outline/glow FX flags). **Verify exact signatures + + the `LICE_CachedFont`↔`HFONT` lifecycle before use.** vwnd reference (drawing craft only, + not the object model): `vendor/WDL/WDL/wingui/virtwnd-slider.cpp` / + `vwnd_slider_drawknobstack` for slider/knob rendering, `virtwnd-controls.h` for the + `WDL_STYLE_*` gradient hooks. ## Drop-and-load — drag a capture onto a track's FX button (S17 spec) diff --git a/PLAN.md b/PLAN.md index 5db07fc..899346a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -383,6 +383,42 @@ alongside S8. stale value by design — it reloads on the *next* poll; confirm no torn-read hazard for the single integer generation key). +## S0-DS — shared LICE drawing kit (design-system foundation; BEFORE S10) +**Goal:** A shared **LICE-based design kit** — palette + type + component-draw layer — that +both the ReaSampler 9000 VST editor and the extension's bank panel draw through, replacing +the current flat `LICE_FillRect` + raw-GDI-`DrawTextA` "temple os" drawing with a modern dark +look (gradient surfaces via `LICE_GradRect`, anti-aliased rounded controls via `LICE_RoundRect`/ +`LICE_Line`, cached anti-aliased text via `LICE_CachedFont`, hover/pressed/drag interaction +states). **Sequenced before S10** so the keyboard-strip editor is *born* in the new language, +not restyled after. No new dependency — LICE (already the draw surface) is the toolkit; vwnd's +drawing craft is cribbed, not its object model (fork DS-1 settled A). Product framing + +palette + the three visual directions + all three forks: `docs/product/visual-design-language.md`. +CONTEXT.md §Phase S (design-system foundation). **Verify LICE gradient/AA/font surfaces and the +`LICE_CachedFont`↔`HFONT` lifecycle against `vendor/WDL/WDL/lice/lice.h` + `lice_text.h` before +use** (surfaces verified present in the design-language doc; confirm signatures at build). +**Depends on:** S1 (the `IPlugView`↔LICE editor surface the kit draws into) + the existing +`bank_panel` LICE surface. Blocks S10 and S14. + +- [ ] Pure `theme`/palette layer: role→color mapping (`bg/base`, `bg/panel`, `bg/cell`, + `text/primary`, `text/dim`, `accent`, `accent/hot`, `warn`) selected via **one direction + constants block** (DS-2), so the visual direction is a single-file change. Unit-test that + each text-on-surface pair clears its WCAG floor (AA 4.5:1 body / 3:1 large + state + indicators) — the "punch to the floor, not past it" rule made testable. No LICE/host types. +- [ ] Pure component **geometry/hit-test** helpers where not already owned: button rect, + slider track/handle geometry, list-row rect + hover hit-test (mirror of + `mode_switch`/`embed_strip`/`editor_geometry` — no LICE, no host types, CTest-covered). +- [ ] Shell **draw kit** (LICE): `fillSurface` (micro-gradient + 1px inner highlight/shadow — + the vwnd trick that kills the flat look), `drawButton`/`drawSlider`/`drawListRow`/ + `drawWaveform`/segmented-switch draw, and a shared `text()` over a cached `LICE_CachedFont` + set (title/label/value-mono/micro). Owns the cached-font lifecycle. Honors the interaction + state model (rest/hover/active/pressed/dragging/focus/disabled). Double-buffered draw + discipline preserved (draw to `LICE_SysBitmap`, single `BitBlt`) — the "zero-jank" half of + "speed is the selling point." +- [ ] Retire the raw GDI `DrawTextA` path in the VST editor's `drawText`/`drawTextCentered` + and the bank panel's GDI text in favor of the kit's cached-font `text()` — the single + biggest "temple os → modern" lever. (This is the drawing-substrate change; the surfaces it + serves land in S10–S14.) + ## S10 — direct-manipulation keymap editor ("ReaSampler 9000" UX overhaul, part 1) **Goal:** Replace the ±1 nudge-button zone editor with a **piano-keyboard-strip zone editor** where the keymap is edited by direct manipulation: a horizontal keyboard span @@ -402,8 +438,11 @@ next held note); overlapping zones render legibly and resolve first-match as bef zone add/select/delete still work; the pure geometry module is CTest-green (edge-grab regions, body-drag delta, key→note mapping, root hit-test) with no host types at its boundary; the ±1 nudge-button row is gone. -**Depends on:** S5 (the `PerformanceMap`/zone model it edits), S1 (the LICE `IPlugView` -drag/event routing — extends the click-only `wndProc` to `WM_MOUSEMOVE`/`WM_LBUTTONUP`). +**Depends on:** S0-DS (the shared drawing kit — the strip/zone bars are drawn in the kit's +new language, and the drag machine's `WM_MOUSEMOVE` tracking also lights the kit's **hover** +states at near-zero marginal cost), S5 (the `PerformanceMap`/zone model it edits), S1 (the +LICE `IPlugView` drag/event routing — extends the click-only `wndProc` to +`WM_MOUSEMOVE`/`WM_LBUTTONUP`). - [ ] Pure `keyboard_strip` geometry module: map a MIDI key span across a strip width (128 keys → pixels, reusing the S6 `embed_strip` key-span idiom); per-zone bar rect from @@ -527,6 +566,53 @@ the relay is proven.** "drop files on the ReaSampler panel to add" affordance rather than silently swallowing the drop — the shipped ingest gesture stays discoverable either way. +## S14 — dock-panel refresh (extension-side; applies the S0-DS kit to the bank panel) +**Goal:** Bring the extension's docked `bank_panel` up to the new design language by +**applying the S0-DS kit** — it is "not terrible" already (thumbnails, selection, focus ring, +LICE grid), so this is a re-skin, not a redesign: route its text through the shared +`LICE_CachedFont` (retiring its GDI text), re-draw buttons / tab strip / segmented mode-switch +/ full-height toggles through the kit's `drawButton`/segment draw, adopt the palette roles + +micro-gradient surfaces, and add **hover** to tabs and rows. Structure stays; only the drawing +upgrades (fork DS-3 settled: light/kit-application only). CONTEXT.md §Phase S (dock-panel +refresh). **Depends on:** S0-DS (the kit). Extension-side and **independent of the VST +S10–S13 chain** — can land any time after the kit, in parallel with the VST overhaul. + +- [ ] Route all `bank_panel` text through the S0-DS cached-font `text()`; retire the GDI + `DrawText` path in the panel. +- [ ] Re-draw the panel's buttons, the named-banks tab strip, the Design View segmented + mode-switch, and the pool/banks full-height toggles through the kit's component draws + (gradient fills, rounded, inner highlight); adopt the palette roles + micro-gradient + surfaces. Hit-test geometry unchanged (`bank_grid`/`tab_strip`/`mode_switch` stay the pure + source of truth) — this touches draw only. +- [ ] Add **hover** state to tab strip and grid rows/cells (the panel's `wndProc` already + runs a timer; add `WM_MOUSEMOVE` hover tracking). No structural/layout change — the + vertical-split, tab, and grid layout are untouched (fork DS-3). + +## Phase S — design-system forks (Daniel's calls) +Framing + full analysis: `docs/product/visual-design-language.md` §6. Two of three carry a +strong product-designer lean; DS-2 is deliberately low-cost-to-reverse. + +- **Fork DS-1 — toolkit.** (A) **LICE drawing kit** (recommended — no new dependency, + consistent with settled D-A; crib vwnd's drawing craft, not its object model); (B) + **vwnd-based** retained-mode controls (not recommended — moves hit-test geometry into + untestable shell, pulls in image-skin assets; narrow exception: vwnd's *listbox* for the + S12 scroll list only, a build-time residual); (C) **iPlug2 / JUCE shift** (reject — re-opens + settled D-A to solve a look problem that is not a toolkit-ceiling problem; REAPER's own + themed UI is LICE). **Lean: A.** This fork exists mainly to let Daniel rule *against* the + LICE kit if he wants a framework anyway. +- **Fork DS-2 — visual direction.** (A) **Studio Rack** (evolutionary green-on-charcoal, done + properly); (B) **Neon Console** (near-black + one vivid accent, modern soft-synth idiom); (C) + **Spectral** (bold — hue-mapped zone/waveform gradients + glow, turns the multi-zone-in-one- + instance advantage into a visual signature). **Lean: B default + C's spectral keyboard + strip.** Because the kit palette is abstract (roles, one constants block), this fork is + **reversible for ~one file** — pick a *starting* direction, not a permanent one. +- **Fork DS-3 — panel refresh scope.** (a) **Light** — apply the kit (fonts/buttons/palette/ + hover), keep the structure (S14 as specced); (b) **Structural** — also rework the + vertical-split / tab-strip / grid layout. **Lean: (a) light** — the panel's structure is + sound; a structural rework is scope Daniel didn't ask for and risks destabilizing landed + Phase-B panel work. (b) available later if the panel's *layout* starts to feel dated once + the VST is sharp. + ## Phase S — product name (ReaSampler 9000) The MIDI-playback instrument's product name is **ReaSampler 9000** (Daniel, 2026-07-26, on DAW-testing the S1–S6 instrument). The extension remains **ReaSampler**; the instrument diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index ba9ed41..f86319e 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -743,6 +743,13 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden 8. **UX overhaul → "better than RS5K"** (**S10–S13**): direct-manipulation keyboard-strip keymap editor (S10), waveform + draggable loop points (S11), scale/ergonomics — scroll, search, direct numeric entry, ADSR (S12), and drop-to-load folding in the S8 relay (S13). +9. **Visual design language → modern/sleek, system-wide** (**S0-DS** + **S14**): a shared + LICE drawing kit (gradients, AA, rounded controls, cached-font text, hover states — no new + toolkit; LICE is sufficient, D-A reinforced), sequenced **before** S10 so the keymap editor + is born in the new language; plus a lighter extension-side dock-panel refresh. Framing + + palette + three visual directions + toolkit/direction/scope forks: + `docs/product/visual-design-language.md`. Answers Daniel's "the VST is dogshit / temple os / + does Cockos have a toolkit" (2026-07-26, post-S1–S6 DAW test). **Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded From 5de2183ce31d8e9f961553a8e6b49420812fdc6d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 19:36:10 -0400 Subject: [PATCH 19/49] docs: detach design-system (S0-DS/S14) to Phase L; leave pointers Remove S0-DS and S14 from Phase S PLAN/CONTEXT (moved to Phase L on dev), restore S10's Depends-on without the kit gate; S10-S13 adopt the Phase L kit when it lands rather than being gated on it. --- CONTEXT.md | 115 +++++++++------------------------- PLAN.md | 113 ++++++++------------------------- docs/product/midi-playback.md | 21 ++++--- 3 files changed, 69 insertions(+), 180 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2416043..57fff3d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1392,82 +1392,26 @@ without re-opening each editor. The missing trigger: a **bank-generation counter This seam serves **both** S8 ingest and M10 recapture; the writer side is extension-only and independent of S8, so it can land alongside either. -## Design-system foundation (S0-DS + S14; "the VST is dogshit / temple os", 2026-07-26) +## Design-system foundation — moved to Phase L (2026-07-26) -**The diagnosis, from the vendored source, not lore.** Daniel DAW-tested the instrument and -called the look "temple os … the VST is dogshit," and asked whether Cockos ships a toolkit for -this. It does, and it's already vendored — the problem is the current editor draws at the -**floor** of it. The S1–S6 editor renders flat opaque `LICE_FillRect` blocks and pipes text -through raw GDI **`DrawTextA`** (reasampler_editor.cpp) — no gradients, no anti-aliasing, no -rounded corners, no hover, aliased system text. That is the entire "temple os" look. The -toolkit ceiling is not the constraint. - -**The toolkit (verified against `vendor/WDL`).** Two layers, both in hand: -- **LICE** (`vendor/WDL/WDL/lice/`) — Cockos's 2D rasterizer, the same one REAPER's themed UI - and SWS draw with. Verified surface beyond the flat `LICE_FillRect` the editor limits itself - to: `LICE_GradRect` (gradient fills), `LICE_Line`/`LICE_FLine`/`LICE_ThickFLine` (AA lines, - `aa=true` default), `LICE_RoundRect` (AA rounded rects), `LICE_Circle`/`LICE_FillCircle` (AA), - `LICE_DrawCBezier` (AA curves — envelope draw), `LICE_FillTriangle`/`FillTrapezoid`/ - `FillConvexPolygon`, per-primitive `alpha`+blend `mode`, and a real font engine - `LICE_CachedFont`/`LICE_IFont` (`lice_text.h`: AA glyph cache from an `HFONT`, `SetTextColor`, - `DrawText`, and shadow/outline/glow FX flags). **This is a sufficient modern-dark-UI toolkit - with no new dependency.** -- **WDL VirtualWnd (vwnd)** (`vendor/WDL/WDL/wingui/virtwnd*.{h,cpp}`) — the retained-mode - LICE-drawn *widget* layer REAPER/SWS use: `virtwnd-iconbutton` (buttons), `virtwnd-slider` - (sliders + `vwnd_slider_drawknobstack` knobs), `virtwnd-listbox` (scroll lists), - `virtwnd-skin` (image skins), `WDL_STYLE_*` gradient/knob hooks. **Fork DS-1 settled A: - crib vwnd's drawing craft (read `virtwnd-slider.cpp` as the reference for a good LICE - slider/knob), do NOT adopt its object model** — its controls own their hit-test internally, - which would move geometry back into untestable shell code and undercut the pure/shell split - the codebase relies on. (Narrow build-time exception: vwnd's *listbox* for the S12 scroll - list only.) **iPlug/IPlug (VST2-era, dated — reject), iPlug2/JUCE (a framework shift that - re-opens settled D-A to solve a non-ceiling problem — reject), VSTGUI (the D-A fallback rung, - now moot since S1's LICE bridge landed).** Full toolkit assessment + palette + the three - visual directions + all forks: `docs/product/visual-design-language.md`. - -**S0-DS — shared LICE drawing kit (BEFORE S10).** One shared kit — palette + type + component -draw — both the VST editor and the bank panel draw through, so "scope it for the whole system" -is one source, not two divergent restyles. **Sequenced before S10** so the keyboard-strip -editor is built in the new language, not restyled after. Honors the house split: the *geometry* -(a role→color `theme` module — direction selectable via one constants block — and component -layout/hit-test helpers) is **pure and CTest-covered** (mirror of `mode_switch`/`embed_strip`/ -`editor_geometry`); the *draw* (LICE `fillSurface` with micro-gradient + inner highlight, -`drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`/segment draw, and shared `LICE_CachedFont` -text) is shell. The kit defines the **interaction state model** the current editor lacks -entirely — rest / **hover** / active / pressed / dragging / focus / disabled — and the -double-buffered zero-jank draw discipline (draw to `LICE_SysBitmap`, single `BitBlt`) already in -both artifacts is preserved. - -**Palette + "punch" rule (Daniel's standing taste).** Dark, modern, **visual punch over -conservative contrast**: for each text-on-surface pair, take the *most* vibrant accent that -still clears its WCAG floor (AA 4.5:1 body / 3:1 large + state indicators) — the floor -approached from the vibrant side, never a retreat to safe gray. Palette is defined by **role** -(base/panel/cell/text-primary/text-dim/accent/accent-hot/warn), not hardcoded hue, so the visual -direction (fork DS-2) is a single constants block. Surfaces gain a micro-gradient + 1px inner -highlight/shadow (the vwnd trick) instead of flat fills; text moves to AA cached fonts (the -single biggest "temple os → modern" lever). - -**"Speed is the selling point" — read as a design constraint, not a tagline.** The UI must -*feel* instant: sub-frame hover/press/drag feedback repainted immediately on the input message -(instant acknowledgment *is* the perception of speed); zero-jank via the preserved double-buffer; -region-scoped `InvalidateRect` during a drag (build-time residual). **No decorative animation** — -no tweens/fades/pulses; the only permitted motion is a level/meter readout following the audio -directly (as the embed strip already does). Animation that costs a frame of latency violates the -speed promise. The "fast" feeling is typography + hover + no-jank, not motion. - -**S14 — dock-panel refresh (extension-side, lighter).** The panel is "not terrible" — this is -kit-application, not redesign (fork DS-3 settled light): route its text through the shared -cached font (retire GDI text), re-draw buttons/tab-strip/segmented-switch/toggles through the -kit, adopt palette roles + micro-gradient surfaces, add hover to tabs/rows. **Structure -untouched** — the vertical-split / tab / grid layout and the pure `bank_grid`/`tab_strip`/ -`mode_switch` hit-test sources of truth stay exactly as landed; only draw upgrades. -Extension-side, independent of the VST S10–S13 chain, parallelizable after the kit. - -**Sequencing:** `S0-DS` gates both S10 and S14. The prior "S10 leads the UX overhaul" becomes -"**S0-DS leads, then S10**" — the kit is a small foundation, not a detour. Constraints all -hold: **LICE/SWELL only** (D-A reinforced, not re-opened), **pure geometry modules** for all -layout/hit-test, RT discipline untouched (draw-thread only), instrument stays a read-only bank -consumer (a visual refresh is not a data-ownership or compat event — VST3 class UID unchanged). +> **The visual design language moved out of Phase S into its own Phase L.** The +> design-system content that stood here (the toolkit assessment, the shared LICE drawing +> kit **S0-DS**, and the dock-panel refresh **S14**) has been lifted into **Phase L** +> (Look-and-feel) on `dev`, taken up by a parallel team so Phase S feature work proceeds +> ungated. S0-DS is now **Phase L point L1** (the shared kit); S14 is now **L2** — and, +> per Daniel's DS-3 call, expanded from a light re-skin into a **thorough dock-panel layout +> redesign** that lays out the full M11-aware button inventory before applying the kit; the +> VST editor + embed-strip restyle is now the explicit **L3** point (gated on Phase S +> landing on dev). The design-system forks DS-1 (LICE + WDL free game, no external +> frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), +> and DS-3 (thorough panel layout redesign) are all **SETTLED (Daniel, 2026-07-26)**. +> +> **Authoritative from here:** **PLAN.md §Phase L + CONTEXT.md §Phase L on `dev`**, and +> `docs/product/visual-design-language.md` (on `dev`). Phase S's **S10–S13 build their +> interaction UX with the current drawing and adopt the Phase L kit when it lands — they +> are not gated on Phase L.** LICE/SWELL-only, the pure-geometry-module discipline, RT +> discipline, and the read-only-over-bank / VST3-class-UID-unchanged guardrails all hold +> exactly as before — a visual refresh is not a data-ownership or compat event. ## ReaSampler 9000 — the UX overhaul (S10–S13; DAW-tested S1–S6, "the UX is awful") @@ -1511,9 +1455,10 @@ read-only-over-bank stay settled. geometry module (key-span↔pixel reusing the `embed_strip` idiom; edge-grab vs. body-move hit regions; `pixel→note`; drag-delta resolver). Shell extends the click-only `wndProc` to a `WM_MOUSEMOVE`/`WM_LBUTTONUP` drag-state machine with live feedback, one coherent - edit on release. **Built in the S0-DS kit's language** (drawn through the shared component - kit, not the flat `LICE_FillRect`/GDI path); the drag machine's `WM_MOUSEMOVE` tracking - also lights the kit's hover states at near-zero marginal cost. + edit on release. **Built with the current LICE drawing; adopts the Phase L kit (L1) when + it lands** (drawn through the shared component kit rather than flat `LICE_FillRect`/GDI once + available) — **not gated on Phase L**; the drag machine's `WM_MOUSEMOVE` tracking also lights + the kit's hover states at near-zero marginal cost once the kit is present. - **S11 — waveform view + draggable loop points.** Selecting a zone shows its sample's **waveform** (peaks via the existing `peaks` module over the shell's already-decoded PCM — no new decode/WAV path) with draggable **start/end/loop-start/loop-end** markers that @@ -1603,15 +1548,11 @@ Set by Daniel on DAW-testing the S1–S6 instrument, alongside the UX-overhaul d - **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the `IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event routing) — the least-trodden edge of the phase. -- **LICE design-kit surfaces (S0-DS).** Verified *present* in `vendor/WDL/WDL/lice/lice.h` + - `lice_text.h`: `LICE_GradRect`, `LICE_RoundRect`, AA `LICE_Line`/`LICE_FLine`/ - `LICE_ThickFLine`/`LICE_Circle`/`LICE_FillCircle`/`LICE_DrawCBezier`, `LICE_FillTriangle`/ - `FillTrapezoid`/`FillConvexPolygon`, and the `LICE_CachedFont`/`LICE_IFont` font engine - (`SetFromHFont`, AA `DrawText`, shadow/outline/glow FX flags). **Verify exact signatures + - the `LICE_CachedFont`↔`HFONT` lifecycle before use.** vwnd reference (drawing craft only, - not the object model): `vendor/WDL/WDL/wingui/virtwnd-slider.cpp` / - `vwnd_slider_drawknobstack` for slider/knob rendering, `virtwnd-controls.h` for the - `WDL_STYLE_*` gradient hooks. +- **LICE design-kit surfaces — moved to Phase L.** The shared LICE drawing-kit surface + verification (`LICE_GradRect`/`LICE_RoundRect`/AA lines/circles/beziers/polygons + the + `LICE_CachedFont`/`LICE_IFont` font engine, and the vwnd drawing-craft references) now + lives with **Phase L point L1** on `dev` — see CONTEXT.md §Phase L "LICE / WDL API + surface". Phase S surfaces (S10–S13) adopt that kit when it lands; they are not gated on it. ## Drop-and-load — drag a capture onto a track's FX button (S17 spec) diff --git a/PLAN.md b/PLAN.md index 899346a..f16d062 100644 --- a/PLAN.md +++ b/PLAN.md @@ -86,6 +86,17 @@ landed milestone. > held, Tier 3 optional-forever); D-D embedded TCP/MCP UI **scheduled** as a later > in-phase point (after the main editor exists). > +> **Visual design moved to Phase L (2026-07-26).** The look-and-feel work originally +> drafted here as **S0-DS** (shared LICE drawing kit) and **S14** (dock-panel refresh) has +> been lifted out of Phase S into its own **Phase L** (Look-and-feel), taken up by a +> parallel team on `dev` so Phase S feature work proceeds ungated. See **PLAN.md §Phase L +> + CONTEXT.md §Phase L on `dev`** and `docs/product/visual-design-language.md` (on `dev`). +> S10–S13 build their interaction UX with the **current** drawing and **adopt the Phase L +> kit when it lands — they are not gated on Phase L.** +> +> **Numbering note:** S0-DS and S14 are removed (moved to Phase L); S7–S13 keep their +> numbers. +> > **Second build artifact (load-bearing, flagged up front):** Phase S produces a > *separate* VST3 binary alongside `reaper_reasampler`. The Steinberg VST3 SDK is a > **new vendored dependency** (vendor at the spike — an implementation-time @@ -383,41 +394,10 @@ alongside S8. stale value by design — it reloads on the *next* poll; confirm no torn-read hazard for the single integer generation key). -## S0-DS — shared LICE drawing kit (design-system foundation; BEFORE S10) -**Goal:** A shared **LICE-based design kit** — palette + type + component-draw layer — that -both the ReaSampler 9000 VST editor and the extension's bank panel draw through, replacing -the current flat `LICE_FillRect` + raw-GDI-`DrawTextA` "temple os" drawing with a modern dark -look (gradient surfaces via `LICE_GradRect`, anti-aliased rounded controls via `LICE_RoundRect`/ -`LICE_Line`, cached anti-aliased text via `LICE_CachedFont`, hover/pressed/drag interaction -states). **Sequenced before S10** so the keyboard-strip editor is *born* in the new language, -not restyled after. No new dependency — LICE (already the draw surface) is the toolkit; vwnd's -drawing craft is cribbed, not its object model (fork DS-1 settled A). Product framing + -palette + the three visual directions + all three forks: `docs/product/visual-design-language.md`. -CONTEXT.md §Phase S (design-system foundation). **Verify LICE gradient/AA/font surfaces and the -`LICE_CachedFont`↔`HFONT` lifecycle against `vendor/WDL/WDL/lice/lice.h` + `lice_text.h` before -use** (surfaces verified present in the design-language doc; confirm signatures at build). -**Depends on:** S1 (the `IPlugView`↔LICE editor surface the kit draws into) + the existing -`bank_panel` LICE surface. Blocks S10 and S14. - -- [ ] Pure `theme`/palette layer: role→color mapping (`bg/base`, `bg/panel`, `bg/cell`, - `text/primary`, `text/dim`, `accent`, `accent/hot`, `warn`) selected via **one direction - constants block** (DS-2), so the visual direction is a single-file change. Unit-test that - each text-on-surface pair clears its WCAG floor (AA 4.5:1 body / 3:1 large + state - indicators) — the "punch to the floor, not past it" rule made testable. No LICE/host types. -- [ ] Pure component **geometry/hit-test** helpers where not already owned: button rect, - slider track/handle geometry, list-row rect + hover hit-test (mirror of - `mode_switch`/`embed_strip`/`editor_geometry` — no LICE, no host types, CTest-covered). -- [ ] Shell **draw kit** (LICE): `fillSurface` (micro-gradient + 1px inner highlight/shadow — - the vwnd trick that kills the flat look), `drawButton`/`drawSlider`/`drawListRow`/ - `drawWaveform`/segmented-switch draw, and a shared `text()` over a cached `LICE_CachedFont` - set (title/label/value-mono/micro). Owns the cached-font lifecycle. Honors the interaction - state model (rest/hover/active/pressed/dragging/focus/disabled). Double-buffered draw - discipline preserved (draw to `LICE_SysBitmap`, single `BitBlt`) — the "zero-jank" half of - "speed is the selling point." -- [ ] Retire the raw GDI `DrawTextA` path in the VST editor's `drawText`/`drawTextCentered` - and the bank panel's GDI text in favor of the kit's cached-font `text()` — the single - biggest "temple os → modern" lever. (This is the drawing-substrate change; the surfaces it - serves land in S10–S14.) +> **S0-DS moved to Phase L (2026-07-26).** The shared LICE drawing kit that stood here is +> now **Phase L point L1** on `dev` — see PLAN.md §Phase L + CONTEXT.md §Phase L and +> `docs/product/visual-design-language.md` (all on `dev`). S10–S13 below build with the +> current drawing and **adopt the L1 kit when it lands — not gated on Phase L.** ## S10 — direct-manipulation keymap editor ("ReaSampler 9000" UX overhaul, part 1) **Goal:** Replace the ±1 nudge-button zone editor with a **piano-keyboard-strip zone @@ -438,11 +418,12 @@ next held note); overlapping zones render legibly and resolve first-match as bef zone add/select/delete still work; the pure geometry module is CTest-green (edge-grab regions, body-drag delta, key→note mapping, root hit-test) with no host types at its boundary; the ±1 nudge-button row is gone. -**Depends on:** S0-DS (the shared drawing kit — the strip/zone bars are drawn in the kit's -new language, and the drag machine's `WM_MOUSEMOVE` tracking also lights the kit's **hover** -states at near-zero marginal cost), S5 (the `PerformanceMap`/zone model it edits), S1 (the -LICE `IPlugView` drag/event routing — extends the click-only `wndProc` to -`WM_MOUSEMOVE`/`WM_LBUTTONUP`). +**Depends on:** S5 (the `PerformanceMap`/zone model it edits), S1 (the LICE `IPlugView` +drag/event routing — extends the click-only `wndProc` to `WM_MOUSEMOVE`/`WM_LBUTTONUP`). +**Adopts the Phase L kit when available — not gated on Phase L.** S10 builds its keyboard +strip + zone bars with the current LICE drawing; when Phase L's L1 kit lands on `dev`, this +surface adopts it (the one source of drawing). The drag machine's `WM_MOUSEMOVE` tracking +also lights the kit's **hover** states at near-zero marginal cost once the kit is present. - [ ] Pure `keyboard_strip` geometry module: map a MIDI key span across a strip width (128 keys → pixels, reusing the S6 `embed_strip` key-span idiom); per-zone bar rect from @@ -566,52 +547,12 @@ the relay is proven.** "drop files on the ReaSampler panel to add" affordance rather than silently swallowing the drop — the shipped ingest gesture stays discoverable either way. -## S14 — dock-panel refresh (extension-side; applies the S0-DS kit to the bank panel) -**Goal:** Bring the extension's docked `bank_panel` up to the new design language by -**applying the S0-DS kit** — it is "not terrible" already (thumbnails, selection, focus ring, -LICE grid), so this is a re-skin, not a redesign: route its text through the shared -`LICE_CachedFont` (retiring its GDI text), re-draw buttons / tab strip / segmented mode-switch -/ full-height toggles through the kit's `drawButton`/segment draw, adopt the palette roles + -micro-gradient surfaces, and add **hover** to tabs and rows. Structure stays; only the drawing -upgrades (fork DS-3 settled: light/kit-application only). CONTEXT.md §Phase S (dock-panel -refresh). **Depends on:** S0-DS (the kit). Extension-side and **independent of the VST -S10–S13 chain** — can land any time after the kit, in parallel with the VST overhaul. - -- [ ] Route all `bank_panel` text through the S0-DS cached-font `text()`; retire the GDI - `DrawText` path in the panel. -- [ ] Re-draw the panel's buttons, the named-banks tab strip, the Design View segmented - mode-switch, and the pool/banks full-height toggles through the kit's component draws - (gradient fills, rounded, inner highlight); adopt the palette roles + micro-gradient - surfaces. Hit-test geometry unchanged (`bank_grid`/`tab_strip`/`mode_switch` stay the pure - source of truth) — this touches draw only. -- [ ] Add **hover** state to tab strip and grid rows/cells (the panel's `wndProc` already - runs a timer; add `WM_MOUSEMOVE` hover tracking). No structural/layout change — the - vertical-split, tab, and grid layout are untouched (fork DS-3). - -## Phase S — design-system forks (Daniel's calls) -Framing + full analysis: `docs/product/visual-design-language.md` §6. Two of three carry a -strong product-designer lean; DS-2 is deliberately low-cost-to-reverse. - -- **Fork DS-1 — toolkit.** (A) **LICE drawing kit** (recommended — no new dependency, - consistent with settled D-A; crib vwnd's drawing craft, not its object model); (B) - **vwnd-based** retained-mode controls (not recommended — moves hit-test geometry into - untestable shell, pulls in image-skin assets; narrow exception: vwnd's *listbox* for the - S12 scroll list only, a build-time residual); (C) **iPlug2 / JUCE shift** (reject — re-opens - settled D-A to solve a look problem that is not a toolkit-ceiling problem; REAPER's own - themed UI is LICE). **Lean: A.** This fork exists mainly to let Daniel rule *against* the - LICE kit if he wants a framework anyway. -- **Fork DS-2 — visual direction.** (A) **Studio Rack** (evolutionary green-on-charcoal, done - properly); (B) **Neon Console** (near-black + one vivid accent, modern soft-synth idiom); (C) - **Spectral** (bold — hue-mapped zone/waveform gradients + glow, turns the multi-zone-in-one- - instance advantage into a visual signature). **Lean: B default + C's spectral keyboard - strip.** Because the kit palette is abstract (roles, one constants block), this fork is - **reversible for ~one file** — pick a *starting* direction, not a permanent one. -- **Fork DS-3 — panel refresh scope.** (a) **Light** — apply the kit (fonts/buttons/palette/ - hover), keep the structure (S14 as specced); (b) **Structural** — also rework the - vertical-split / tab-strip / grid layout. **Lean: (a) light** — the panel's structure is - sound; a structural rework is scope Daniel didn't ask for and risks destabilizing landed - Phase-B panel work. (b) available later if the panel's *layout* starts to feel dated once - the VST is sharp. +> **S14 moved to Phase L (2026-07-26).** The dock-panel refresh that stood here is now +> **Phase L point L2** on `dev` — and, per Daniel's DS-3 call, expanded from a light re-skin +> into a **thorough dock-panel layout redesign** that lays out the full M11-aware button +> inventory before applying the kit. See PLAN.md §Phase L + CONTEXT.md §Phase L and +> `docs/product/visual-design-language.md` (all on `dev`). The design-system forks DS-1/ +> DS-2/DS-3 are all **SETTLED (2026-07-26)** and recorded in the Phase L docs on `dev`. ## Phase S — product name (ReaSampler 9000) The MIDI-playback instrument's product name is **ReaSampler 9000** (Daniel, 2026-07-26, diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index f86319e..31d03d7 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -743,13 +743,20 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden 8. **UX overhaul → "better than RS5K"** (**S10–S13**): direct-manipulation keyboard-strip keymap editor (S10), waveform + draggable loop points (S11), scale/ergonomics — scroll, search, direct numeric entry, ADSR (S12), and drop-to-load folding in the S8 relay (S13). -9. **Visual design language → modern/sleek, system-wide** (**S0-DS** + **S14**): a shared - LICE drawing kit (gradients, AA, rounded controls, cached-font text, hover states — no new - toolkit; LICE is sufficient, D-A reinforced), sequenced **before** S10 so the keymap editor - is born in the new language; plus a lighter extension-side dock-panel refresh. Framing + - palette + three visual directions + toolkit/direction/scope forks: - `docs/product/visual-design-language.md`. Answers Daniel's "the VST is dogshit / temple os / - does Cockos have a toolkit" (2026-07-26, post-S1–S6 DAW test). +9. **Visual design language → modern/sleek, system-wide — moved to its own Phase L + (2026-07-26).** The look-and-feel work (a shared LICE drawing kit + the surfaces that + adopt it) was originally drafted here as Phase S points S0-DS + S14; it has been **lifted + out of Phase S into its own Phase L** (Look-and-feel) on `dev`, taken up by a parallel team + so Phase S feature work proceeds ungated. S0-DS → **L1** (shared kit); S14 → **L2** + (expanded to a thorough dock-panel layout redesign per DS-3); VST editor + embed restyle → + **L3** (gated on Phase S landing on dev). Forks DS-1 (LICE + WDL free game, no external + frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and + DS-3 (thorough panel layout) are all **SETTLED (2026-07-26)**. Framing + palette + the three + visual directions + forks: `docs/product/visual-design-language.md` (on `dev`); roadmap + + spec: **PLAN.md §Phase L + CONTEXT.md §Phase L** (on `dev`). **S10–S13 build with the + current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers + Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26, + post-S1–S6 DAW test). **Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded From 991c190bb8bcf9f386eab0e00b90818fb8aff543 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 19:49:03 -0400 Subject: [PATCH 20/49] docs(product): reframe S10 capture-first (browser + single-capture setup); reverse S4 auto-select; settle S-NAME-1 filename rename --- CONTEXT.md | 132 ++++++++++++++++------- PLAN.md | 198 ++++++++++++++++++++++++---------- docs/product/midi-playback.md | 84 +++++++++++++-- 3 files changed, 312 insertions(+), 102 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 57fff3d..ebab860 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1426,6 +1426,35 @@ Setting a zone from C1 to C4 by clicking "+" thirty-six times is the catastrophe a fixed envelope) compound it. The overhaul is scoped as **S10–S13**, sequenced so the friction Daniel feels every test pass is removed first. +### Workflow hierarchy (REVISED 2026-07-26 — Daniel; supersedes the keymap-first S10) + +The overhaul is reframed around the **actual workflow**, not a keymap. Daniel's directive, +distilled: *a giant list of "item" blocks is visually useless; optimize for working with +individual captures, not a huge list of everything.* The settled hierarchy: + +1. **Primary flow = one capture, fast.** Most instances play a **single capture**. The + metric is **time-to-first-note**: open → pick a capture → see it (waveform/peaks) → play + it. The default editor face serves this, not a zone table. +2. **Fresh instance is SILENT — nothing auto-selected (policy reversal of S4).** On open + with no stored selection, the instrument plays **nothing** and shows a clear **empty + state** ("pick a capture") — it does **not** auto-play sample #1. This deliberately + reverses the S4 "first sample plays" convenience: the `selectSample` first-sample + fallback and the processor's Tier-0 fallback that resolved it are removed; an empty + stored id resolves to silence. (Recorded as a reversal, not a regression.) +3. **Capture browser, not an item list.** Scannable **cards/rows** with **peak thumbnails** + (the `Sample` peaks bank_model already carries — the same data the dock panel thumbnails + draw), name, and a **root/key badge** where present; **filterable by bank** (bank_book + named banks). A "giant list of item blocks" is the anti-pattern — the browser is designed + for scanning by eye. +4. **Graphic, descriptive controls with a guided fast path.** Once a capture is picked, a + prominent, self-explanatory single-capture setup surface (root note, play-mode basics, + level). The keyboard strip serves the **single-capture** case first (shows where the + capture sits / its root); drag matters most when zoning. +5. **Zones demoted to secondary (nice-to-have).** Multi-zone keymap editing becomes an + **opt-in "Zones" panel** (S10-Z), not the default face — "most of the time the zones + won't be used." The keyboard-strip drag machinery is still built, but in service of the + capture-first layout. + **What "better than RS5K" means, specifically (not vibes).** RS5K's genuine strengths — match or beat each: (1) **drag a file straight onto it** loads the sample (our S13 relay); (2) **note-start / note-end** range with a visual sense of the keyboard (our S10 keyboard @@ -1434,10 +1463,12 @@ strip — RS5K's own range UI is two number fields, so a *draggable* strip beats S12); (5) velocity layers / round-robin (Tier 2 — held, not in this overhaul). RS5K's real **weaknesses are our opening:** its **one-sample-per-instance** model forces track sprawl (one RS5K per drum) and it has **no multi-zone view in a single instance** — ReaSampler -9000 is multi-zone in one instrument by design (S5), so the keyboard-strip editor showing -*all* zones at once is a capability RS5K structurally lacks. "Better than RS5K" = -direct-manipulation where RS5K uses number fields, multi-zone where RS5K is one-shot, and -bank-integrated ingest where RS5K is file-at-a-time. +9000 is multi-zone in one instrument by design (S5), so the **opt-in Zones panel** showing +*all* zones at once is a capability RS5K structurally lacks. But per the reframe, the *default* +face is the single-capture fast path (browser + setup), and multi-zone is the demoted +nice-to-have. "Better than RS5K" = a fast single-capture browser where RS5K makes you drag a +file blind, direct-manipulation where RS5K uses number fields, multi-zone-when-you-want-it +where RS5K is one-shot, and bank-integrated ingest where RS5K is file-at-a-time. **Constraints (unchanged — settled, do not re-open):** LICE/SWELL drawing only (no toolkit change — D-A settled); **all layout/hit-test math in pure geometry modules** (mirror of @@ -1448,17 +1479,28 @@ stays a **read-only bank consumer** (loop/root/ADSR edits are the instrument's * map*, D-B — never written back to the bank); component-state persistence and read-only-over-bank stay settled. -- **S10 — direct-manipulation keymap editor.** A **piano-keyboard strip** along the top; - each zone a bar over the keys it covers. **Drag an edge** → change low/high note; **drag - the bar body** → move the zone (span preserved); **click a key** → set/relocate the - zone's root. Retires the seven ±1 nudge buttons per row. New pure `keyboard_strip` - geometry module (key-span↔pixel reusing the `embed_strip` idiom; edge-grab vs. body-move - hit regions; `pixel→note`; drag-delta resolver). Shell extends the click-only `wndProc` - to a `WM_MOUSEMOVE`/`WM_LBUTTONUP` drag-state machine with live feedback, one coherent - edit on release. **Built with the current LICE drawing; adopts the Phase L kit (L1) when - it lands** (drawn through the shared component kit rather than flat `LICE_FillRect`/GDI once - available) — **not gated on Phase L**; the drag machine's `WM_MOUSEMOVE` tracking also lights - the kit's hover states at near-zero marginal cost once the kit is present. +- **S10 — capture-first editor: browser + guided single-capture setup (REVISED 2026-07-26).** + The default face is the **capture browser** (scannable cards with **peak thumbnails** from + the `Sample` peaks bank_model carries, name, root/key badge; **bank filter** over bank_book + banks) feeding a **guided single-capture setup** (root note, play-mode basics, level). + Fresh instance is **silent, nothing auto-selected** — the S4 first-sample fallback is + **removed** (empty stored id → silence + a "pick a capture" empty state). New pure modules: + `capture_browser` (card/grid layout + hit-test) and `keyboard_strip` (key-span↔pixel via + the `embed_strip` idiom; a **root marker** for the single loaded capture; `pixel→note`; + drag-delta resolver; per-zone bar rect + edge-grab hit regions for the opt-in Zones panel). + Shell extends the click-only `wndProc` to a `WM_MOUSEMOVE`/`WM_LBUTTONUP` drag-state + machine with live feedback, one coherent edit on release. **Multi-zone keymap editing is an + opt-in "Zones" panel (S10-Z), not the default** — the demoted nice-to-have; it reuses the + same strip geometry + drag machine (edge = resize, body = move, key = root) and retires the + seven ±1 nudge buttons per row. **Built with the current LICE drawing; adopts the Phase L + kit (L1) when it lands** (drawn through the shared component kit rather than flat + `LICE_FillRect`/GDI once available) — **not gated on Phase L**; the drag machine's + `WM_MOUSEMOVE` tracking also lights the kit's hover states at near-zero marginal cost once + the kit is present. **Boundary shifts (from the reframe):** the "sample list" S12 was to + scroll/search **is now this browser** — the card layout, peak thumbnails, and bank filter + are S10's; S12 keeps **scroll** + **type-to-filter search** *layered over* S10's browser + (bank filter picks the bank, search narrows within it). The waveform S11 makes loop-editable + is the same waveform S10 shows read-only for the picked single capture ("see it"). - **S11 — waveform view + draggable loop points.** Selecting a zone shows its sample's **waveform** (peaks via the existing `peaks` module over the shell's already-decoded PCM — no new decode/WAV path) with draggable **start/end/loop-start/loop-end** markers that @@ -1466,13 +1508,17 @@ read-only-over-bank stay settled. **per-zone loop override** (additive on `PerformanceZone`, same shape as `rootOverride`; seeded from the S2 bank intrinsic, never written back). Marker/waveform geometry pure (`frame↔pixel`, marker grab regions, clamp start≤end, zero-crossing snap helper). -- **S12 — scale + ergonomics.** The sample list **scrolls** (wheel + scrollbar) so a long - bank is fully reachable, and has a **type-to-filter search**. **Direct numeric entry** - for zone low/high/root (a click-to-type field over the strip, for precision the drag - can't hit). An **ADSR editor** — four draggable controls over the S3 `AdsrParams` (the - math already exists and is wired into the voice engine; today the envelope is a fixed - default). Scroll/search/slider/entry layout pure; ADSR + (implicitly) any exposed - parameters become per-instance component state (additive, version-bumped, back-compat). +- **S12 — scale + ergonomics.** **Scroll** (wheel + scrollbar) over **S10's capture + browser** so a bank longer than the panel is fully reachable, and a **type-to-filter + search** that narrows the cards by name, **composing with S10's bank filter** (bank filter + selects the bank; search narrows within it). *(Boundary shift from the 2026-07-26 reframe: + the browser card layout, peak thumbnails, and bank filter are now **S10's**; S12 = scroll + + search layered over that browser.)* **Direct numeric entry** for zone low/high/root (a + click-to-type field over the strip, for precision the drag can't hit — Zones-panel-scoped). + An **ADSR editor** — four draggable controls over the S3 `AdsrParams` (the math already + exists and is wired into the voice engine; today the envelope is a fixed default). + Scroll/search/slider/entry layout pure; ADSR + (implicitly) any exposed parameters become + per-instance component state (additive, version-bumped, back-compat). - **S13 — drop-to-load (the S8 relay, in the editor).** Dropping an OS file / media item **onto the editor window** ingests into the bank + assigns to this instance — the RS5K "drop a file straight on it" affordance. **The instrument does not ingest:** the editor's @@ -1482,12 +1528,15 @@ read-only-over-bank stay settled. degrade to the docked-`bank_panel` drop path with a clear affordance. Never inserts a timeline item (capture/placement separation intact). -**Sequencing (recommendation, argued below in this section's tail).** S10 first — the -nudge-button zone editor is the friction Daniel hits on *every* test pass, so removing it -buys the most felt improvement per unit work and de-risks the drag-state machine S11/S12 -reuse. S11 and S12 follow (both lean on S10's drag machine). S13 depends on S8's ingest -seam, so it sequences after S8. Against the queued engine work: **S10 should land before or -interleaved with S7 (stereo).** S7 is a real engine capability (stereo capture in true +**Sequencing (recommendation, argued below in this section's tail).** S10 first — under the +reframe it now carries the **whole felt win**: the empty-state / no-auto-select fix, the +capture browser (peak thumbnails, bank filter) that replaces the useless item list, and the +guided single-capture setup that retires the nudge buttons. This is the entire "the UX is +awful" wound, and time-to-first-note is the metric it moves. S11 (waveform + loop) and S12 +(scroll/search over the browser, numeric entry, ADSR) follow — both lean on S10's browser + +drag machine, and S11's waveform is the same surface S10 shows for the picked capture. S13 +depends on S8's ingest seam, so it sequences after S8. Against the queued engine work: **S10 +should land before or interleaved with S7 (stereo).** S7 is a real engine capability (stereo capture in true stereo) and touches the DSP Daniel smoke-tests — but the *reason* he'll keep smoke-testing is the editor, and today every test pass is taxed by the nudge-button UX. Fixing what he feels first (S10) makes every subsequent S7 test less painful; there is no hard dependency @@ -1502,17 +1551,28 @@ The MIDI-playback instrument's product name is **ReaSampler 9000**. The extensio Set by Daniel on DAW-testing the S1–S6 instrument, alongside the UX-overhaul directive. - **Propagate the display name** across user-visible surfaces: the VST3 class **display - name** string in the factory registration, the `IPlugView` editor **title band** - (currently "ReaSampler Instrument"), the **S6 embed-strip label**, and the Phase S docs. + name** string in the factory registration, the **factory vendor/name strings**, the + `IPlugView` editor **title band** (currently "ReaSampler Instrument"), the **S6 embed-strip + label**, and the Phase S docs. - **Do NOT change the VST3 class UID.** Instances in already-saved projects key off the class UID; changing it orphans every existing instance in every saved project. The UID is a forever-stable contract (mirror of the command-id / ext-state-namespace forever-stable - strings). The name change is **display-string-only** on the code side. -- **Fork S-NAME-1 (Daniel's call): binary filename.** Renaming the built module (e.g. - `reasampler_9000.vst3`) carries compat weight — REAPER partly keys a saved project's - plugin reference by filename, so a rename can force users to re-select the plugin on - existing instances. **Lean: keep the filename, change only display strings** (zero compat - weight; the name shows everywhere the user reads it). Flagged for Daniel, not decided. + strings). +- **S-NAME-1 SETTLED (Daniel, 2026-07-26): rename the binary filename too.** The on-disk + module name is renamed to match the product (e.g. `reasampler_9000.vst3`), not just the + display strings. Full rename surface: **CMake `OUTPUT_NAME`** on the second VST3 target, + the **factory vendor/name strings**, the **editor title**, and the **embed label**. The + **class UID stays locked** as the compat anchor. +- **Compat verification (must-DAW-verify before shipping the rename).** The working + assumption is that REAPER **rebinds a saved instance by its VST3 class UID, not by the + module filename** — so a filename rename with an unchanged UID keeps saved projects working. + **This is a to-verify assumption, not a confirmed fact:** a web check surfaced a + JUCE/VST3-replace-VST2 case suggesting REAPER's binding can be more nuanced than "UID only" + (an FXID match is involved), so it is not safe to assert UID-only rebinding from source. + **DAW-verify:** save a project with an instance under the old filename, rename the module, + reopen, and confirm the instance rebinds and restores its state. If REAPER keys partly on + filename, fall back to keeping the current filename (display-strings-only) and record that + as the shipped choice. ## REAPER / Steinberg API surface (verify all signatures) diff --git a/PLAN.md b/PLAN.md index f16d062..86560b1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -399,48 +399,107 @@ alongside S8. > `docs/product/visual-design-language.md` (all on `dev`). S10–S13 below build with the > current drawing and **adopt the L1 kit when it lands — not gated on Phase L.** -## S10 — direct-manipulation keymap editor ("ReaSampler 9000" UX overhaul, part 1) -**Goal:** Replace the ±1 nudge-button zone editor with a **piano-keyboard-strip zone -editor** where the keymap is edited by direct manipulation: a horizontal keyboard span -along the top, each zone drawn as a bar over the keys it covers; **drag a zone's edge to -change its low/high note, drag the bar's body to move the whole zone, click a key (or the -zone's root marker) to set the root note.** No number is ever set by clicking a button 40 -times. This is the friction Daniel feels every test pass — the current row of seven -mini-buttons per zone is the catastrophe. All layout/hit-test math is a **new pure -geometry module** (`keyboard_strip` — mirror of `mode_switch`/`editor_geometry`); the LICE -draw + drag-state machine is the editor shell. RT discipline untouched (edits still commit -off-thread via `commitMapAndReload`). CONTEXT.md §Phase S (ReaSampler 9000 UX — keymap -editor). -**Verify (in DAW):** a zone's range is set by **dragging its edges on the keyboard strip** -(not by nudge clicks); dragging a zone's body moves the whole range preserving its span; -clicking a key sets/relocates the zone's root (with the repitched result audible on the -next held note); overlapping zones render legibly and resolve first-match as before; -zone add/select/delete still work; the pure geometry module is CTest-green (edge-grab -regions, body-drag delta, key→note mapping, root hit-test) with no host types at its -boundary; the ±1 nudge-button row is gone. -**Depends on:** S5 (the `PerformanceMap`/zone model it edits), S1 (the LICE `IPlugView` -drag/event routing — extends the click-only `wndProc` to `WM_MOUSEMOVE`/`WM_LBUTTONUP`). -**Adopts the Phase L kit when available — not gated on Phase L.** S10 builds its keyboard -strip + zone bars with the current LICE drawing; when Phase L's L1 kit lands on `dev`, this -surface adopts it (the one source of drawing). The drag machine's `WM_MOUSEMOVE` tracking -also lights the kit's **hover** states at near-zero marginal cost once the kit is present. +## S10 — capture-first editor: browser + guided single-capture setup ("ReaSampler 9000" UX overhaul, part 1) +**Goal (REVISED 2026-07-26 — workflow-first reframe, Daniel):** Rebuild the editor's +default face around the **primary flow = one capture, fast**, not a keymap. A giant list of +"item" blocks is visually useless; most instances play a *single capture*, and zones are a +nice-to-have. So the default view is a **capture browser** (scannable cards with peak +thumbnails, name, root/key badge; **bank filter**) feeding a **guided single-capture setup** +(root note, play-mode basics, level) — and the keyboard strip serves the *single-capture* +case first (shows where the capture sits / its root). **Time-to-first-note is the metric.** +Multi-zone keymap editing is **demoted to an opt-in "Zones" panel** (S10-Z below), not the +default. The keyboard-strip drag machinery is still built here, but in service of the +capture-first layout. All layout/hit-test math is **pure geometry** (new `keyboard_strip` + +a `capture_browser` layout module — mirrors of `mode_switch`/`editor_geometry`); the LICE +draw + drag-state machine is the editor shell. RT discipline untouched (edits commit +off-thread via `commitMapAndReload`); the instrument stays a **read-only bank consumer**. +CONTEXT.md §Phase S (ReaSampler 9000 UX — capture-first editor). -- [ ] Pure `keyboard_strip` geometry module: map a MIDI key span across a strip width - (128 keys → pixels, reusing the S6 `embed_strip` key-span idiom); per-zone bar rect from - `[lowNote,highNote]`; **edge-grab hit regions** (a few px inside each end = resize handles, - the middle = move handle); `pixel→note` and a `keyAtPoint` for click-to-set-root; a - drag-delta resolver `(grabbedField, startNote, dxPixels) → newNote`. No VST3/REAPER/LICE - types at the boundary; unit-tested outside the DAW (edge grabs, body-move delta, key - mapping, clamps low≤high, boundary rounding). Mirror of `mode_switch`/`editor_geometry`. -- [ ] Editor shell: draw the keyboard strip + zone bars in LICE (house palette), and drive - a **drag-state machine** — `WM_LBUTTONDOWN` grabs an edge/body/key, `WM_MOUSEMOVE` - updates the in-flight zone against the pure resolver, `WM_LBUTTONUP` commits via the - existing `commitMapAndReload` (off-thread reload; RT path untouched). Live visual feedback - while dragging; a single undo-coherent edit on release. -- [ ] Click-to-set-root + zone move/resize replace the seven per-row nudge/delete - mini-buttons; delete stays as one affordance (a small × on the zone bar or a keystroke). - The `zoneHitTest`/±1 nudge path in `editor_geometry` is retired for the new strip (kept - only if a numeric fallback is wanted for accessibility — build-time residual, not a fork). +**Policy reversal — fresh instance is SILENT, nothing auto-selected (was S4).** The S4 +"first sample plays" fallback is **removed**: on open with no stored selection, the +instrument plays **nothing** and the editor shows a clear **empty state** ("pick a capture") +— it does not auto-play sample #1. Retires the `selectSample` first-sample fallback +(`sample_map.cpp` "No stored id → fall back to the FIRST sample") and the processor's +Tier-0 fallback that resolved it; an empty stored id now resolves to silence. A capture is +loaded when the user picks one (or via S13 drop-to-load / S8 ingest). This is a deliberate +reversal of the S4 convenience default, not a regression. + +**Verify (in DAW):** a fresh instance plays **nothing** and shows the "pick a capture" +empty state (no auto-play of sample #1); the capture browser draws **peak thumbnails** +(the `Sample` peaks bank_model already carries — same data the dock panel thumbnails use), +name, and a root/key badge where present, and is **filterable by bank** (bank_book named +banks); picking a capture loads it, shows it (waveform/peaks + its root on the keyboard +strip), and it plays repitched from its root; time-to-first-note is a pick-then-play, not a +list-scroll; the keyboard strip shows the single capture's root and is draggable to set it; +the pure geometry modules are CTest-green (browser card/grid layout + hit-test; strip +edge-grab/body-move/key→note) with no host types at their boundary; the ±1 nudge-button row +is gone. +**Depends on:** S4 (the selection state + reload path this reverses the fallback on), S1 +(the LICE `IPlugView` drag/event routing — extends the click-only `wndProc` to +`WM_MOUSEMOVE`/`WM_LBUTTONUP`), S5 (the `PerformanceMap`/zone model the opt-in Zones panel +edits — but the default face does not require a keymap). **Adopts the Phase L kit when +available — not gated on Phase L.** S10 builds its browser cards + keyboard strip with the +current LICE drawing; when Phase L's L1 kit lands on `dev`, this surface adopts it (the one +source of drawing). The drag machine's `WM_MOUSEMOVE` tracking also lights the kit's +**hover** states at near-zero marginal cost once the kit is present. + +- [ ] No-auto-select + empty state (the policy reversal): remove the `selectSample` + first-sample fallback (`sample_map.cpp`) and the processor's Tier-0 fallback that + consumed it — an empty stored selection resolves to **silence**, not sample #1. The + editor draws a clear **empty state** ("pick a capture" affordance) when nothing is + selected. Pure change is testable (empty id → `nullopt`); the empty-state draw is shell. +- [ ] Capture browser (pure layout + shell draw): grow `SampleChoice` to carry the + **peak thumbnail data** (from the `Sample` peaks bank_model already stores — the same + peaks the dock panel draws), the **root/key badge** (S2 `rootNote` intrinsic / the + optional musical key), and its bank. A new pure `capture_browser` module lays out + scannable **cards/rows** (card rect grid, thumbnail rect, hit-test a point → card) — no + host types at the boundary, unit-tested. The shell draws each card's peak thumbnail + + name + badge in LICE (house palette) and routes a click to select. +- [ ] Bank filter (pure + shell): a filter/tab strip over the browser that narrows the + drawn cards to a chosen bank_book bank (or "all"). Filter-tab layout + hit-test pure + (mirror of `mode_switch`); the active-filter state is transient UI state; the shell draws + the tabs and applies the filter to the card list. (Type-to-filter search folds in from + S12 — see S12's boundary note; a name-substring filter over the same card list.) +- [ ] Guided single-capture setup (the fast path): once a capture is picked, a prominent, + self-explanatory setup surface — **root note** (settable on the keyboard strip / typed), + **play-mode basics**, **level** — sized for the single-capture case, not a zone table. + Graphic and descriptive; the point is to get from pick → set → play with no hunting. +- [ ] Pure `keyboard_strip` geometry module (serves the single-capture case first): map a + MIDI key span across a strip width (128 keys → pixels, reusing the S6 `embed_strip` + key-span idiom); a **root marker** for the loaded capture; `pixel→note` and a `keyAtPoint` + for click-to-set-root; a drag-delta resolver `(grabbedField, startNote, dxPixels) → + newNote`; **per-zone bar rect** + **edge-grab hit regions** (resize handles vs. body + move-handle) for the opt-in Zones panel. No VST3/REAPER/LICE types at the boundary; + unit-tested (root marker, edge grabs, body-move delta, key mapping, clamps low≤high, + boundary rounding). Mirror of `mode_switch`/`editor_geometry`. +- [ ] Editor shell drag-state machine: `WM_LBUTTONDOWN` grabs a card / a key / a zone + edge-or-body, `WM_MOUSEMOVE` updates the in-flight edit against the pure resolver, + `WM_LBUTTONUP` commits via the existing `commitMapAndReload` (off-thread reload; RT path + untouched). Live visual feedback while dragging; a single undo-coherent edit on release. + +### S10-Z — Zones panel (opt-in multi-zone keymap editing; demoted from the default face) +The multi-zone keymap editor is now an **opt-in view/panel** ("Zones" toggle), not the +default. It reuses the same `keyboard_strip` geometry and drag-state machine: each zone a +bar over the keys it covers; **drag an edge** → low/high note; **drag the bar body** → move +the zone (span preserved); **click a key** → set/relocate the zone's root. This is the +capability RS5K structurally lacks (multi-zone in one instrument), kept as a *nice-to-have* +per Daniel's hierarchy — "most of the time the zones won't be used." Add/select/delete a +zone; overlapping zones render legibly and resolve first-match. The seven ±1 nudge/delete +mini-buttons are retired everywhere; delete is one affordance (a small × on the bar or a +keystroke). The `zoneHitTest`/±1 nudge path in `editor_geometry` is retired (a numeric +fallback for accessibility is a build-time residual, not a fork). +**Verify (in DAW):** the Zones panel is reachable via an explicit toggle (default view is +the capture browser + single-capture setup, not this); a zone's range is set by **dragging +edges** (not nudge clicks); body-drag moves the span; click-a-key sets the root (audible on +the next held note); zone add/select/delete work; the ±1 nudge row is gone. + +- [ ] "Zones" panel toggle (opt-in): the default editor face is the capture browser + + single-capture setup; a toggle reveals the multi-zone keymap editor. Toggle state is + transient UI state (or per-instance component state if it should persist — build-time + residual). +- [ ] Zone edit via the shared strip: draw the keyboard strip + zone bars in LICE, drive + the shared drag-state machine (edge = resize, body = move, key = root), commit via + `commitMapAndReload`. Zone add/select/delete as single affordances; ±1 nudge row gone. ## S11 — waveform view with draggable loop points (UX overhaul, part 2) **Goal:** Give each sample/zone a **waveform display** with **draggable start/end/loop @@ -464,6 +523,13 @@ CTest-green (px↔frame mapping, marker grab regions, clamp start≤end). (the zone the loop attaches to), S10 (shares the editor's drag-state machine + shell). The zero-crossing snap is a small pure helper over the decoded PCM. +> **Boundary note (S10 reframe, 2026-07-26):** the waveform view is now **central to the +> single-capture fast path**, not just per-zone. Selecting a capture in S10's browser shows +> its waveform (this is "see it" in pick → see it → play it); the loop-marker drag here +> extends that same waveform surface. S11's waveform draw is the same one S10's picked- +> capture view uses — build it once, S10 shows it read-only for the single capture, S11 adds +> the draggable loop markers. No renumber; S11 stays the loop-editing point. + - [ ] Pure waveform/marker geometry: `frame↔pixel` mapping across the waveform rect, marker x-position from a frame index, marker grab regions (start/end/loop-start/loop-end), drag-delta `(grabbedMarker, dxPixels) → newFrame` with clamps (start≤end, in-bounds). A @@ -495,14 +561,26 @@ sample is reachable; typing filters the list to matching names; a zone's low/hig be **typed** (not only dragged) via a click-to-edit field; the amp envelope's ADSR is **adjustable** (four draggable controls) and the change is audible + persists across project save/reopen (component state); the scroll/search/slider geometry is CTest-green. -**Depends on:** S10 (the editor shell + drag-state machine), S3 (the `AdsrParams` the ADSR -sliders drive — already wired into the voice engine; today they are fixed defaults), S5 -(the map the numeric fields edit). +**Depends on:** S10 (the editor shell + drag-state machine + the **capture browser** the +scroll/search now apply to), S3 (the `AdsrParams` the ADSR sliders drive — already wired +into the voice engine; today they are fixed defaults), S5 (the map the numeric fields edit). -- [ ] Scrollable, searchable sample list: a scroll offset (wheel + scrollbar drag) so long - banks are fully reachable; a type-to-filter search that narrows the drawn rows to matching - display names. Scroll/filter layout + hit-test is pure geometry (visible-row window, - scrollbar thumb rect, search-box rect); filter/scroll state is transient UI state. +> **Boundary note (S10 reframe, 2026-07-26):** the "sample list" S12 originally scrolled and +> searched **is now S10's capture browser** (cards with peak thumbnails, bank filter). What +> pulled INTO S10: the browser layout itself, the peak thumbnails, and the **bank filter** +> (a bank_book tab, distinct from name search). What stays in S12 and applies **to S10's +> browser**: (a) **scroll** for a bank longer than the panel, and (b) **type-to-filter +> search** (a name-substring narrow over the same cards, composing with S10's bank filter — +> bank filter picks the bank, search narrows within it). The scroll/search geometry is pure, +> layered over the `capture_browser` module S10 builds. Net: S12 = scroll + search over the +> S10 browser + numeric entry + ADSR; the browser *card* work is S10's. + +- [ ] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a + bank longer than the panel is fully reachable; a **type-to-filter search** that narrows + the drawn cards to matching display names, **composing with S10's bank filter** (bank + filter selects the bank; search narrows within it). Scroll/search layout + hit-test is + pure geometry (visible-card window, scrollbar thumb rect, search-box rect), layered over + S10's `capture_browser` module; filter/scroll state is transient UI state. - [ ] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip (LICE text-entry idiom or a SWELL edit control on the child HWND) so a precise note can be typed, not only dragged. Commits via `commitMapAndReload` like every other edit. @@ -564,13 +642,23 @@ is **ReaSampler 9000**. Framing + propagation surfaces: class **display name** string (in the factory registration), the `IPlugView` editor title band (currently "ReaSampler Instrument"), the S6 embed-strip label, and the Phase S docs. **Do NOT change the VST3 class UID** — instances in already-saved projects key off it; a - UID change orphans every existing instance (fork below). -- [ ] **Fork S-NAME-1 (Daniel's call): the binary filename.** Renaming the built VST3 - module (e.g. `reasampler_9000.vst3`) is a compat break — REAPER keys a saved project's - plugin reference partly by filename, so a rename can require users to re-select the plugin - on existing instances. Lean: **keep the current filename, change only display strings** - (zero compat weight, the name shows everywhere the user reads), unless Daniel wants the - on-disk name to match. Flag, do not decide. + UID change orphans every existing instance. +- [ ] **Rename the binary filename too (S-NAME-1 SETTLED, Daniel 2026-07-26):** rename the + built VST3 module (CMake `OUTPUT_NAME` / target artifact — e.g. `reasampler_9000.vst3`) + alongside the display strings, so the on-disk name matches the product name. Record the + full rename surface: **CMake output name** (the second VST3 target's artifact name), the + **factory vendor/name strings**, the **`IPlugView` editor title**, and the **S6 embed + label**. Do NOT touch the **VST3 class UID** (unchanged — the compat anchor). +- [ ] **Compat verification (must-DAW-verify before shipping the rename):** the working + assumption is that REAPER **rebinds a saved instance by its VST3 class UID, not by the + module filename**, so a filename rename with an unchanged UID keeps saved projects working + (existing instances still resolve). **This is not yet confirmed from source** — a web + check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's binding is more nuanced + than "UID only" (it can involve an FXID match), so treat UID-rebind as **to-verify, not + asserted fact**. **DAW-verify:** save a project with a ReaSampler 9000 instance under the + old filename, rename the module, reopen — confirm the instance rebinds and restores its + state. If REAPER does key partly on filename, fall back to keeping the current filename + (display-strings-only) and record that as the shipped choice. ## S17 — drop-and-load: drag a capture onto a track's FX button → instantiate ReaSampler 9000 with the capture loaded **Goal:** Turn a bank capture into a playable instrument in one gesture. Today a drag diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index 31d03d7..e488dee 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -7,10 +7,15 @@ the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN *why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled decision record). -Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r5)**. r5 records -the post-DAW-test directives on the S1–S6 instrument: the product name **ReaSampler 9000** -and the **"better than RS5K" UX overhaul** (Phase S points S10–S13) — see the Addendum in -§4. r4 (below) settled the four residual forks D-A..D-D. The +Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r6)**. r6 records the +**workflow-first reframe of S10** (Daniel, 2026-07-26): the editor's default face becomes a +**capture browser + guided single-capture setup**, a fresh instance is **silent with a "pick +a capture" empty state** (reversing the S4 first-sample auto-play), and multi-zone editing is +demoted to an opt-in Zones panel — see the r6 Addendum in §4. r6 also settles **S-NAME-1** +(rename the binary filename too, UID locked). r5 records the post-DAW-test directives on the +S1–S6 instrument: the product name **ReaSampler 9000** and the **"better than RS5K" UX +overhaul** (Phase S points S10–S13) — see the r5 Addendum in §4. r4 (below) settled the four +residual forks D-A..D-D. The "no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D" collides with the existing Design View phase). **PLAN.md §Phase S is now the @@ -676,8 +681,9 @@ Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-beari orphans every existing instance. The name change is **display-string-only** on the code side. *Fork S-NAME-1 (Daniel's call):* the on-disk **binary filename** — renaming it (`reasampler_9000.vst3`) carries compat weight (REAPER keys a saved project's plugin -reference partly by filename), so the lean is **keep the filename, change only display -strings**; flagged, not decided. +reference partly by filename), so the r5 lean was **keep the filename, change only display +strings**; flagged, not decided. *(Now SETTLED in r6, below: rename the filename too, UID +locked, compat is a DAW-verify — the r5 lean is superseded.)* **The UX bar is "better than ReaSamplOMatic5000."** Verdict verbatim: "okay it works, but the UX is awful." The S1–S6 editor was spike-grade — a clickable list, zone rows with @@ -711,6 +717,59 @@ is no hard ordering, but the live wound is the editor. Honest counter: if the st *sound* is what blocks real use, S7-first is defensible — but "it works, the UX is awful" names the editor as the wound. +### Addendum — S10 workflow-first reframe + S-NAME-1 settled (Daniel, 2026-07-26, r6) + +After the r5 UX-overhaul directive was specced (keymap-first S10), Daniel reframed the +workflow before S10 was implemented. This **revises S10** and settles S-NAME-1. Settled +directions, not open forks — recorded here per the doc's settled-decisions convention; PLAN.md +§S10 and CONTEXT.md §Phase S (workflow hierarchy) carry the spec. + +**The reframe, verbatim (Daniel, 2026-07-26):** *"We need to think hard about the workflow +with this plugin. Have a giant list of 'item' blocks is visually useless. When the plugin is +loaded, we should not have any samples selected. We also need to show the peaks for each +capture. Filters for a specific bank would be useful. We need to be graphic and descriptive +with the controls, and guide the user QUICKLY towards setting up a sampler. Most of the time +the zones won't be used, but it's a nice-to-have. So we should optimize the UX for working +with individual captures, not a huge list of everything."* + +**What changed in S10 (the hierarchy is Daniel's; details are product judgment):** + +1. **Primary flow = one capture, fast.** The metric is **time-to-first-note**: open → pick a + capture → see it → play it. The default face serves the single capture, not a keymap. +2. **Fresh instance is SILENT — nothing auto-selected (policy reversal of S4).** The S4 + "first sample plays" convenience is **removed**: open with no stored selection → the + instrument plays nothing and shows a **"pick a capture" empty state**, not auto-play of + sample #1. Concretely retires the `selectSample` first-sample fallback (`sample_map.cpp`) + and the processor's Tier-0 fallback that resolved it (an empty stored id → silence). This + is a deliberate reversal of the S4 default, recorded as such — not a regression. +3. **Capture browser, not an item list.** Scannable cards with **peak thumbnails** (the + `Sample` peaks bank_model already carries — the same data the dock panel thumbnails draw), + name, root/key badge, and a **bank filter** (bank_book named banks). "A giant list of item + blocks" is the named anti-pattern; the browser is designed for scanning by eye. `SampleChoice` + grows to carry the peaks + badge + bank (today it is only `{id, displayName}`). +4. **Graphic, descriptive controls with a guided fast path.** Once a capture is picked, a + prominent single-capture setup surface (root note, play-mode basics, level); the keyboard + strip serves the single-capture case first (shows the capture's root). +5. **Zones demoted to an opt-in "Zones" panel (S10-Z), not the default face.** "Most of the + time the zones won't be used." The keyboard-strip drag machinery is still built (it serves + both the single-capture root-set and the opt-in zoning), but multi-zone editing is behind a + toggle. Some S12 list ergonomics **pulled into S10**: the browser card layout, peak + thumbnails, and bank filter are S10's; S12 keeps **scroll** + **type-to-filter search** + layered over the S10 browser. S11's waveform is the same surface S10 shows for the picked + capture ("see it"). No renumber — S11/S12/S13 keep their numbers and their boundaries were + annotated, not moved wholesale. + +**S-NAME-1 → SETTLED: rename the binary filename too.** The r5 lean (keep the filename, +display-strings-only) is superseded. The on-disk module is renamed to match the product (e.g. +`reasampler_9000.vst3`) — full surface: **CMake `OUTPUT_NAME`**, factory vendor/name strings, +editor title, embed label. The **VST3 class UID stays locked** as the compat anchor. +**Compat is a DAW-verify, not an asserted fact:** the working assumption is REAPER rebinds a +saved instance by class UID (not filename), so a rename with an unchanged UID keeps saved +projects working — but a web check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's +binding can be more nuanced than "UID only" (an FXID match is involved), so UID-only rebinding +is **not** safe to assert from source. Verify by save-rename-reopen in the DAW; if REAPER keys +partly on filename, fall back to keeping the filename and record that as shipped. + --- ## Where this landed @@ -738,11 +797,14 @@ Two further directions set post-scoping (2026-07-26; see the Addendum in §4): Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Addendum in §4): -7. **Product name → `ReaSampler 9000`** (display-string-only; VST3 class UID unchanged; - binary filename = fork S-NAME-1, Daniel's call). -8. **UX overhaul → "better than RS5K"** (**S10–S13**): direct-manipulation keyboard-strip - keymap editor (S10), waveform + draggable loop points (S11), scale/ergonomics — scroll, - search, direct numeric entry, ADSR (S12), and drop-to-load folding in the S8 relay (S13). +7. **Product name → `ReaSampler 9000`** (VST3 class UID unchanged; **binary filename renamed + too — S-NAME-1 SETTLED r6**, compat is a DAW-verify). +8. **UX overhaul → workflow-first, "better than RS5K"** (**S10–S13**; S10 **reframed r6**): + S10 = **capture browser (peak thumbnails + bank filter) + guided single-capture setup**, + **silent-on-open / no auto-select** (reverses S4), multi-zone editing demoted to an opt-in + Zones panel (S10-Z); S11 waveform + draggable loop points; S12 scroll/search over the S10 + browser + numeric entry + ADSR; S13 drop-to-load folding in the S8 relay. Metric: + time-to-first-note. 9. **Visual design language → modern/sleek, system-wide — moved to its own Phase L (2026-07-26).** The look-and-feel work (a shared LICE drawing kit + the surfaces that adopt it) was originally drafted here as Phase S points S0-DS + S14; it has been **lifted From 7151e19432b04ef5691939cf472bf99868e72fa0 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:36:36 -0400 Subject: [PATCH 21/49] =?UTF-8?q?S10:=20capture-first=20ReaSampler=209000?= =?UTF-8?q?=20editor=20=E2=80=94=20browser,=20single-capture=20setup,=20zo?= =?UTF-8?q?nes=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse S4 auto-select (empty=silence+empty state), add peak-thumbnail capture browser + bank filter + keyboard-strip drag machine, v3 component state (selection + zones), and the S-NAME-1 filename/display rename (UID locked). MSVC min/max macro collisions fixed post-implementation; 26/26 tests green. --- CMakeLists.txt | 46 +- src/vst/capture_browser.cpp | 96 ++++ src/vst/capture_browser.h | 92 ++++ src/vst/keyboard_strip.cpp | 118 +++++ src/vst/keyboard_strip.h | 121 +++++ src/vst/reasampler_editor.cpp | 760 +++++++++++++++++++++++-------- src/vst/reasampler_editor.h | 129 ++++-- src/vst/reasampler_embed.cpp | 7 +- src/vst/reasampler_processor.cpp | 49 +- src/vst/reasampler_processor.h | 13 +- src/vst/reasampler_vst.h | 12 +- src/vst/sample_map.cpp | 130 ++++-- src/vst/sample_map.h | 89 +++- tests/test_capture_browser.cpp | 203 +++++++++ tests/test_keyboard_strip.cpp | 201 ++++++++ tests/test_sample_map.cpp | 165 ++++++- 16 files changed, 1906 insertions(+), 325 deletions(-) create mode 100644 src/vst/capture_browser.cpp create mode 100644 src/vst/capture_browser.h create mode 100644 src/vst/keyboard_strip.cpp create mode 100644 src/vst/keyboard_strip.h create mode 100644 tests/test_capture_browser.cpp create mode 100644 tests/test_keyboard_strip.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0cd069d..d372965 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -642,6 +642,23 @@ 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) +# capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the +# capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly +# grid/tab arithmetic lives here, unit-tested outside the DAW; the editor shell draws each +# card's peak thumbnail + name + badge and routes clicks into it. Links editor_geometry for +# the shared Rect + contains(). NEITHER SDK. +add_library(capture_browser STATIC src/vst/capture_browser.cpp) +target_include_directories(capture_browser PUBLIC src/vst) +target_link_libraries(capture_browser PUBLIC editor_geometry) + +# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects + +# edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's +# keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of +# embed_strip; links editor_geometry for the shared Rect. NEITHER SDK. +add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp) +target_include_directories(keyboard_strip PUBLIC src/vst) +target_link_libraries(keyboard_strip PUBLIC editor_geometry) + add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) @@ -661,6 +678,14 @@ 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) +add_executable(capture_browser_tests tests/test_capture_browser.cpp) +target_link_libraries(capture_browser_tests PRIVATE capture_browser) +add_test(NAME capture_browser_tests COMMAND capture_browser_tests) + +add_executable(keyboard_strip_tests tests/test_keyboard_strip.cpp) +target_link_libraries(keyboard_strip_tests PRIVATE keyboard_strip) +add_test(NAME keyboard_strip_tests COMMAND keyboard_strip_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -763,8 +788,13 @@ endif() # (VSTGUI/examples/tests are NOT). After `git submodule update --init vendor/vst3sdk`: # cd vendor/vst3sdk && git submodule update --init pluginterfaces base public.sdk # =========================================================================== -if(WIN32) - set(VST3_SDK ${CMAKE_CURRENT_SOURCE_DIR}/vendor/vst3sdk) +set(VST3_SDK ${CMAKE_CURRENT_SOURCE_DIR}/vendor/vst3sdk) +# The VST3 module needs the nested vst3sdk slice (pluginterfaces / base / public.sdk) +# checked out — the one-time step documented above. When it is absent (a fresh clone +# that ran only the top-level `git submodule update --init`), skip the module rather than +# fail configure on missing sources: the pure geometry/mapping libraries + their CTest +# targets still build and test without the SDK. Probe one representative source file. +if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # --- 5a) The bounded slice of the Steinberg VST3 SDK this spike needs. -------- # Enumerated (not add_subdirectory of the whole SDK) to keep the build hermetic and @@ -832,12 +862,18 @@ if(WIN32) # app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so # the instrument reads the SAME namespace the extension writes; its PUBLIC include dir # (build/generated) carries version_generated.h for the channel bit. + # capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip + # geometry the capture-first editor draws + hit-tests against; both link editor_geometry + # transitively (shared Rect). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sample_map capture_paths embed_strip app_version) + sample_map capture_paths embed_strip app_version capture_browser keyboard_strip) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) - # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. + # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk + # product name (S-NAME-1, SETTLED 2026-07-26): reasampler_9000.vst3, matching the + # "ReaSampler 9000" display strings. The VST3 class UID (reasampler_vst.h) is unchanged + # — the compat anchor a saved instance rebinds by (save-rename-reopen is a DAW-verify). set_target_properties(reasampler_vst PROPERTIES PREFIX "" SUFFIX ".vst3" - OUTPUT_NAME "reasampler_vst") + OUTPUT_NAME "reasampler_9000") endif() diff --git a/src/vst/capture_browser.cpp b/src/vst/capture_browser.cpp new file mode 100644 index 0000000..a36f63e --- /dev/null +++ b/src/vst/capture_browser.cpp @@ -0,0 +1,96 @@ +// capture_browser.cpp — see capture_browser.h. Pure math; no host types. + +#include "capture_browser.h" + +#include + +namespace reasampler::vst { + +namespace { + +// The left edge of tab i in a strip of the given x-origin and width divided into `count` +// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same +// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly. +int tabEdge(int x, int width, int i, int count) { + return x + (i * width) / count; +} + +} // namespace + +BrowserLayout layoutBrowser(int w, int h) { + const int cw = std::max(0, w); + const int ch = std::max(0, h); + + BrowserLayout out; + const int tabH = std::min(kBrowserTabHeight, ch); + out.tabStrip = Rect{0, 0, cw, tabH}; + out.grid = Rect{0, tabH, cw, ch}; + + const int gridW = std::max(0, out.grid.width()); + out.columns = std::max(1, gridW / kBrowserCardWidth); + return out; +} + +Rect cardCellRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const int cols = std::max(1, layout.columns); + const int col = index % cols; + const int row = index / cols; + const int left = layout.grid.left + col * kBrowserCardWidth; + const int top = layout.grid.top + row * kBrowserCardHeight; + return Rect{left, top, left + kBrowserCardWidth, top + kBrowserCardHeight}; +} + +Rect cardContentRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const Rect cell = cardCellRect(layout, index); + return Rect{cell.left + kBrowserCardGutter, cell.top + kBrowserCardGutter, + cell.right - kBrowserCardGutter, cell.bottom - kBrowserCardGutter}; +} + +Rect cardThumbnailRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const Rect content = cardContentRect(layout, index); + const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height())); + return Rect{content.left, content.top, content.right, content.top + thumbH}; +} + +Rect cardLabelRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const Rect content = cardContentRect(layout, index); + const Rect thumb = cardThumbnailRect(layout, index); + return Rect{content.left, thumb.bottom, content.right, content.bottom}; +} + +int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) { + if (cardCount <= 0) return -1; + if (!contains(layout.grid, x, y)) return -1; + const int cols = std::max(1, layout.columns); + const int col = (x - layout.grid.left) / kBrowserCardWidth; + const int row = (y - layout.grid.top) / kBrowserCardHeight; + if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone) + const int index = row * cols + col; + if (index < 0 || index >= cardCount) return -1; + // Only a hit inside the card CONTENT counts — a click in the inter-card gutter misses. + if (!contains(cardContentRect(layout, index), x, y)) return -1; + return index; +} + +Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) { + if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{}; + const Rect& strip = layout.tabStrip; + const int left = tabEdge(strip.left, std::max(0, strip.width()), index, tabCount); + const int right = tabEdge(strip.left, std::max(0, strip.width()), index + 1, tabCount); + return Rect{left, strip.top, right, strip.bottom}; +} + +int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) { + if (tabCount <= 0) return -1; + if (!contains(layout.tabStrip, x, y)) return -1; + for (int i = 0; i < tabCount; ++i) { + if (contains(filterTabRect(layout, tabCount, i), x, y)) return i; + } + return -1; +} + +} // namespace reasampler::vst diff --git a/src/vst/capture_browser.h b/src/vst/capture_browser.h new file mode 100644 index 0000000..864f378 --- /dev/null +++ b/src/vst/capture_browser.h @@ -0,0 +1,92 @@ +// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default +// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3, +// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / +// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is +// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail + +// name + root/key badge and routes clicks into these functions. +// +// The browser replaces the old text item-list (the named anti-pattern). It lays out N +// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab +// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This +// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the +// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this +// module only "where does card i draw" / "what did the user click". +// +// Scroll is NOT here (S12 layers it over this module). The browser lays out every card +// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping +// scroll out keeps this module the stable card/tab geometry S12 builds on. +// +// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom). + +#pragma once + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a +// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list. +inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height +inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter) +inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter) +inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card +inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card + +// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to +// the area so a degenerate (tiny/zero) size never yields an inverted rect. +struct BrowserLayout { + Rect tabStrip; // top: the bank-filter tabs + Rect grid; // below the tabs: where the capture cards tile + int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width() +}; + +// Divide a (w x h) browser area into its regions and compute the column count. Pure: same +// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never +// exceeds the area); the grid takes the rest. columns = max(1, grid.width()/cardWidth) so a +// browser narrower than one card still lays out a single column. A zero/negative size +// yields empty rects + columns==1. +BrowserLayout layoutBrowser(int w, int h); + +// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then +// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect +// insets it to the drawable card. Rows past the visible grid are still computed (the shell +// clips at paint time). A negative index yields an empty rect. Pure. +Rect cardCellRect(const BrowserLayout& layout, int index); + +// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides. +// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure. +Rect cardContentRect(const BrowserLayout& layout, int index); + +// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top +// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the +// name + badge go in the remaining strip below. Pure. +Rect cardThumbnailRect(const BrowserLayout& layout, int index); + +// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band. +// The shell draws the display name + root/key badge here. Pure. +Rect cardLabelRect(const BrowserLayout& layout, int index); + +// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside +// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT +// rect counts as a hit — a click in the inter-card gutter is a miss. Pure. +int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y); + +// --- Bank-filter tabs -------------------------------------------------------- +// +// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch): +// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount == +// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell +// supplies the labels and tracks which tab is active. A tab click narrows the card list to +// that bank (the shell filters its SampleChoice list before laying out cards). + +// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal +// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with +// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an +// empty rect. Pure. +Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index); + +// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the +// tab strip. Pure. +int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y); + +} // namespace reasampler::vst diff --git a/src/vst/keyboard_strip.cpp b/src/vst/keyboard_strip.cpp new file mode 100644 index 0000000..77b848f --- /dev/null +++ b/src/vst/keyboard_strip.cpp @@ -0,0 +1,118 @@ +// keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types. + +#include "keyboard_strip.h" + +#include + +namespace reasampler::vst { + +namespace { + +int clampNote(int n) { + if (n < 0) return 0; + if (n > kStripKeyCount - 1) return kStripKeyCount - 1; + return n; +} + +// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given +// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer +// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling +// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX). +int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { + if (keyEdge <= 0) return bandLeft; + if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth; + return bandLeft + (keyEdge * bandWidth) / kStripKeyCount; +} + +} // namespace + +StripLayout layoutStrip(int w, int h) { + const int cw = std::max(0, w); + const int ch = std::max(0, h); + StripLayout out; + out.keys = Rect{0, 0, cw, ch}; + return out; +} + +int keyLeftX(const StripLayout& layout, int note) { + const Rect& band = layout.keys; + const int bandWidth = std::max(0, band.width()); + // note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to + // get a key's right edge, and 128 maps to the band right. + const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); + return keyEdgeToX(band.left, bandWidth, edge); +} + +Rect keyRect(const StripLayout& layout, int note) { + const int n = clampNote(note); + const int leftX = keyLeftX(layout, n); + const int rightX = keyLeftX(layout, n + 1); + return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; +} + +Rect rootMarkerRect(const StripLayout& layout, int rootNote) { + return keyRect(layout, rootNote); +} + +int keyAtPoint(const StripLayout& layout, int x, int y) { + const Rect& band = layout.keys; + if (!contains(band, x, y)) return -1; + const int bandWidth = std::max(0, band.width()); + if (bandWidth <= 0) return -1; + // Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide + // the pixel offset back to a key; clamp defensively (a point on band.right-1 maps to 127). + const int offset = x - band.left; + int note = (offset * kStripKeyCount) / bandWidth; + return clampNote(note); +} + +Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) { + int lo = clampNote(lowNote); + int hi = clampNote(highNote); + if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts + const int leftX = keyLeftX(layout, lo); + const int rightX = keyLeftX(layout, hi + 1); + return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; +} + +ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) { + const Rect bar = zoneBarRect(layout, lowNote, highNote); + if (!contains(bar, x, y)) return ZoneGrab::kNone; + + const int barW = bar.width(); + // A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so + // a click exactly on the midpoint resizes low (deterministic). + if (barW < 2 * kStripEdgeGrabWidth) { + const int mid = bar.left + barW / 2; + return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge; + } + if (x < bar.left + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge; + if (x >= bar.right - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge; + return ZoneGrab::kBody; +} + +ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs, + int count, int x, int y) { + if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{}; + if (!contains(layout.keys, x, y)) return ZoneBarHit{}; + for (int i = 0; i < count; ++i) { + const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y); + if (g != ZoneGrab::kNone) return ZoneBarHit{i, g}; + } + return ZoneBarHit{}; // on the band but on no bar +} + +int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) { + if (dxPixels == 0) return clampNote(startNote); + const int bandWidth = std::max(0, layout.keys.width()); + if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion + // Key width in pixels (>= 1 via the max). Round the delta to the nearest key so a + // half-key drag flips at the key centre: add/subtract half a key before the divide. + const int keyW = std::max(1, bandWidth / kStripKeyCount); + const int half = keyW / 2; + const int shift = dxPixels >= 0 ? (dxPixels + half) / keyW + : -((-dxPixels + half) / keyW); + return clampNote(startNote + shift); +} + +} // namespace reasampler::vst diff --git a/src/vst/keyboard_strip.h b/src/vst/keyboard_strip.h new file mode 100644 index 0000000..9b6a846 --- /dev/null +++ b/src/vst/keyboard_strip.h @@ -0,0 +1,121 @@ +// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first +// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. +// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle + +// note-mapping arithmetic lives here so it is unit-tested outside the DAW, while the +// editor shell (reasampler_editor.cpp) draws the strip and marshals mouse events into +// these functions. +// +// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span +// idiom embed_strip uses). It serves TWO faces of the S10 editor: +// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on +// the strip, click-a-key (or drag the marker) sets the capture's root note; and +// * the opt-in ZONES panel (S10-Z, demoted): each performance zone drawn as a bar over +// the keys it covers, with edge-grab resize handles + a body move-handle so a drag +// sets low/high (edges) or moves the span (body), and a key-click sets the zone root. +// +// All interaction resolves through the pure DRAG-DELTA resolver here: the shell captures +// a grab on WM_LBUTTONDOWN, feeds each WM_MOUSEMOVE's pixel delta back through +// resolveDragNote, and commits the resolved note(s) on WM_LBUTTONUP. Live feedback is the +// shell re-drawing the in-flight note; one coherent edit lands on release. +// +// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), +// so this header depends on editor_geometry.h rather than redefining a rectangle type. + +#pragma once + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named +// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay +// independent — the editor strip may grow octave labels/metrics the embed strip never does. +inline constexpr int kStripKeyCount = 128; + +// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started +// within this many pixels of the bar's left/right edge resizes that edge; a drag started +// anywhere else on the bar moves the whole span. A zone narrower than 2*this has no body +// move-handle (both edges win their halves) — deliberate: a 1-key zone is all edges. +inline constexpr int kStripEdgeGrabWidth = 6; + +// The strip's regions, derived from the (w x h) band the shell allots it. The keys band +// takes the whole area today (a future octave-label lane can carve a sub-band here without +// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect. +struct StripLayout { + Rect keys; // the key band: the 128-key span maps linearly across keys.width() +}; + +// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or +// negative size yields empty rects (no inversion). +StripLayout layoutStrip(int w, int h); + +// The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key +// span maps linearly across keys.width(); key N occupies the half-open pixel range +// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's +// right edge (so a key's right edge is keyLeftX(note+1)). Pure. +int keyLeftX(const StripLayout& layout, int note); + +// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note), +// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range) +// note clamps to [0,127]. Pure. +Rect keyRect(const StripLayout& layout, int note); + +// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`, +// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point +// so the shell's intent (this is the root marker, not just any key) reads at the call site, +// and so a future marker shape (a triangle over the key) has one place to change. Pure. +Rect rootMarkerRect(const StripLayout& layout, int rootNote); + +// The MIDI note a point (x, y) lands on, or -1 for a point outside the keys band. Backs +// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure. +int keyAtPoint(const StripLayout& layout, int x, int y); + +// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote] +// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes +// clamp to [0,127] and low clamps to <= high, so a malformed zone yields an in-band +// (possibly zero-width) rect, never an inverted one. Mirrors embed_strip::zoneSegmentRect. +// Pure. +Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote); + +// Which part of a zone bar a grab landed on. The shell uses this to decide what a drag +// edits: an edge resizes that boundary; the body moves the whole span; none means the grab +// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a +// deselect). +enum class ZoneGrab { + kNone, // the point is not on this zone's bar + kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low + kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high + kBody, // on the bar but not an edge -> move the whole span +}; + +// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the +// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within +// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth) +// resolves the near half to each edge (no body). The LOW edge wins a tie at the exact +// midpoint of a narrow bar (deterministic). Pure. +ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y); + +// The zone (index into `lows`/`highs`, draw order) whose bar a grab at (x, y) lands on, +// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in +// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The +// arrays are parallel (lows[i]/highs[i] is zone i's inclusive range); `count` is their +// length. Pure — no host containers at the boundary (a raw pointer pair, like +// embed_strip::zoneAtPoint). +struct ZoneBarHit { + int zoneIndex = -1; + ZoneGrab grab = ZoneGrab::kNone; +}; +ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs, + int count, int x, int y); + +// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time +// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note +// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to +// [0,127]. keyWidth is derived from the layout (band width / 128); a zero-width band pins +// the result to startNote (no motion). This is the single arithmetic behind edge-resize, +// body-move (apply to both edges with the SAME delta so the span is preserved), and +// root-marker drag. Pure — rounding is to the nearest key so a half-key drag flips at the +// key centre. Returns startNote unchanged for dxPixels==0. +int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index b21b4fb..7ad1759 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -1,22 +1,30 @@ -// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge. -// Windows-only (D5); the whole file is guarded so a non-Windows build (not a target, -// but keeps the TU honest) degrades to the CPluginView defaults. +// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the +// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is +// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults. #include "reasampler_editor.h" +#include +#include +#include #include +#include -#include "editor_geometry.h" +#include "capture_browser.h" +#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "editor_geometry.h" // Rect, contains #include "ext_keys.h" +#include "keyboard_strip.h" +#include "peaks.h" // computeEnvelope #include "reaper_bridge.h" #include "reasampler_processor.h" +#include "reasampler_vst.h" // kPluginName (the editor title band) #include "sample_map.h" +#include "wav_trim.h" // parseWavLayout, extractFloatFrames #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM -// LICE — the same drawing stack bank_panel uses. On Windows LICE routes through native -// GDI; no SWELL needed for the child window itself. #include "wdltypes.h" #include "lice/lice.h" #endif @@ -27,91 +35,194 @@ namespace reasampler::vst { namespace { #ifdef _WIN32 -constexpr const wchar_t* kChildClassName = L"ReaSamplerVstEditor"; +constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; -// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads -// as the same tool. +// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry +// is in capture_browser / keyboard_strip). The title band names the plugin + a live +// readout; the toggle band carries the Browser/Zones switch; the setup band (single- +// capture face) hosts the keyboard strip + level readout under the browser. +constexpr int kTitleHeight = 24; +constexpr int kToggleHeight = 22; +constexpr int kSetupHeight = 96; // the single-capture setup surface (strip + labels) +constexpr int kStripBandHeight = 40; + +// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as +// the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.) const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255); -const LICE_pixel kColBtnBg = LICE_RGBA(44, 44, 48, 255); -const LICE_pixel kColBtnHitBg = LICE_RGBA(58, 96, 84, 255); -const LICE_pixel kColBtnBorder = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColCardBg = LICE_RGBA(44, 44, 48, 255); +const LICE_pixel kColCardSelBg = LICE_RGBA(48, 72, 64, 255); +const LICE_pixel kColCardBorder = LICE_RGBA(70, 70, 76, 255); +const LICE_pixel kColCardSelBorder = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColTabBg = LICE_RGBA(36, 36, 40, 255); +const LICE_pixel kColTabActiveBg = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255); +const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255); +const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255); const COLORREF kRgbText = RGB(210, 230, 220); +const COLORREF kRgbDim = RGB(140, 150, 146); -const LICE_pixel kColZoneSelBg = LICE_RGBA(48, 72, 64, 255); -const LICE_pixel kColCtrlBg = LICE_RGBA(60, 60, 66, 255); - -void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { - // LICE has no built-in font handle here; use GDI text into the bitmap DC, matching - // bank_panel's drawCenteredText approach (SetTextColor + DrawText on getDC()). +void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col, + UINT fmt = DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX) { HDC dc = bmp->getDC(); SetBkMode(dc, TRANSPARENT); SetTextColor(dc, col); RECT gr{r.left, r.top, r.right, r.bottom}; - DrawTextA(dc, s, -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + DrawTextA(dc, s, -1, &gr, fmt | DT_END_ELLIPSIS); } void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { - HDC dc = bmp->getDC(); - SetBkMode(dc, TRANSPARENT); - SetTextColor(dc, col); - RECT gr{r.left, r.top, r.right, r.bottom}; - DrawTextA(dc, s, -1, &gr, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + drawText(bmp, r, s, col, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); } -// Clamp a MIDI note to [0, 127]. -int clampNote(int n) { return n < 0 ? 0 : (n > 127 ? 127 : n); } +// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the +// common DAW convention REAPER uses). +std::string noteLabel(int note) { + static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"}; + if (note < 0) note = 0; + if (note > 127) note = 127; + const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4 + return std::string(kNames[note % 12]) + std::to_string(octave); +} -// A short display name for a bank sample id, from the snapshotted list (id if unnamed, -// "?" if the id no longer resolves — e.g. a stale zone). +// Draw a mono peak envelope centered vertically in `r` (mirror of bank_panel::drawThumbnail, +// single channel). Each bin is a vertical line from its min to its max about the midline. +void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) { + if (r.width() <= 0 || r.height() <= 0 || env.empty() || env[0].empty()) return; + const ChannelEnvelope& ch = env[0]; + const int mid = r.top + r.height() / 2; + const int halfH = r.height() / 2; + const int bins = static_cast(ch.size()); + for (int x = 0; x < r.width() && x < bins; ++x) { + const MinMax& mm = ch[static_cast(x)]; + const int yTop = mid - static_cast(mm.max * halfH); + const int yBot = mid - static_cast(mm.min * halfH); + LICE_Line(bmp, r.left + x, yTop, r.left + x, yBot, kColThumb, 1.0f, 0, false); + } +} + +// A display name for a bank sample id from the snapshotted list ("?" if the id no longer +// resolves — e.g. a zone naming a deleted sample). std::string sampleLabel(const std::vector& samples, const std::string& id) { for (const SampleChoice& c : samples) { if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; } - return "?"; // stale: id not in the live bank + return "?"; +} + +// The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin +// per horizontal pixel. +int thumbBins(const BrowserLayout& layout) { + return (std::max)(1, cardThumbnailRect(layout, 0).width()); } #endif } // namespace ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) : CPluginView(nullptr), processor_(processor) { - // Default view size; the host may resize (canResize() == true). - ViewRect r(0, 0, 420, 260); + // Default view size — sized to show a couple of card rows + the setup strip. + ViewRect r(0, 0, 560, 400); setRect(r); } -void ReaSamplerEditor::refreshSampleList() { +void ReaSamplerEditor::refreshFromBank() { // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). + thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks if (!processor_) { samples_.clear(); + banks_.clear(); + visible_.clear(); selectedId_.clear(); map_.zones.clear(); selectedZone_ = -1; return; } - auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - samples_ = banks ? listSamples(*banks) : std::vector{}; + auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banksJson ? listSamples(*banksJson) : std::vector{}; + banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; selectedId_ = processor_->selectedSampleId(); map_ = processor_->performanceMap(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; + // Drop a filter that names a bank no longer present. + if (!activeFilterBankId_.empty()) { + bool found = false; + for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; + if (!found) activeFilterBankId_.clear(); + } + rebuildVisible(); } -void ReaSamplerEditor::commitMapAndReload() { - // UI thread only. Publish the edited map to the processor, then rebuild the instrument - // off the audio thread (reloadFromBank bakes the zones into the live Keymap). +void ReaSamplerEditor::rebuildVisible() { + visible_.clear(); + for (const SampleChoice& s : samples_) { + if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_) + visible_.push_back(s); + } +} + +void ReaSamplerEditor::commitAndReload() { + // UI thread only. Publish the edited selection + zones to the processor, then rebuild + // the instrument off the audio thread (reloadFromBank bakes them into the live Keymap). if (!processor_) return; + processor_->setSelectedSampleId(selectedId_); processor_->setPerformanceMap(map_); processor_->reloadFromBank(); #ifdef _WIN32 - if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); + invalidate(); #endif } +const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { + const std::string key = sampleId + "|" + std::to_string(binCount); + auto it = thumbCache_.find(key); + if (it != thumbCache_.end()) return it->second; + + // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so + // resolve the path from the live bank blob (selectSample) and decode via the shared WAV + // parse — the mirror of the processor's decodeRelative. Every failure path caches an + // empty envelope so a broken/missing file is not re-decoded on every paint. + std::string relativePath; + Envelope env; + if (processor_) { + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; + } + if (!relativePath.empty()) { + const std::string projectDir = processor_->bridge().activeProjectDir(); + const std::string abs = resolveBankFile(projectDir, relativePath); + std::vector bytes; + std::ifstream f(abs, std::ios::binary | std::ios::ate); + if (f) { + const std::streamoff size = f.tellg(); + if (size > 0) { + f.seekg(0, std::ios::beg); + bytes.resize(static_cast(size)); + if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); + } + } + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + std::vector mono = + downmixToMono(interleaved, layout.channelCount); + env = computeEnvelope(mono, 1, mono.size(), + static_cast((std::max)(1, binCount))); + } + } + } + auto ins = thumbCache_.emplace(key, std::move(env)); + return ins.first->second; +} + ReaSamplerEditor::~ReaSamplerEditor() { #ifdef _WIN32 - // Defensive teardown: the host normally calls removed() (which destroys the child) - // before releasing us, but if we're destroyed while still attached, don't leak the - // window — mirror the create in attachedToParent(). if (childHwnd_) { DestroyWindow(childHwnd_); childHwnd_ = nullptr; @@ -132,17 +243,18 @@ tresult PLUGIN_API ReaSamplerEditor::canResize() { #ifdef _WIN32 +void ReaSamplerEditor::invalidate() { + if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); +} + void ReaSamplerEditor::attachedToParent() { - // systemWindow is the parent HWND the host created (CPluginView::attached set it - // from the void* parent when the type is HWND). HWND parent = static_cast(systemWindow); if (!parent) return; - HINSTANCE hInst = reinterpret_cast( - GetWindowLongPtr(parent, GWLP_HINSTANCE)); + HINSTANCE hInst = + reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); if (!hInst) hInst = GetModuleHandle(nullptr); - // Register the child window class once per module. static bool classRegistered = false; if (!classRegistered) { WNDCLASSW wc{}; @@ -155,17 +267,13 @@ void ReaSamplerEditor::attachedToParent() { classRegistered = true; } - // Snapshot the live bank so the first paint shows the sample list. - refreshSampleList(); + refreshFromBank(); const ViewRect& r = getRect(); - childHwnd_ = CreateWindowExW( - 0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(), - r.getHeight(), parent, nullptr, hInst, nullptr); + childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, + r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); if (childHwnd_) { - // Stash `this` so wndProc can route messages back to the instance. - SetWindowLongPtr(childHwnd_, GWLP_USERDATA, - reinterpret_cast(this)); + SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); } } @@ -177,15 +285,37 @@ void ReaSamplerEditor::removedFromParent() { } tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { - // Let CPluginView latch the new rect, then resize the child window to match so the - // LICE surface fills the host-provided seat. tresult res = CPluginView::onSize(newSize); if (childHwnd_ && newSize) { MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); + thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them } return res; } +// The client bands: title (top), toggle (below title), then the mode content. In the +// browser view the content is the browser grid on top of the single-capture setup band +// (when a capture is picked); in the zones view the content is the zones strip + list. +namespace { +struct EditorBands { + Rect title; + Rect toggleBrowser; // left half of the toggle band + Rect toggleZones; // right half + Rect content; // below the toggle band: the mode's own area +}; +EditorBands computeBands(int w, int h) { + EditorBands b; + const int titleH = (std::min)(kTitleHeight, h); + b.title = Rect{0, 0, w, titleH}; + const int toggleTop = titleH; + const int toggleBot = (std::min)(h, toggleTop + kToggleHeight); + b.toggleBrowser = Rect{0, toggleTop, w / 2, toggleBot}; + b.toggleZones = Rect{w / 2, toggleTop, w, toggleBot}; + b.content = Rect{0, toggleBot, w, h}; + return b; +} +} // namespace + void ReaSamplerEditor::paint(HDC hdc) { RECT cr{}; GetClientRect(childHwnd_, &cr); @@ -193,173 +323,425 @@ void ReaSamplerEditor::paint(HDC hdc) { const int h = cr.bottom - cr.top; if (w <= 0 || h <= 0) return; - // Same double-buffered LICE pattern as bank_panel::paintPanel: draw into a - // LICE_SysBitmap, then BitBlt to the window DC. LICE_SysBitmap bmp(w, h); LICE_Clear(&bmp, kColBackground); - const KeymapEditorLayout layout = layoutKeymapEditor(w, h); - const EditorLayout& base = layout.base; + const EditorBands bands = computeBands(w, h); - // Title band: the plugin name + a live-state / mode readout. - LICE_FillRect(&bmp, base.titleBar.left, base.titleBar.top, base.titleBar.width(), - base.titleBar.height(), kColTitleBg, 1.0f, 0); - std::string title = "ReaSampler Instrument"; + // Title band: product name + live readout. + LICE_FillRect(&bmp, bands.title.left, bands.title.top, bands.title.width(), + bands.title.height(), kColTitleBg, 1.0f, 0); + std::string title = kPluginName; if (processor_ && processor_->bridge().isConnected()) { - if (samples_.empty()) { - title += " [bank: empty]"; - } else if (map_.zones.empty()) { - title += " [Tier 0: pick a sample - Add Zone for a keymap]"; - } else { - title += " [keymap: " + std::to_string(map_.zones.size()) + " zone(s)]"; - } + if (samples_.empty()) title += " [bank empty]"; + else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]"; + else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; + else title += " [" + sampleLabel(samples_, selectedId_) + "]"; } else { title += " [host: no bridge]"; } - Rect titleText{base.titleBar.left + 8, base.titleBar.top, base.titleBar.right - 8, - base.titleBar.bottom}; + Rect titleText{bands.title.left + 8, bands.title.top, bands.title.right - 8, + bands.title.bottom}; drawText(&bmp, titleText, title.c_str(), kRgbText); - // LEFT column: the bank-sample list. The selected id (fallback + "sample to add" for - // a new zone) is highlighted. Clip rows past the visible list. - for (int i = 0; i < static_cast(samples_.size()); ++i) { - const Rect row = keymapSampleRowRect(layout, i); - if (row.top >= layout.sampleList.bottom) break; - const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_; - LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(), - sel ? kColBtnHitBg : kColBtnBg, 1.0f, 0); - if (sel) { - LICE_DrawRect(&bmp, row.left, row.top, row.width() - 1, row.height() - 1, - kColBtnBorder, 1.0f, 0); - } - Rect textR{row.left + 8, row.top, row.right - 8, row.bottom}; - const std::string& name = samples_[i].displayName; - drawText(&bmp, textR, name.empty() ? samples_[i].id.c_str() : name.c_str(), - kRgbText); - } + // Toggle band: Browser | Zones. + const bool inZones = (view_ == View::kZones); + LICE_FillRect(&bmp, bands.toggleBrowser.left, bands.toggleBrowser.top, + bands.toggleBrowser.width(), bands.toggleBrowser.height(), + inZones ? kColTabBg : kColTabActiveBg, 1.0f, 0); + LICE_FillRect(&bmp, bands.toggleZones.left, bands.toggleZones.top, + bands.toggleZones.width(), bands.toggleZones.height(), + inZones ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(&bmp, bands.toggleBrowser, "Browser", kRgbText); + drawTextCentered(&bmp, bands.toggleZones, "Zones", kRgbText); - // RIGHT column: the zone panel. "Add Zone" button on top, then one row per zone. - LICE_FillRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top, - layout.addZoneButton.width(), layout.addZoneButton.height(), kColBtnBg, - 1.0f, 0); - LICE_DrawRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top, - layout.addZoneButton.width() - 1, layout.addZoneButton.height() - 1, - kColBtnBorder, 1.0f, 0); - drawTextCentered(&bmp, layout.addZoneButton, "+ Add Zone", kRgbText); - - // The seven per-row controls, laid out left-to-right pinned to the right edge. - const char* kCtrlGlyphs[7] = {"-", "+", "-", "+", "-", "+", "x"}; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - const Rect row = zoneRowRect(layout, i); - if (row.top >= layout.zoneRowArea.bottom) break; // past the visible panel - const bool sel = (i == selectedZone_); - LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(), - sel ? kColZoneSelBg : kColBtnBg, 1.0f, 0); - - const PerformanceZone& z = map_.zones[i]; - std::string label = sampleLabel(samples_, z.sampleId); - label += " " + std::to_string(z.lowNote) + "-" + std::to_string(z.highNote); - label += " r" + (z.rootOverride ? std::to_string(*z.rootOverride) + "*" - : std::string("(bank)")); - // Label area stops where the control block begins (7 * ctrl width from the right). - const int ctrlBlockLeft = row.right - 7 * kZoneCtrlWidth; - Rect textR{row.left + 6, row.top, ctrlBlockLeft - 4, row.bottom}; - drawText(&bmp, textR, label.c_str(), kRgbText); - - // Draw the 7 mini-buttons. - for (int s = 0; s < 7; ++s) { - const int bx = ctrlBlockLeft + s * kZoneCtrlWidth; - Rect cell{bx, row.top + 2, bx + kZoneCtrlWidth - 1, row.bottom - 2}; - LICE_FillRect(&bmp, cell.left, cell.top, cell.width(), cell.height(), - kColCtrlBg, 1.0f, 0); - drawTextCentered(&bmp, cell, kCtrlGlyphs[s], kRgbText); - } + if (view_ == View::kZones) { + paintZones(&bmp, w, h); + } else { + paintBrowser(&bmp, w, h); } BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } -void ReaSamplerEditor::onClick(int x, int y) { +void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { + // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from + // a bank filter that hides everything. Either way it is the "pick a capture" empty state. + const char* msg = samples_.empty() + ? "No captures in this project yet — capture audio into the bank to play it here." + : "No captures in this bank filter. Choose another bank tab above."; + drawTextCentered(bmp, area, msg, kRgbDim); +} + +void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { + const EditorBands bands = computeBands(w, h); + // When a capture is picked, the setup band takes the bottom; the browser gets the rest. + const bool havePick = !selectedId_.empty(); + const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) + : bands.content.bottom; + const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + + // The browser tabs + card grid, laid out by the pure module over the browser sub-area. + // capture_browser lays out from (0,0); offset the draw by browserArea's origin. + const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); + const int ox = browserArea.left; + const int oy = browserArea.top; + + // Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights. + const int tabCount = static_cast(banks_.size()) + 1; + for (int i = 0; i < tabCount; ++i) { + Rect t = filterTabRect(bl, tabCount, i); + t = Rect{t.left + ox, t.top + oy, t.right + ox, t.bottom + oy}; + const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; + const bool active = (i == 0) ? activeFilterBankId_.empty() + : (banks_[static_cast(i - 1)].id == activeFilterBankId_); + LICE_FillRect(bmp, t.left, t.top, t.width(), t.height(), + active ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(bmp, t, label.c_str(), kRgbText); + } + + // Cards: one per visible sample. Clip at the browser area bottom (scroll is S12). + const int bins = thumbBins(bl); + for (int i = 0; i < static_cast(visible_.size()); ++i) { + Rect content = cardContentRect(bl, i); + if (content.top + oy >= browserArea.bottom) break; // past the visible grid + Rect thumb = cardThumbnailRect(bl, i); + Rect labelR = cardLabelRect(bl, i); + content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy}; + thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy}; + labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy}; + + const SampleChoice& s = visible_[static_cast(i)]; + const bool sel = (s.id == selectedId_); + LICE_FillRect(bmp, content.left, content.top, content.width(), content.height(), + sel ? kColCardSelBg : kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1, + sel ? kColCardSelBorder : kColCardBorder, 1.0f, 0); + drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); + + // Name + root/key badge under the thumbnail. + std::string caption = s.displayName.empty() ? s.id : s.displayName; + Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2}; + Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom}; + drawText(bmp, nameR, caption.c_str(), kRgbText); + std::string badge; + if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); + else if (s.key) badge = *s.key; + else badge = "root —"; + drawText(bmp, badgeR, badge.c_str(), kRgbDim); + } + + if (havePick) { + paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom}); + } else if (visible_.empty()) { + paintEmptyState(bmp, browserArea); + } +} + +void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { + // The guided single-capture setup: the picked capture's name + root/level, and a + // keyboard strip with its root marker (drag to set root). + LICE_FillRect(bmp, area.left, area.top, area.width(), area.height(), + kColTitleBg, 1.0f, 0); + + // Effective root: the picked sample's rootNote intrinsic (or middle C when unset). + int root = 60; + for (const SampleChoice& s : visible_) { + if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; + } + // If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride; + } + + const int pad = 8; + Rect headerR{area.left + pad, area.top + 4, area.right - pad, area.top + 22}; + std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root); + drawText(bmp, headerR, header.c_str(), kRgbText); + + Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16}; + drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim); + + // Keyboard strip with the root marker. + const int stripTop = area.bottom - kStripBandHeight; + Rect stripArea{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int sx = stripArea.left; + const int sy = stripArea.top; + LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(), + kColStripBg, 1.0f, 0); + // Faint per-octave key ticks for orientation. + for (int n = 0; n <= 127; n += 12) { + Rect k = keyRect(sl, n); + LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey, + 1.0f, 0, false); + } + Rect marker = rootMarkerRect(sl, root); + LICE_FillRect(bmp, marker.left + sx, sy, (std::max)(2, marker.width()), stripArea.height(), + kColRootMarker, 1.0f, 0); +} + +void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { + const EditorBands bands = computeBands(w, h); + const int pad = 8; + + // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip + // with one bar per zone. Delete is a small × on the selected zone (keystroke also). + Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, + bands.content.top + 4 + 20}; + LICE_FillRect(bmp, addR.left, addR.top, addR.width(), addR.height(), kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, addR.left, addR.top, addR.width() - 1, addR.height() - 1, + kColCardSelBorder, 1.0f, 0); + drawTextCentered(bmp, addR, "+ Add Zone", kRgbText); + + Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; + if (selectedZone_ >= 0) { + LICE_FillRect(bmp, delR.left, delR.top, delR.width(), delR.height(), kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, delR.left, delR.top, delR.width() - 1, delR.height() - 1, + kColCardSelBorder, 1.0f, 0); + drawTextCentered(bmp, delR, "Delete", kRgbText); + } + + // The zones strip. + const int stripTop = addR.bottom + 12; + Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, + stripTop + kStripBandHeight}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int sx = stripArea.left; + const int sy = stripArea.top; + LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(), + kColStripBg, 1.0f, 0); + for (int n = 0; n <= 127; n += 12) { + Rect k = keyRect(sl, n); + LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey, + 1.0f, 0, false); + } + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + Rect bar = zoneBarRect(sl, z.lowNote, z.highNote); + const bool sel = (i == selectedZone_); + LICE_FillRect(bmp, bar.left + sx, sy, (std::max)(2, bar.width()), stripArea.height(), + sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0); + } + + // A one-line legend of the selected zone below the strip. + Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26}; + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) + + " – " + noteLabel(z.highNote) + " root " + + (z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)")); + drawText(bmp, infoR, info.c_str(), kRgbText); + } else if (map_.zones.empty()) { + drawText(bmp, infoR, + "No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim); + } +} + +// --- Input: the drag-state machine ------------------------------------------- + +void ReaSamplerEditor::onMouseDown(int x, int y) { if (!processor_) return; RECT cr{}; GetClientRect(childHwnd_, &cr); - const KeymapEditorLayout layout = - layoutKeymapEditor(cr.right - cr.left, cr.bottom - cr.top); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + const EditorBands bands = computeBands(w, h); - // 1. Left list: pick the Tier-0 fallback / the sample a new zone will use. - const int row = - keymapSampleRowHitTest(layout, static_cast(samples_.size()), x, y); - if (row >= 0) { - processor_->setSelectedSampleId(samples_[row].id); - selectedId_ = samples_[row].id; - // A fallback change only affects playback when the map is empty; reload so the - // Tier-0 case updates immediately. - processor_->reloadFromBank(); - InvalidateRect(childHwnd_, nullptr, FALSE); + // Toggle band: switch views. + if (contains(bands.toggleBrowser, x, y)) { view_ = View::kBrowser; invalidate(); return; } + if (contains(bands.toggleZones, x, y)) { view_ = View::kZones; invalidate(); return; } + + if (view_ == View::kBrowser) { + const bool havePick = !selectedId_.empty(); + const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) + : bands.content.bottom; + const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); + const int bx = x - browserArea.left; + const int by = y - browserArea.top; + + // Filter tabs. + const int tabCount = static_cast(banks_.size()) + 1; + const int tab = filterTabHitTest(bl, tabCount, bx, by); + if (tab >= 0) { + activeFilterBankId_ = (tab == 0) ? std::string() + : banks_[static_cast(tab - 1)].id; + rebuildVisible(); + invalidate(); + return; + } + // Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). + const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by); + if (card >= 0) { + selectedId_ = visible_[static_cast(card)].id; + commitAndReload(); // publishes the pick + reloads; process() plays it repitched + return; + } + // The setup strip: grab the root marker (drag to set the picked capture's root). + if (havePick) { + const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + const int stripTop = area.bottom - kStripBandHeight; + const Rect stripArea{area.left + 8, stripTop, area.right - 8, area.bottom - 4}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); + if (note >= 0) { + drag_ = DragKind::kRootMarker; + dragStartX_ = x; + dragStartRoot_ = note; + // A click sets the root immediately (drag then refines); the override lives on + // a one-zone map entry for the picked capture (D-B, never written to the bank). + onMouseMove(x, y); // apply the click position as the first delta==0 set + return; + } + } return; } - // 2. "Add Zone": append a full-keyboard zone for the currently-selected sample (root - // from the bank intrinsic — no override until the user nudges it). - if (addZoneHitTest(layout, x, y)) { - if (selectedId_.empty()) return; // nothing selected to add + // Zones view. + const int pad = 8; + Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, + bands.content.top + 4 + 20}; + if (contains(addR, x, y)) { + // Append a full-keyboard zone for the picked capture (or the first visible sample as a + // sensible seed). No pick -> nothing to add. + std::string seed = !selectedId_.empty() ? selectedId_ + : (!visible_.empty() ? visible_.front().id : std::string()); + if (seed.empty()) return; PerformanceZone z; - z.sampleId = selectedId_; + z.sampleId = seed; z.lowNote = 0; z.highNote = 127; map_.zones.push_back(z); selectedZone_ = static_cast(map_.zones.size()) - 1; - commitMapAndReload(); + commitAndReload(); return; } - - // 3. Zone rows: select a zone, nudge its range/root, or delete it. - const ZoneHit hit = - zoneHitTest(layout, static_cast(map_.zones.size()), x, y); - if (hit.zoneIndex < 0) return; - selectedZone_ = hit.zoneIndex; - - if (hit.field == ZoneField::kZoneNone) { - InvalidateRect(childHwnd_, nullptr, FALSE); // select only, no reload - return; - } - if (hit.field == ZoneField::kDelete) { - map_.zones.erase(map_.zones.begin() + hit.zoneIndex); + Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; + if (selectedZone_ >= 0 && contains(delR, x, y)) { + map_.zones.erase(map_.zones.begin() + selectedZone_); selectedZone_ = -1; - commitMapAndReload(); + commitAndReload(); return; } - PerformanceZone& z = map_.zones[hit.zoneIndex]; - switch (hit.field) { - case ZoneField::kLowDown: z.lowNote = clampNote(z.lowNote - 1); break; - case ZoneField::kLowUp: z.lowNote = clampNote(z.lowNote + 1); break; - case ZoneField::kHighDown: z.highNote = clampNote(z.highNote - 1); break; - case ZoneField::kHighUp: z.highNote = clampNote(z.highNote + 1); break; - case ZoneField::kRootDown: { - const int base = z.rootOverride ? *z.rootOverride : 60; - z.rootOverride = clampNote(base - 1); // first nudge establishes the override - break; + // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the + // selected zone's root. + const int stripTop = addR.bottom + 12; + const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, + stripTop + kStripBandHeight}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int lx = x - stripArea.left; + const int ly = y - stripArea.top; + + std::vector lows, highs; + lows.reserve(map_.zones.size()); + highs.reserve(map_.zones.size()); + for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); } + const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(), + highs.empty() ? nullptr : highs.data(), + static_cast(map_.zones.size()), lx, ly); + if (hit.zoneIndex >= 0) { + selectedZone_ = hit.zoneIndex; + const PerformanceZone& z = map_.zones[static_cast(hit.zoneIndex)]; + dragStartX_ = x; + dragStartLow_ = z.lowNote; + dragStartHigh_ = z.highNote; + switch (hit.grab) { + case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break; + case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break; + case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break; + default: drag_ = DragKind::kNone; break; } - case ZoneField::kRootUp: { - const int base = z.rootOverride ? *z.rootOverride : 60; - z.rootOverride = clampNote(base + 1); - break; + invalidate(); + return; + } + // A bare key-click inside the strip sets the selected zone's root override. + if (contains(stripArea, x, y) && selectedZone_ >= 0 && + selectedZone_ < static_cast(map_.zones.size())) { + const int note = keyAtPoint(sl, lx, ly); + if (note >= 0) { + map_.zones[static_cast(selectedZone_)].rootOverride = note; + commitAndReload(); } - default: break; // kZoneNone/kDelete handled above } - // Keep low <= high after a range nudge (clamp the moved edge against its partner). - if (z.lowNote > z.highNote) { - if (hit.field == ZoneField::kLowUp) z.lowNote = z.highNote; - else if (hit.field == ZoneField::kHighDown) z.highNote = z.lowNote; +} + +void ReaSamplerEditor::onMouseMove(int x, int y) { + if (drag_ == DragKind::kNone) return; + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + const EditorBands bands = computeBands(w, h); + const int dx = x - dragStartX_; + + if (drag_ == DragKind::kRootMarker) { + // The single-capture root strip lives in the setup band. + const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); + const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + const Rect stripArea{area.left + 8, area.bottom - kStripBandHeight, area.right - 8, + area.bottom - 4}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int note = resolveDragNote(sl, dragStartRoot_, dx); + // The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone), + // so setting the single capture's root materializes a full-keyboard zone carrying the + // override. This plays identically to the un-zoned single-capture path (one chromatic + // zone over the whole keyboard) and round-trips through the v3 component state; the + // zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a + // repeated drag edits the same zone rather than stacking duplicates. + bool found = false; + for (PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) { z.rootOverride = note; found = true; break; } + } + if (!found) { + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.rootOverride = note; + map_.zones.push_back(z); + } + invalidate(); // live feedback; the commit lands on WM_LBUTTONUP + return; } - commitMapAndReload(); + + // Zone edits: recompute the grabbed field(s) against the pure resolver, live. + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + const int pad = 8; + const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12 + const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, + stripTop + kStripBandHeight}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (drag_ == DragKind::kZoneLow) { + z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote); + } else if (drag_ == DragKind::kZoneHigh) { + z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote); + } else if (drag_ == DragKind::kZoneBody) { + // Move the whole span: apply the SAME delta to both edges so the span is preserved, + // clamping so neither edge escapes [0,127] (the span shifts, never shrinks). + const int newLow = resolveDragNote(sl, dragStartLow_, dx); + const int newHigh = resolveDragNote(sl, dragStartHigh_, dx); + const int span = dragStartHigh_ - dragStartLow_; + if (newLow < 0) { z.lowNote = 0; z.highNote = span; } + else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; } + else { z.lowNote = newLow; z.highNote = newHigh; } + } + invalidate(); +} + +void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) { + if (drag_ == DragKind::kNone) return; + drag_ = DragKind::kNone; + // One coherent edit lands on release: publish the in-flight map + reload off-thread. + commitAndReload(); } LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { - auto* self = reinterpret_cast( - GetWindowLongPtr(hwnd, GWLP_USERDATA)); + auto* self = + reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); switch (msg) { case WM_PAINT: { PAINTSTRUCT ps{}; @@ -369,10 +751,22 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, return 0; } case WM_LBUTTONDOWN: - if (self) self->onClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + if (self) { + SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child + self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + } + return 0; + case WM_MOUSEMOVE: + if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_LBUTTONUP: + if (self) { + self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + ReleaseCapture(); + } return 0; case WM_ERASEBKGND: - return 1; // we fully repaint in WM_PAINT; skip the flicker-inducing erase + return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase default: return DefWindowProcW(hwnd, msg, wParam, lParam); } diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 3012a75..e492869 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -1,90 +1,135 @@ -// reasampler_editor.h — the VST3 IPlugView LICE editor (Phase S1 bridge spike). THIN -// shell: hosts a LICE-drawn child window inside the host's IPlugView seat and routes -// host paint/click into the pure editor_geometry hit-test. Windows-only (D5). +// reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000 +// capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the +// host's IPlugView seat and routes host paint/mouse into the pure geometry modules +// (capture_browser, keyboard_strip) + the pure mapping (sample_map). Windows-only (D5). // -// This is the phase's ONE genuine unknown (per CONTEXT.md §Phase S / S1): wiring LICE -// into an IPlugView. It reuses the bank_panel LICE/SWELL competence — a LICE_SysBitmap -// blitted in WM_PAINT, GET_X/Y_LPARAM hit-testing in WM_LBUTTONDOWN — but hangs it off a -// child HWND created in IPlugView::attached() rather than a docked SWELL dialog. +// The default face is the CAPTURE BROWSER: a bank-filter tab strip over a grid of +// scannable capture cards (peak thumbnail + name + root/key badge). A fresh instance with +// no pick shows a "pick a capture" EMPTY STATE and plays silence (the S10 policy reversal +// of the S4 first-sample auto-play). Picking a card loads that one capture and reveals a +// guided SINGLE-CAPTURE SETUP surface (a keyboard strip with the capture's root marker + +// a level readout). Multi-zone keymap editing is a demoted, opt-in ZONES panel (S10-Z), +// reached by a toggle and driven by the same keyboard_strip drag machine. // -// Subclasses CPluginView (public.sdk/source/common/pluginview.h) for the IPlugView -// refcount + attached/removed/getSize/onSize boilerplate; we override the attach/remove -// hooks to create/destroy the child window and onSize to resize it. +// All layout/hit-test/drag math lives in the pure modules; this shell only draws + routes +// (a LICE_SysBitmap blitted in WM_PAINT, a WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP +// drag-state machine hit-testing via the pure resolvers). Peak thumbnails are computed +// shell-side from the decoded WAV (bank_model's Sample carries no envelope) and cached — +// the mirror of bank_panel::thumbnailFor. Every edit commits OFF the audio thread via the +// processor's reloadFromBank (RT path untouched). +// +// Subclasses CPluginView for the IPlugView boilerplate; overrides the attach/remove hooks +// to create/destroy the child window and onSize to resize it. #pragma once +#include #include +#include #include #include "public.sdk/source/common/pluginview.h" -#include "sample_map.h" // SampleChoice (the list the editor draws) +#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) +#include "peaks.h" // Envelope (the cached peak thumbnail) +#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) #ifdef _WIN32 #include #endif +class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only in the .cpp + namespace reasampler::vst { class ReaSamplerProcessor; class ReaSamplerEditor : public Steinberg::CPluginView { public: - // `processor` owns this editor's lifetime domain and outlives it; the editor reads - // the live bank through it (the sample list) and drives selection + reload when the - // user clicks a row. May be null (defensive — a real host always supplies one). + // `processor` owns this editor's lifetime domain and outlives it; the editor reads the + // live bank through it and drives selection/zone edits + reload on user input. May be + // null (defensive — a real host always supplies one). explicit ReaSamplerEditor(ReaSamplerProcessor* processor); ~ReaSamplerEditor() override; - // Accept only the Windows HWND platform type (D5: Windows-only). Steinberg::tresult PLUGIN_API isPlatformTypeSupported( Steinberg::FIDString type) override; - - // The view is user-resizable in the spike so we exercise the onSize path. Steinberg::tresult PLUGIN_API canResize() override; protected: - // CPluginView hooks: systemWindow is set by the time attachedToParent() fires. void attachedToParent() override; void removedFromParent() override; - Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override; private: + // Which face the editor shows. The browser is the default; the Zones panel is the + // demoted opt-in view reached by the toggle. Both draw over the same snapshotted bank. + enum class View { kBrowser, kZones }; + + // What a mouse drag is currently editing (the drag-state machine). kNone = no drag in + // flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the + // single-capture root drag on the setup strip. + enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody }; + #ifdef _WIN32 - // Draw the current surface into the child window's DC via a LICE bitmap. void paint(HDC hdc); - // Route a client-space click: bank-sample pick (left list), zone edit (right panel), - // or the "Add Zone" button. - void onClick(int x, int y); + void paintBrowser(LICE_IBitmap* bmp, int w, int h); + void paintSetup(LICE_IBitmap* bmp, const Rect& area); + void paintZones(LICE_IBitmap* bmp, int w, int h); + void paintEmptyState(LICE_IBitmap* bmp, const Rect& area); + + void onMouseDown(int x, int y); + void onMouseMove(int x, int y); + void onMouseUp(int x, int y); static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); + void invalidate(); HWND childHwnd_ = nullptr; #endif - // Re-read the bank's sample list from the live bridge into `samples_`, and snapshot the - // instrument's performance map. Main/UI thread only (reads ext-state); called on attach - // and after any edit that reloads the instrument. - void refreshSampleList(); + // Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's + // selection + performance map. Main/UI thread only. Called on attach and after any edit. + void refreshFromBank(); - // Push the edited performance map to the processor and rebuild the instrument OFF the - // audio thread. UI thread only. Centralizes the "commit an edit" path so every zone - // mutation reloads identically. - void commitMapAndReload(); + // Publish the edited zones/selection to the processor, then rebuild the instrument OFF + // the audio thread. UI thread only. One place so every edit commits identically. + void commitAndReload(); + + // Recompute the capture cards visible under the current bank filter (samples_ narrowed by + // activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change. + void rebuildVisible(); + + // The peak thumbnail for a bank sample id at `binCount` bins, computed once from the + // decoded WAV (mirror of bank_panel::thumbnailFor) and cached by (id, binCount). Returns + // an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O). + const Envelope& thumbnailFor(const std::string& sampleId, int binCount); ReaSamplerProcessor* processor_ = nullptr; - // The bank's samples, snapshotted for the current paint. Refreshed off the audio - // thread; the paint just draws it. - std::vector samples_; - // The currently-selected sample id (Tier-0 fallback + the "sample to add" for a new - // zone), mirrored for the paint's highlight. - std::string selectedId_; - // The instrument's performance map, snapshotted for edit + paint. Edits mutate this - // copy then commit it to the processor via commitMapAndReload. - PerformanceMap map_; - // The zone row currently highlighted (for the paint); -1 = none. - int selectedZone_ = -1; + + // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- + std::vector samples_; // every bank sample, bank order + std::vector banks_; // the named banks, for the filter tab strip + std::vector visible_; // samples_ narrowed by the active bank filter + std::string selectedId_; // the single-capture pick ("" = empty state) + PerformanceMap map_; // the opt-in zones (empty = no zones) + + // --- Transient UI state (not persisted; component state carries selection + zones) --- + View view_ = View::kBrowser; // default face is the browser + std::string activeFilterBankId_; // "" = All; else a bank id from banks_ + int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none + + // --- Drag-state machine ------------------------------------------------------ + DragKind drag_ = DragKind::kNone; + int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver + int dragStartLow_ = 0; // the grabbed field's note at grab time + int dragStartHigh_ = 0; + int dragStartRoot_ = 60; + + // --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------ + // Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so + // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail. + std::unordered_map thumbCache_; }; } // namespace reasampler::vst diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp index 43d6e30..ca4b5b9 100644 --- a/src/vst/reasampler_embed.cpp +++ b/src/vst/reasampler_embed.cpp @@ -151,8 +151,9 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { const EmbedLayout layout = layoutEmbed(w, h); if (map_.zones.empty()) { - // No keymap authored yet: show a single faint band spanning the keymap area so the - // strip still reads as "present but empty" (Tier-0 fallback plays chromatically). + // No opt-in zones authored: show a single faint band spanning the keymap area so the + // strip reads as "present, no zones" — the default single-capture face lives in the + // editor (this S6 strip mirrors the zones map only). LICE_FillRect(bmp, layout.keymap.left, layout.keymap.top, layout.keymap.width(), layout.keymap.height(), kColEmpty, 0.5f, 0); HDC dc = bmp->getDC(); @@ -161,7 +162,7 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { RECT gr{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right, layout.keymap.bottom}; const std::string label = - samples_.empty() ? "ReaSampler (bank empty)" : "ReaSampler (no zones)"; + samples_.empty() ? "ReaSampler 9000 (bank empty)" : "ReaSampler 9000 (no zones)"; DrawTextA(dc, label.c_str(), -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); } else { diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index fc00d58..daca7c7 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -65,8 +65,8 @@ std::vector readFileBytes(const std::string& path) { // Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file // I/O — off-thread only), and downmix to the core's mono contract. Returns nullopt when // the path fails to resolve, the file is unreadable, the WAV is malformed, or the decode -// yields no frames — the caller drops the zone (Tier 1) or plays silence (Tier 0). Shared -// by the zoned build and the Tier-0 fallback so both decode identically. +// yields no frames — the caller drops the zone (zoned map) or plays silence (single capture). +// Shared by the zoned build and the single-capture path so both decode identically. std::optional decodeRelative(const std::string& projectDir, const std::string& relativePath) { const std::string abs = resolveBankFile(projectDir, relativePath); @@ -165,14 +165,16 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { bytes.insert(bytes.end(), chunk, chunk + got); } - // Component state IS the performance map (Tier 1, D-B). deserializePerformance lifts a - // v1 (S4 single-selection) blob to a one-zone map, so already-saved Tier-0 instances - // restore cleanly. When the restored map is empty, the instrument falls back to the - // Tier-0 first-sample in reloadFromBank; if any single-zone restored map is present we - // seed the fallback selection from it so the editor reflects the restored pick. - const PerformanceMap map = deserializePerformance(bytes); - setPerformanceMap(map); - if (map.zones.size() == 1) setSelectedSampleId(map.zones.front().sampleId); + // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The + // selection and the zones are DISTINCT — the default face is one picked capture, zones + // are a demoted overlay — so both are restored explicitly (no more inferring a selection + // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only + // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so + // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 + // silent empty state (no first-sample fallback in reloadFromBank). + const ComponentState cs = deserializeComponentState(bytes); + setSelectedSampleId(cs.selectionId); + setPerformanceMap(cs.map); // Rebuild from the restored state (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; @@ -180,10 +182,15 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; - // Persist the performance map (the instrument's own Tier-1 state, D-B — NEVER written - // to the "reasampler" bank ext-state). An empty map serializes to just the version + - // zero-count header (restores as empty -> Tier-0 fallback). - const std::vector bytes = serializePerformance(performanceMap()); + // Persist the full instance state (v3, S10): the single-capture selection id AND the + // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" + // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} + // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing + // sample #1. + ComponentState state_out; + state_out.selectionId = selectedSampleId(); + state_out.map = performanceMap(); + const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), static_cast(bytes.size()), nullptr); @@ -260,9 +267,13 @@ std::string ReaSamplerProcessor::reloadFromBank() { } } - // 3. Tier-0 fallback: an empty (or fully-unresolvable) performance map plays the - // selected fallback sample chromatically across the whole keyboard — preserving - // the S4 "the bank plays" behavior for an un-authored instrument. + // 3. Single-capture fast path (S10): an empty performance map plays the ONE + // deliberately-selected capture chromatically across the whole keyboard. This is + // the default face — one picked capture, repitched from its root. NO first- + // sample fallback: an EMPTY selection (or a stale id) resolves to nullopt in + // selectSample, so an un-picked instrument stays SILENT (the editor shows its + // "pick a capture" empty state) rather than auto-playing sample #1 (S10 policy + // reversal of the S4 convenience default). if (!haveKeymap) { std::optional sel = selectSample(*banksJson, selectedSampleId()); @@ -273,9 +284,7 @@ std::string ReaSamplerProcessor::reloadFromBank() { km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, sel->rootNote, sel->loop); haveKeymap = true; - // Record which id actually resolved so a first-sample fallback - // (empty stored id) becomes the concrete selection. - resolvedId = selectedSampleId(); + resolvedId = selectedSampleId(); // the concrete pick that resolved } } } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 67dda1b..d832d2a 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -115,10 +115,10 @@ public: // 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. In Tier 1 the selection is the - // Tier-0 FALLBACK sample (played chromatically when the performance map is empty); the - // zoned map, when non-empty, supersedes it. + // The current single-capture selection id (main/UI thread reads for the editor). Guarded + // by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked + // capture the default face plays chromatically when the performance map is empty; an EMPTY + // id resolves to SILENCE (no first-sample fallback). A non-empty zoned map supersedes it. std::string selectedSampleId(); void setSelectedSampleId(const std::string& id); @@ -166,8 +166,9 @@ private: std::vector graveyard_; // drained on reclaim + setActive(false) + terminate std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access - // The selected sample id (Tier-0 fallback sample). Off-thread only; a small mutex - // guards the string against a getState/editor race. NOT read on the audio thread. + // The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence). + // 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_; diff --git a/src/vst/reasampler_vst.h b/src/vst/reasampler_vst.h index 120311b..fc1b942 100644 --- a/src/vst/reasampler_vst.h +++ b/src/vst/reasampler_vst.h @@ -12,9 +12,15 @@ namespace reasampler::vst { -// Human-facing identity. "ReaSampler" is the tool; the instrument surfaces as -// "ReaSampler Instrument" in REAPER's FX browser to distinguish it from the extension. -inline constexpr const char* kPluginName = "ReaSampler Instrument"; +// Human-facing identity (S-NAME-1, SETTLED 2026-07-26). "ReaSampler" is the extension +// (capture + organization); the MIDI-playback instrument's product name is +// "ReaSampler 9000" — the string that surfaces in REAPER's FX browser, the factory +// display name, the editor title band, and the embed-strip label. The on-disk module is +// renamed to match (CMake OUTPUT_NAME reasampler_9000.vst3). The VST3 class UID below is +// the compat anchor and is NOT changed by the rename — a saved REAPER project rebinds a +// saved instance by class UID, so the display/filename rename keeps existing instances +// resolving (compat is a DAW-verify; see PLAN.md §S-NAME-1). +inline constexpr const char* kPluginName = "ReaSampler 9000"; inline constexpr const char* kVendorName = "ReaSampler"; inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/reasampler"; inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com"; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 6056268..cb6d8b9 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -39,28 +39,23 @@ SelectedSample distill(const Sample& s) { std::optional selectSample(const std::string& banksJson, const std::string& sampleId) { + // POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short- + // circuit before parsing — no stored id resolves to nothing to play by design. + if (sampleId.empty()) return std::nullopt; if (banksJson.empty()) return std::nullopt; std::optional book = BankBook::deserialize(banksJson); if (!book) return std::nullopt; // malformed -> nothing to play (never throw) // Search every bank (pool first, then named — banks() is ordinal order) for the // stored id. A sample lives in exactly one bank, so first hit wins. - if (!sampleId.empty()) { - for (const Bank& b : book->banks()) { - if (const Sample* s = b.index.query(sampleId)) { - return distill(*s); - } - } - } - - // No stored id, or the id no longer resolves (the sample was deleted/moved out): - // fall back to the FIRST sample in ordinal order so a fresh instance plays. for (const Bank& b : book->banks()) { - if (!b.index.all().empty()) { - return distill(b.index.all().front()); + if (const Sample* s = b.index.query(sampleId)) { + return distill(*s); } } - return std::nullopt; // bank has zero samples anywhere + // A stale stored id (no longer resolves) is SILENCE, not a substituted first sample: + // the editor reflects the missing pick with its empty state rather than masking it. + return std::nullopt; } std::vector listSamples(const std::string& banksJson) { @@ -70,12 +65,23 @@ std::vector listSamples(const std::string& 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}); + out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id}); } } return out; } +std::vector listBanks(const std::string& banksJson) { + std::vector out; + if (banksJson.empty()) return out; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; + for (const Bank& b : book->banks()) { + out.push_back(BankChoice{b.id, b.displayName}); + } + return out; +} + std::vector downmixToMono(const std::vector& interleaved, int channelCount) { std::vector out; @@ -211,11 +217,9 @@ struct ByteReader { int i32() { return static_cast(static_cast(u32())); } }; -} // namespace - -std::vector serializePerformance(const PerformanceMap& map) { - std::vector out; - putU32le(out, kPerformanceStateVersion); +// Append the zones payload (zone count + per-zone records) — the shared body of the v2 +// performance blob and the v3 component blob, so both write zones identically. +void putZonesPayload(std::vector& out, const PerformanceMap& map) { putU32le(out, static_cast(map.zones.size())); for (const PerformanceZone& z : map.zones) { putU32le(out, static_cast(z.sampleId.size())); @@ -228,6 +232,33 @@ std::vector serializePerformance(const PerformanceMap& map) { static_cast(static_cast(*z.rootOverride))); } } +} + +// Read a zones payload (zone count + per-zone records) from `r` into `map`. Shared by the +// v2 performance parse and the v3 component parse. A truncated mid-zone read keeps the zones +// that parsed cleanly and drops the rest; the reader position is left after the last byte +// read successfully. +void readZonesPayload(ByteReader& r, PerformanceMap& map) { + const std::uint32_t count = r.u32(); + for (std::uint32_t i = 0; i < count && r.ok; ++i) { + PerformanceZone z; + const std::uint32_t idLen = r.u32(); + z.sampleId = r.str(idLen); + z.lowNote = r.i32(); + z.highNote = r.i32(); + const std::uint8_t hasOverride = r.u8(); + if (hasOverride) z.rootOverride = r.i32(); + if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest + map.zones.push_back(std::move(z)); + } +} + +} // namespace + +std::vector serializePerformance(const PerformanceMap& map) { + std::vector out; + putU32le(out, kPerformanceStateVersion); + putZonesPayload(out, map); return out; } @@ -252,21 +283,58 @@ PerformanceMap deserializePerformance(const std::vector& bytes) { } if (version != kPerformanceStateVersion) return map; // unknown -> empty - const std::uint32_t count = r.u32(); - for (std::uint32_t i = 0; i < count && r.ok; ++i) { - PerformanceZone z; - const std::uint32_t idLen = r.u32(); - z.sampleId = r.str(idLen); - z.lowNote = r.i32(); - z.highNote = r.i32(); - const std::uint8_t hasOverride = r.u8(); - if (hasOverride) z.rootOverride = r.i32(); - if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest - map.zones.push_back(std::move(z)); - } + readZonesPayload(r, map); return map; } +// --- Combined component state (v3, S10) -------------------------------------- + +std::vector serializeComponentState(const ComponentState& state) { + std::vector out; + putU32le(out, kComponentStateVersion); + // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — + // unlike the v1 selection blob where the id ran to end-of-stream). + putU32le(out, static_cast(state.selectionId.size())); + out.insert(out.end(), state.selectionId.begin(), state.selectionId.end()); + putZonesPayload(out, state.map); + return out; +} + +ComponentState deserializeComponentState(const std::vector& bytes) { + ComponentState out; + ByteReader r(bytes); + const std::uint32_t version = r.u32(); + if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state) + + // BACK-COMPAT: an older blob predates the v3 {selection, zones} split. + // * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard + // zone} so the old pick survives as BOTH the selection and a one-zone map. + // * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate + // single-capture selection. + if (version == kSelectionStateVersion) { + out.selectionId = deserializeSelection(bytes); + if (!out.selectionId.empty()) { + PerformanceZone z; + z.sampleId = out.selectionId; + z.lowNote = 0; + z.highNote = 127; + out.map.zones.push_back(std::move(z)); + } + return out; + } + if (version == kPerformanceStateVersion) { + readZonesPayload(r, out.map); // v2 body starts right after the version tag + return out; + } + if (version != kComponentStateVersion) return out; // unknown -> empty + + const std::uint32_t idLen = r.u32(); + out.selectionId = r.str(idLen); + if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty + readZonesPayload(r, out.map); + return out; +} + std::vector serializeSelection(const std::string& sampleId) { std::vector out; out.resize(4 + sampleId.size()); diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 6cc794c..ad2b696 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -44,28 +44,50 @@ struct SelectedSample { // // Precedence, all pure: // * empty / malformed banksJson -> nullopt (nothing to play) +// * sampleId empty -> nullopt (NO selection -> silence) // * 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 +// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved; +// the editor returns to the empty state) // -// 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. +// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh +// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first +// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play +// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns +// nullopt rather than silently substituting a different sample — the editor reflects the +// missing selection with its "pick a capture" empty state instead of masking it. std::optional selectSample(const std::string& banksJson, const std::string& sampleId); -// All (id, displayName) pairs across every bank in ordinal order (pool first), for the -// selection UI to list. Empty for an empty / malformed blob. Pure projection over the -// shared parse — the UI never parses JSON itself. +// One entry in the capture browser's card list: the stable id + display name plus the S2 +// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge, +// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded +// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache, +// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already +// holds: the metadata the card badge + bank filter need. Pure projection over the shared +// parse — the UI never parses JSON itself. +// +// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the +// badge shows "root: —" / no root, never a guessed value). +// - key: the optional human musical key label ("F#m"), when the bank set it. +// - bankId: the id of the bank this sample lives in (the bank filter matches on it). struct SampleChoice { std::string id; std::string displayName; + std::optional rootNote; + std::optional key; + std::string bankId; }; std::vector listSamples(const std::string& banksJson); +// One bank the filter tab strip offers: its stable id + display name, in ordinal order +// (pool first). The browser prepends an "All" tab (no id) shell-side. Empty for an empty / +// malformed blob. Pure projection over the shared parse. +struct BankChoice { + std::string id; + std::string displayName; +}; +std::vector listBanks(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, @@ -175,7 +197,12 @@ Keymap buildZonedKeymap(const std::vector& zones, // BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is // lifted to a single full-keyboard zone playing that id (no override) — so an instance // saved under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob -// deserializes to an EMPTY map (the instrument falls back to Tier-0 first-sample). +// deserializes to an EMPTY map. +// +// These two functions serialize the ZONES only. Since S10 the instrument's full component +// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState +// below, the v3 format the processor actually reads/writes. serializePerformance/ +// deserializePerformance are retained for the v3 zones payload + the v1/v2 back-compat lift. inline constexpr std::uint32_t kPerformanceStateVersion = 2; @@ -186,6 +213,38 @@ std::vector serializePerformance(const PerformanceMap& map); // directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map. PerformanceMap deserializePerformance(const std::vector& bytes); +// --- Combined component state (VST3 setState/getState, v3 — S10) ------------- +// +// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that +// BOTH persist: the default face is one picked capture (the selection id), and zones are a +// demoted opt-in overlay (the performance map). The component state carries both so a saved +// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an +// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty +// state), never auto-playing sample #1. +// +// Format (v3): 4-byte LE version tag (== 3), then a 4-byte LE selection-id length + +// id bytes, then the v2 zones payload (4-byte LE zone count + per-zone records, identical +// to serializePerformance's body). BACK-COMPAT on read: +// * v3 blob -> {selectionId, zones} parsed directly. +// * v2 blob -> {"", zones}: an S5 instance had zones but no separate selection. +// * v1 blob -> {id, one full-keyboard zone}: the S4 single-selection lift, so the +// old pick survives as both the selection AND a one-zone map. +// * empty/unknown -> {"", no zones}: EMPTY (the S10 silent empty state). +struct ComponentState { + std::string selectionId; // the single-capture pick; "" = no pick (empty state) + PerformanceMap map; // the opt-in zones; empty = no zones +}; + +inline constexpr std::uint32_t kComponentStateVersion = 3; + +// The full instance state serialized to bytes for IBStream (getState). +std::vector serializeComponentState(const ComponentState& state); + +// The full instance state parsed back from IBStream bytes (setState). Tolerant of +// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table +// above so already-saved instances restore cleanly. +ComponentState deserializeComponentState(const std::vector& bytes); + // --- Instance state (VST3 setState/getState) -------------------------------- // // The instrument's OWN state is which bank sample it plays (D-B: the selection is a @@ -196,8 +255,10 @@ PerformanceMap deserializePerformance(const std::vector& bytes); // 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. +// by returning "" (no selection — under the S10 policy reversal an empty selection is +// SILENCE + the "pick a capture" empty state, not the bank's first sample), never +// throwing across the host boundary. Retained for the v1→v3 back-compat lift in +// deserializeComponentState; the processor's live state is the v3 ComponentState above. inline constexpr std::uint32_t kSelectionStateVersion = 1; diff --git a/tests/test_capture_browser.cpp b/tests/test_capture_browser.cpp new file mode 100644 index 0000000..9f8a7a0 --- /dev/null +++ b/tests/test_capture_browser.cpp @@ -0,0 +1,203 @@ +// Standalone tests for reasampler::vst::capture_browser — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests (embed_strip / editor_geometry): assert +// the capture-first browser's card-grid + bank-filter-tab layout and hit-testing directly. +// +// Covers: layoutBrowser splitting an area into the tab strip + card grid and deriving the +// column count; a tiny/zero area (no inversion, columns >= 1); cardCellRect / cardContentRect +// / cardThumbnailRect / cardLabelRect tiling row-major across columns with the gutter inset +// and the thumbnail band above the label; cardHitTest landing on the card content (and MISSING +// in the inter-card gutter, past the last card, and on the tab strip); filterTabRect dividing +// the strip into equal segments with the last tab absorbing the remainder; filterTabHitTest +// hitting each tab and missing off-strip. + +#include "../src/vst/capture_browser.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- layoutBrowser ------------------------------------------------------------ + +static void testLayoutNormalArea() { + // Wide enough for several columns of the fixed-width card. + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(L.tabStrip.left == 0 && L.tabStrip.top == 0 && L.tabStrip.right == 560); + CHECK(L.tabStrip.height() == kBrowserTabHeight); + // The grid starts right below the tab strip and fills the rest, contiguous. + CHECK(L.grid.top == L.tabStrip.bottom); + CHECK(L.grid.bottom == 300 && L.grid.right == 560); + // columns = grid.width() / cardWidth (>= 1). + CHECK(L.columns == 560 / kBrowserCardWidth); + CHECK(L.columns >= 1); +} + +static void testLayoutNarrowAreaSingleColumn() { + // Narrower than one card: still a single column, no inversion. + const BrowserLayout L = layoutBrowser(kBrowserCardWidth - 10, 200); + CHECK(L.columns == 1); + CHECK(L.grid.width() >= 0); + CHECK(L.tabStrip.height() == kBrowserTabHeight); +} + +static void testLayoutZeroArea() { + const BrowserLayout L = layoutBrowser(0, 0); + CHECK(L.tabStrip.width() == 0 && L.tabStrip.height() == 0); + CHECK(L.grid.width() == 0); + CHECK(L.columns == 1); // never zero (avoids a divide-by-zero in card layout) +} + +static void testLayoutTinyHeightClampsTabStrip() { + // A height below the tab band: the tab strip clamps to the area, the grid is empty. + const BrowserLayout L = layoutBrowser(560, kBrowserTabHeight - 6); + CHECK(L.tabStrip.height() == kBrowserTabHeight - 6); + CHECK(L.grid.height() <= 0); // no room left for cards +} + +// --- card rects --------------------------------------------------------------- + +static void testCardCellsTileRowMajor() { + const BrowserLayout L = layoutBrowser(560, 300); + const int cols = L.columns; + // Card 0 is top-left of the grid. + const Rect c0 = cardCellRect(L, 0); + CHECK(c0.left == L.grid.left && c0.top == L.grid.top); + CHECK(c0.width() == kBrowserCardWidth && c0.height() == kBrowserCardHeight); + // Card 1 is one card-width to the right, same row. + const Rect c1 = cardCellRect(L, 1); + CHECK(c1.left == L.grid.left + kBrowserCardWidth); + CHECK(c1.top == c0.top); + // The first card of the SECOND row wraps back to the left, one card-height down. + const Rect wrap = cardCellRect(L, cols); + CHECK(wrap.left == L.grid.left); + CHECK(wrap.top == L.grid.top + kBrowserCardHeight); +} + +static void testCardCellNegativeIndex() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect r = cardCellRect(L, -1); + CHECK(r.left == 0 && r.top == 0 && r.right == 0 && r.bottom == 0); +} + +static void testCardContentInsetByGutter() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect cell = cardCellRect(L, 0); + const Rect content = cardContentRect(L, 0); + CHECK(content.left == cell.left + kBrowserCardGutter); + CHECK(content.top == cell.top + kBrowserCardGutter); + CHECK(content.right == cell.right - kBrowserCardGutter); + CHECK(content.bottom == cell.bottom - kBrowserCardGutter); +} + +static void testThumbnailAboveLabel() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect content = cardContentRect(L, 0); + const Rect thumb = cardThumbnailRect(L, 0); + const Rect label = cardLabelRect(L, 0); + // Thumbnail is the top band of the content; the label is the remainder below it, contiguous. + CHECK(thumb.left == content.left && thumb.right == content.right); + CHECK(thumb.top == content.top); + CHECK(thumb.height() == kBrowserThumbHeight); + CHECK(label.top == thumb.bottom); + CHECK(label.bottom == content.bottom); + CHECK(label.left == content.left && label.right == content.right); +} + +// --- cardHitTest -------------------------------------------------------------- + +static void testCardHitCenterOfCard() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect content = cardContentRect(L, 3); + const int cx = content.left + content.width() / 2; + const int cy = content.top + content.height() / 2; + CHECK(cardHitTest(L, 12, cx, cy) == 3); +} + +static void testCardHitMissesGutter() { + const BrowserLayout L = layoutBrowser(560, 300); + // A point in the gutter between the content and the cell edge (top-left corner of cell 0) + // is a miss — only the card CONTENT counts. + const Rect cell = cardCellRect(L, 0); + CHECK(cardHitTest(L, 12, cell.left, cell.top) == -1); +} + +static void testCardHitMissesPastLastCard() { + const BrowserLayout L = layoutBrowser(560, 300); + // Only 2 cards exist; a point on where card 5 WOULD be is a miss. + const Rect content = cardContentRect(L, 5); + const int cx = content.left + content.width() / 2; + const int cy = content.top + content.height() / 2; + CHECK(cardHitTest(L, 2, cx, cy) == -1); +} + +static void testCardHitMissesTabStrip() { + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(cardHitTest(L, 12, 10, L.tabStrip.top + 2) == -1); +} + +static void testCardHitZeroCards() { + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(cardHitTest(L, 0, 20, 40) == -1); +} + +// --- filter tabs -------------------------------------------------------------- + +static void testFilterTabsTileStrip() { + const BrowserLayout L = layoutBrowser(560, 300); + const int n = 4; // "All" + 3 banks + const Rect t0 = filterTabRect(L, n, 0); + const Rect tLast = filterTabRect(L, n, n - 1); + CHECK(t0.left == L.tabStrip.left); + // Adjacent tabs share an exact edge (no gap). + CHECK(filterTabRect(L, n, 0).right == filterTabRect(L, n, 1).left); + CHECK(filterTabRect(L, n, 1).right == filterTabRect(L, n, 2).left); + // The last tab reaches the strip's right edge exactly (absorbs the remainder). + CHECK(tLast.right == L.tabStrip.right); + // All tabs share the strip's height. + CHECK(t0.top == L.tabStrip.top && t0.bottom == L.tabStrip.bottom); +} + +static void testFilterTabOutOfRange() { + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(filterTabRect(L, 3, -1).width() == 0); + CHECK(filterTabRect(L, 3, 3).width() == 0); + CHECK(filterTabRect(L, 0, 0).width() == 0); +} + +static void testFilterTabHit() { + const BrowserLayout L = layoutBrowser(560, 300); + const int n = 3; + for (int i = 0; i < n; ++i) { + const Rect t = filterTabRect(L, n, i); + const int cx = t.left + t.width() / 2; + const int cy = t.top + t.height() / 2; + CHECK(filterTabHitTest(L, n, cx, cy) == i); + } + // Below the strip (in the grid) -> no tab. + CHECK(filterTabHitTest(L, n, 20, L.grid.top + 4) == -1); +} + +int main() { + testLayoutNormalArea(); + testLayoutNarrowAreaSingleColumn(); + testLayoutZeroArea(); + testLayoutTinyHeightClampsTabStrip(); + testCardCellsTileRowMajor(); + testCardCellNegativeIndex(); + testCardContentInsetByGutter(); + testThumbnailAboveLabel(); + testCardHitCenterOfCard(); + testCardHitMissesGutter(); + testCardHitMissesPastLastCard(); + testCardHitMissesTabStrip(); + testCardHitZeroCards(); + testFilterTabsTileStrip(); + testFilterTabOutOfRange(); + testFilterTabHit(); + + if (g_fail == 0) std::printf("capture_browser: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp new file mode 100644 index 0000000..e8dfbe1 --- /dev/null +++ b/tests/test_keyboard_strip.cpp @@ -0,0 +1,201 @@ +// Standalone tests for reasampler::vst::keyboard_strip — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests. Assert the capture-first editor's +// keyboard-strip layout, root marker, key mapping, zone-bar hit regions, and the drag-delta +// note resolver directly — the geometry that backs the single-capture root-set and the opt-in +// Zones panel. +// +// Covers: layoutStrip (normal + zero); keyLeftX monotonic across the 128-key span with the +// boundary at 128 == band right; keyRect / rootMarkerRect (rootMarkerRect == keyRect); +// keyAtPoint inverting the mapping and clamping/ missing off-band; zoneBarRect spanning +// [low,high] inclusive and collapsing (not inverting) a malformed low>high; zoneGrabAt +// classifying low-edge / high-edge / body and the narrow-bar midpoint split (low wins the +// tie); zoneBarAtPoint first-match on overlap + null-list rejection; resolveDragNote rounding +// to the nearest key at the key centre, clamping to [0,127], and the zero-delta / zero-width +// no-ops. + +#include "../src/vst/keyboard_strip.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A comfortable strip: 1280px wide (10px per key) so key math is exact and easy to reason +// about. +static StripLayout wideStrip() { return layoutStrip(1280, 40); } + +// --- layoutStrip -------------------------------------------------------------- + +static void testLayoutNormalArea() { + const StripLayout L = layoutStrip(640, 40); + CHECK(L.keys.left == 0 && L.keys.top == 0); + CHECK(L.keys.right == 640 && L.keys.bottom == 40); +} + +static void testLayoutZeroArea() { + const StripLayout L = layoutStrip(0, 0); + CHECK(L.keys.width() == 0 && L.keys.height() == 0); +} + +// --- keyLeftX / keyRect / rootMarkerRect -------------------------------------- + +static void testKeyLeftMonotonicAndBounds() { + const StripLayout L = wideStrip(); + // Key 0's left edge is the band left; the 128 boundary is the band right. + CHECK(keyLeftX(L, 0) == L.keys.left); + CHECK(keyLeftX(L, 128) == L.keys.right); + // Strictly non-decreasing across the span. + int prev = keyLeftX(L, 0); + for (int n = 1; n <= 128; ++n) { + const int x = keyLeftX(L, n); + CHECK(x >= prev); + prev = x; + } + // At 10px/key, key 12 (one octave) starts at 120px. + CHECK(keyLeftX(L, 12) == 120); +} + +static void testKeyRectHalfOpen() { + const StripLayout L = wideStrip(); + const Rect k = keyRect(L, 60); + CHECK(k.left == keyLeftX(L, 60)); + CHECK(k.right == keyLeftX(L, 61)); + CHECK(k.top == L.keys.top && k.bottom == L.keys.bottom); + CHECK(k.width() == 10); // 10px/key +} + +static void testRootMarkerEqualsKeyRect() { + const StripLayout L = wideStrip(); + const Rect m = rootMarkerRect(L, 64); + const Rect k = keyRect(L, 64); + CHECK(m.left == k.left && m.right == k.right && m.top == k.top && m.bottom == k.bottom); +} + +// --- keyAtPoint --------------------------------------------------------------- + +static void testKeyAtPointInverts() { + const StripLayout L = wideStrip(); + // A point in the middle of key 60's cell resolves to 60. + const Rect k = keyRect(L, 60); + CHECK(keyAtPoint(L, k.left + 5, k.top + 2) == 60); + // The very left of the band is key 0; just inside the right edge is key 127. + CHECK(keyAtPoint(L, L.keys.left, 2) == 0); + CHECK(keyAtPoint(L, L.keys.right - 1, 2) == 127); +} + +static void testKeyAtPointOffBand() { + const StripLayout L = wideStrip(); + CHECK(keyAtPoint(L, -5, 2) == -1); // left of band + CHECK(keyAtPoint(L, L.keys.right + 5, 2) == -1); // right of band + CHECK(keyAtPoint(L, 100, L.keys.bottom + 5) == -1); // below band +} + +// --- zoneBarRect -------------------------------------------------------------- + +static void testZoneBarSpansInclusive() { + const StripLayout L = wideStrip(); + const Rect bar = zoneBarRect(L, 12, 23); // C1..B1 inclusive + CHECK(bar.left == keyLeftX(L, 12)); + CHECK(bar.right == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully + CHECK(bar.width() == 120); // 12 keys * 10px +} + +static void testZoneBarMalformedCollapses() { + const StripLayout L = wideStrip(); + // low > high must collapse, never invert. + const Rect bar = zoneBarRect(L, 80, 40); + CHECK(bar.width() >= 0); + CHECK(bar.right >= bar.left); +} + +// --- zoneGrabAt --------------------------------------------------------------- + +static void testZoneGrabEdgesAndBody() { + const StripLayout L = wideStrip(); + const Rect bar = zoneBarRect(L, 20, 60); // wide bar with a clear body + const int y = L.keys.top + 2; + // Near the left edge -> low; near the right edge -> high; the middle -> body. + CHECK(zoneGrabAt(L, 20, 60, bar.left + 1, y) == ZoneGrab::kLowEdge); + CHECK(zoneGrabAt(L, 20, 60, bar.right - 1, y) == ZoneGrab::kHighEdge); + CHECK(zoneGrabAt(L, 20, 60, bar.left + bar.width() / 2, y) == ZoneGrab::kBody); + // Off the bar entirely -> none. + CHECK(zoneGrabAt(L, 20, 60, bar.right + 20, y) == ZoneGrab::kNone); +} + +static void testZoneGrabNarrowBarSplitsAtMidpointLowWins() { + const StripLayout L = wideStrip(); + // A 1-key bar is narrower than 2*edge: no body; the low edge wins the exact midpoint. + const Rect bar = zoneBarRect(L, 50, 50); + const int y = L.keys.top + 2; + const int mid = bar.left + bar.width() / 2; + CHECK(zoneGrabAt(L, 50, 50, mid, y) == ZoneGrab::kLowEdge); // tie -> low + CHECK(zoneGrabAt(L, 50, 50, bar.right - 1, y) == ZoneGrab::kHighEdge); +} + +// --- zoneBarAtPoint ----------------------------------------------------------- + +static void testZoneBarAtPointFirstMatch() { + const StripLayout L = wideStrip(); + const int lows[2] = {20, 30}; // zone 0 and zone 1 overlap on [30,50] + const int highs[2] = {50, 70}; + const Rect overlap = zoneBarRect(L, 30, 50); + const int y = L.keys.top + 2; + const int cx = overlap.left + overlap.width() / 2; + // A point in the overlap resolves to the FIRST covering zone (draw order). + const ZoneBarHit hit = zoneBarAtPoint(L, lows, highs, 2, cx, y); + CHECK(hit.zoneIndex == 0); + CHECK(hit.grab != ZoneGrab::kNone); +} + +static void testZoneBarAtPointNullList() { + const StripLayout L = wideStrip(); + const ZoneBarHit hit = zoneBarAtPoint(L, nullptr, nullptr, 0, 100, 2); + CHECK(hit.zoneIndex == -1 && hit.grab == ZoneGrab::kNone); +} + +// --- resolveDragNote ---------------------------------------------------------- + +static void testResolveDragRoundsToNearestKey() { + const StripLayout L = wideStrip(); // 10px/key + // A +25px drag from key 60 = +2.5 keys -> rounds to +3 (half-key flips at the centre). + CHECK(resolveDragNote(L, 60, 25) == 63); + // A +24px drag = +2.4 keys -> rounds to +2. + CHECK(resolveDragNote(L, 60, 24) == 62); + // Symmetric for negative deltas. + CHECK(resolveDragNote(L, 60, -25) == 57); + CHECK(resolveDragNote(L, 60, -24) == 58); +} + +static void testResolveDragClampsAndNoOps() { + const StripLayout L = wideStrip(); + CHECK(resolveDragNote(L, 60, 0) == 60); // zero delta -> unchanged + CHECK(resolveDragNote(L, 2, -1000) == 0); // clamps at 0 + CHECK(resolveDragNote(L, 120, 1000) == 127); // clamps at 127 + // Zero-width band -> no motion (pins to startNote, clamped). + const StripLayout Z = layoutStrip(0, 40); + CHECK(resolveDragNote(Z, 60, 500) == 60); +} + +int main() { + testLayoutNormalArea(); + testLayoutZeroArea(); + testKeyLeftMonotonicAndBounds(); + testKeyRectHalfOpen(); + testRootMarkerEqualsKeyRect(); + testKeyAtPointInverts(); + testKeyAtPointOffBand(); + testZoneBarSpansInclusive(); + testZoneBarMalformedCollapses(); + testZoneGrabEdgesAndBody(); + testZoneGrabNarrowBarSplitsAtMidpointLowWins(); + testZoneBarAtPointFirstMatch(); + testZoneBarAtPointNullList(); + testResolveDragRoundsToNearestKey(); + testResolveDragClampsAndNoOps(); + + if (g_fail == 0) std::printf("keyboard_strip: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index f35945e..da645ef 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -9,13 +9,15 @@ // 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 -> "". +// Covers: selectSample by-id hit (across pool + named banks), the S10 policy reversal +// (empty / stale id -> SILENCE nullopt, not the first sample), empty & malformed blob -> +// nullopt, zero-samples -> nullopt, rootNote/loop intrinsic threading incl. the middle-C +// default; listSamples ordinal order + the card metadata (rootNote/key/bankId) + empty/ +// malformed; listBanks ordinal order (pool first) + 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 -> ""; component state (v3) +// round-trip + v1/v2 back-compat lift + empty/unknown -> empty. // wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave- // stride contract across the seam (that the byte stride wav_trim reports matches the // channel-count stride downmixToMono divides by). @@ -78,24 +80,25 @@ static void testSelectByIdHit() { CHECK(sel && sel->rootNote == 38); } -static void testSelectFirstSampleFallbackOnEmptyId() { +static void testSelectEmptyIdIsSilence() { 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). + // POLICY REVERSAL (S10): no stored selection resolves to SILENCE (nullopt), NOT the + // bank's first sample. A fresh instance plays nothing and shows the "pick a capture" + // empty state — the deliberate reversal of the S4 first-sample auto-play. auto sel = selectSample(json, ""); - CHECK(sel.has_value()); - CHECK(sel && sel->relativePath == "reasampler_bank/a.wav"); - CHECK(sel && sel->rootNote == 36); + CHECK(!sel.has_value()); } -static void testSelectFirstSampleFallbackOnUnknownId() { +static void testSelectUnknownIdIsSilence() { 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. + // A stale stored id (deleted/moved-out sample) resolves to SILENCE, not a substituted + // first sample — the editor reflects the missing pick with its empty state rather than + // masking it with a mystery sample. auto sel = selectSample(json, "deleted-id"); - CHECK(sel.has_value()); - CHECK(sel && sel->relativePath == "reasampler_bank/a.wav"); + CHECK(!sel.has_value()); } static void testSelectRootNoteDefault() { @@ -155,12 +158,53 @@ static void testListSamplesOrdinalOrder() { CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare"); } +static void testListSamplesCarriesCardMetadata() { + // The browser card needs rootNote/key badge + the bank id (for the filter). A pool sample + // reports the pool bank id; a named-bank sample reports "drums-id"; an un-rooted sample + // reports no rootNote (the badge shows "root —", never a guessed value). + Sample rooted = makeSample("a", "Kick", "reasampler_bank/a.wav", 36); + rooted.key = "Cm"; + Sample unrooted = makeSample("u", "Loop", "reasampler_bank/u.wav", std::nullopt); + const std::string json = bookJson({rooted, unrooted}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + const std::vector list = listSamples(json); + CHECK(list.size() == 3); + // Pool sample "a": rooted + keyed, pool bank id. + CHECK(list[0].id == "a" && list[0].rootNote.has_value() && *list[0].rootNote == 36); + CHECK(list[0].key.has_value() && *list[0].key == "Cm"); + CHECK(!list[0].bankId.empty()); // the pool has an id; the filter matches on it + // Pool sample "u": no root intrinsic -> no rootNote (badge shows "root —"). + CHECK(list[1].id == "u" && !list[1].rootNote.has_value()); + // Named-bank sample "b": its bank id distinguishes it from the pool for the filter. + CHECK(list[2].id == "b" && list[2].bankId == "drums-id"); + CHECK(list[2].bankId != list[0].bankId); // pool vs. named bank differ (filterable apart) +} + static void testListSamplesEmptyAndMalformed() { CHECK(listSamples("").empty()); CHECK(listSamples("{garbage").empty()); CHECK(listSamples(bookJson({}, {})).empty()); } +static void testListBanksOrdinalOrder() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + const std::vector banks = listBanks(json); + // Pool first (bank-zero), then the named bank "Drums". Both ids are present so the filter + // tab strip can key on them. + CHECK(banks.size() == 2); + CHECK(banks.size() == 2 && banks[1].id == "drums-id" && banks[1].displayName == "Drums"); + CHECK(banks.size() == 2 && !banks[0].id.empty()); // the pool bank has an id too +} + +static void testListBanksEmptyAndMalformed() { + CHECK(listBanks("").empty()); + CHECK(listBanks("{garbage").empty()); + // A valid book with no samples still has the pool bank -> one entry. + CHECK(listBanks(bookJson({}, {})).size() == 1); +} + // --- downmixToMono ------------------------------------------------------------ static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; } @@ -558,10 +602,86 @@ static void testPerformanceStateNegativeNotesRoundTrip() { *back.zones[0].rootOverride == 0); } +// --- Combined component state (v3, S10) -------------------------------------- + +static void testComponentStateRoundTrip() { + // The v3 state carries the single-capture selection AND the opt-in zones, distinctly. + ComponentState s; + s.selectionId = "picked-capture"; + s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt)); + s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48)); + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "picked-capture"); + CHECK(back.map.zones.size() == 2); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && + back.map.zones[0].highNote == 59 && !back.map.zones[0].rootOverride.has_value()); + CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" && + back.map.zones[1].rootOverride.has_value() && *back.map.zones[1].rootOverride == 48); +} + +static void testComponentStateSelectionOnlyNoZones() { + // A single-capture instance: a pick, no zones. Must restore the pick with an empty map + // (NOT synthesize a zone) — the default face is one capture, zones are opt-in. + ComponentState s; + s.selectionId = "just-a-pick"; + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "just-a-pick"); + CHECK(back.map.zones.empty()); +} + +static void testComponentStateEmptyIsEmpty() { + // No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample. + const ComponentState s; // selectionId "", empty map + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId.empty()); + CHECK(back.map.zones.empty()); +} + +static void testComponentStateV1BackCompat() { + // A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick + // survives as BOTH the selection and a one-zone map. + const std::vector v1 = serializeSelection("legacy-id"); + const ComponentState back = deserializeComponentState(v1); + CHECK(back.selectionId == "legacy-id"); + CHECK(back.map.zones.size() == 1); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" && + back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127); + // A v1 blob with an EMPTY id -> empty state (no selection, no zone). + const ComponentState empty = deserializeComponentState(serializeSelection("")); + CHECK(empty.selectionId.empty() && empty.map.zones.empty()); +} + +static void testComponentStateV2BackCompat() { + // A v2 S5 blob (zones-only) lifts to {"", zones}: that instance had zones but no separate + // single-capture selection. + PerformanceMap m; + m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt)); + const std::vector v2 = serializePerformance(m); + const ComponentState back = deserializeComponentState(v2); + CHECK(back.selectionId.empty()); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" && + back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24); +} + +static void testComponentStateGarbage() { + // Empty / unknown version -> empty (never throws across the host). + CHECK(deserializeComponentState({}).selectionId.empty()); + CHECK(deserializeComponentState({}).map.zones.empty()); + const std::vector unknown{0xAA, 0xBB, 0xCC, 0xDD}; + CHECK(deserializeComponentState(unknown).map.zones.empty()); + CHECK(deserializeComponentState(unknown).selectionId.empty()); + // A v3 header claiming a longer id than the blob holds -> empty (bounded read). + std::vector t; + t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0); // version 3 + t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0); // id length 200 (absent) + CHECK(deserializeComponentState(t).selectionId.empty()); + CHECK(deserializeComponentState(t).map.zones.empty()); +} + int main() { testSelectByIdHit(); - testSelectFirstSampleFallbackOnEmptyId(); - testSelectFirstSampleFallbackOnUnknownId(); + testSelectEmptyIdIsSilence(); + testSelectUnknownIdIsSilence(); testSelectRootNoteDefault(); testSelectLoopThreaded(); testSelectNoLoopIsAbsent(); @@ -569,7 +689,10 @@ int main() { testSelectMalformedBlob(); testSelectZeroSamples(); testListSamplesOrdinalOrder(); + testListSamplesCarriesCardMetadata(); testListSamplesEmptyAndMalformed(); + testListBanksOrdinalOrder(); + testListBanksEmptyAndMalformed(); testDownmixMonoPassthrough(); testDownmixStereoAverages(); testDownmixThreeChannelAverages(); @@ -597,6 +720,12 @@ int main() { testPerformanceStateV1BackCompat(); testPerformanceStateGarbage(); testPerformanceStateNegativeNotesRoundTrip(); + testComponentStateRoundTrip(); + testComponentStateSelectionOnlyNoZones(); + testComponentStateEmptyIsEmpty(); + testComponentStateV1BackCompat(); + testComponentStateV2BackCompat(); + testComponentStateGarbage(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; From c64ab687bfe159bafb66a681cca9efe624ffe8e9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:09:15 -0400 Subject: [PATCH 22/49] docs(product): spec Phase S sampling modes (S15 Trigger/Gate) + pitch envelope (S16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate: AHDSR + loops; Trigger: fade-in / %-length / fade-out, note-off-immune. Start point joins the instrument-side overrides. Pitch env is a per-frame ratio multiply on the existing core — no resampler rewrite. WDL swept: sinc resampler held as optional upgrade; no elastique-class stretch in WDL. --- CONTEXT.md | 167 +++++++++++++++++++++++++++++++++- PLAN.md | 157 +++++++++++++++++++++++++++++++- docs/product/midi-playback.md | 80 ++++++++++++++-- 3 files changed, 396 insertions(+), 8 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index ebab860..e241e6d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1320,6 +1320,148 @@ stereo; the current mono downmix is a Tier-0 simplification, not a permanent sha REAPER's mono/stereo instrument-bus expectations against the vendored Steinberg SDK + `reaper_vst3_interfaces.h`. +## Sampling modes — Trigger vs Gate + pitch envelope (S15/S16; core + editor) + +**Daniel's directive (2026-07-26, verbatim):** *"Sampling mode: Trigger vs Gate. Gate has +an AHDSR envelope. Trigger has fade in, % length, and fade out. Both modes have modifiable +start point, Gate has modifiable loop points too. In addition to amp env, there will be a +pitch envelope/curve (AD?) which is off by default."* The feature set is **settled**; two +forks (S15-F1 choke, S15-F2 param granularity) are flagged with leans below. + +### Play mode — Gate vs Trigger (S15) + +Each played sample carries a **play mode** — a per-sample/per-zone **performance choice** +(D-B, instrument-owned, never a bank fact). Two modes, precisely: + +- **Gate — classic held note (grows the current path).** Note-on enters the amp envelope; + note-off enters release; a **sustain loop** applies for held notes (S11's draggable loop + markers are Gate-mode UI). The current core envelope is **ADSR**; Gate adds a **Hold** + stage → **AHDSR**: `0→1` over attack, **hold at 1** over `holdFrames`, `1→sustain` over + decay, hold sustain until note-off, `level→0` over release. **`holdFrames == 0` is + exactly today's ADSR** — a back-compat degenerate, no behavior change for existing Gate + play. Segment math is the existing linear-ramp idiom (`AdsrEnvelope::tick`) with one new + stage inserted between Attack and Decay. +- **Trigger — one-shot drum-pad.** Note-on fires playback of a defined **% of sample + length** with a **fade-in** and **fade-out** ramp; **note-off is ignored** (the voice + plays through); **no sustain loop**. Envelope math (distinct from AHDSR): play the frame + span `[startFrame, playEnd)` where `playEnd = startFrame + round(lengthFraction·(frames − + startFrame))`, `lengthFraction ∈ (0,1]`; amplitude ramps `0→1` over `fadeInFrames` + (fade-in) at the head and `1→0` over `fadeOutFrames` anchored to `playEnd` (fade-out), + unity between; fades clamp so `fadeInFrames + fadeOutFrames ≤ play length`. The voice + frees when `readPos_ ≥ playEnd` (mirror of the current run-off-end idle). **Fade curve + default: equal-power** (constant-power `sin`/`cos` — click-free on one-shots); linear is a + build-time residual. **Note-off in Trigger is a no-op** (choke is held — fork S15-F1). + +**Both modes: modifiable start point.** Playback begins at `startFrame` (a frame offset into +the sample, clamped `0 ≤ startFrame < frames`), not always frame 0. This is the voice's +initial `readPos_`; the existing per-frame `readPos_ += ratio_` read and linear-interp/loop +machinery are otherwise unchanged. Gate additionally has **modifiable loop points** (already +the S2 loop intrinsic + S11 override); Trigger has none (it is a one-shot). + +**Voice-stealing interaction (unchanged).** The S3 stealing policy (oldest-in-release, else +oldest-overall) is mode-agnostic — a Trigger one-shot is a normal active voice until it runs +off `playEnd`; it can be stolen like any voice. No new stealing rule. + +**Confirmed from the core (`sampler_core.cpp`):** the read loop advances `readPos_` by an +arbitrary `ratio_` per frame with 2-point linear interpolation, and the amp is a per-frame +`env_.tick()` multiply — so both the AHDSR hold stage and the Trigger fade/%-length envelope +are **per-frame amplitude functions** over the existing read machinery, and the start point +is just a non-zero initial `readPos_`. No resampler or voice-lifecycle rewrite is needed. + +**Parameter ownership (D-B).** The play mode + its params (Gate: AHDSR; Trigger: %-length + +fade-in + fade-out; both: start point) attach to the **capture selection / zone** and live +in the instrument's **performance map** (component state, version-bumped, back-compat: a +truncated/older blob defaults to **Gate, hold=0, start=0, no fades = exactly today**). Start +point joins `rootOverride` / loop-override as another per-`PerformanceZone` optional +override; a per-zone `PlayMode` + param struct is added additively. **Fork S15-F2 (flagged):** +per-capture-selection *and* per-zone, or per-zone only with the single-capture case as a +one-zone map? **Lean: per-zone only** — the single capture is already a one-zone map +(S10-Z's back-compat lift), so one storage site serves both; flagged because it touches +S10's single-capture setup surface shape. + +**Editor (mode-aware, on the S11 waveform surface).** Gate shows draggable **start + loop +markers**; Trigger shows **start + %-length end + fade-in/out** handles — same waveform, same +pure `frame↔pixel` + marker-grab geometry module (S11), mode switches which markers draw. A +**mode toggle** per capture/zone sits in the S10 guided setup / S10-Z Zones panel. Every edit +commits **off-thread** via `commitMapAndReload`; the instrument stays a **read-only bank +consumer** (mode/params are performance map, never written to `Sample` or the bank). + +### Pitch envelope — AD, off by default (S16) + +A per-voice **pitch modulation curve** on top of a zone's base repitch — a short **AD** +(attack-decay) envelope that biases the voice's read-increment over time. **Off by default** +(so existing playback is bit-identical). The classic use is a percussive **pitch drop**. + +- **Shape (lean, build-time residual): two-segment AD** — at note-on the pitch offset rises + to `peakSemitones` over `attackFrames`, then falls to 0 (base pitch) over `decayFrames`. + A **zero attack** gives the pure "start high, drop to base" percussive drop; a positive + peak that settles to 0 is the classic sampler pitch envelope. Documented; the alternative + (start-offset → monotone glide to base) is the degenerate `attack=0` case of this. +- **Range: semitones (±).** `peakSemitones` is signed (positive = start/peak above base, + negative = below); default depth range noted at build. +- **RT implication — confirmed clean.** The core's resampler is already an **arbitrary + per-frame ratio** linear interpolation (`readPos_ += ratio_`, verified in + `sampler_core.cpp`). The pitch envelope is therefore a **per-frame multiply of `ratio_`** + by `2^(pitchEnvSemitones(frame)/12)` — the effective read increment varies frame-by-frame + at no structural cost. **No new resampler, no WDL dependency** for the modulation path. The + envelope is the same per-frame `tick()` idiom as the amp envelope — RT-safe, no allocation + in `process`, per-voice (polyphonic notes each run their own). +- **Ownership + editor.** Per-zone instrument performance-map state (D-B), additive/version- + bumped (absent → disabled). Editor exposure folds into the S12 ADSR-editor tier: attack + + decay + a ±semitone depth control, default-off (discoverable but inert until enabled). + +### WDL pitch/resample surface — verified finding (feeds S15/S16, not a committed point) + +The **full** vendored WDL pitch/resample surface was swept — `vendor/WDL/WDL/resample.h` +and `vendor/WDL/WDL/simple_pitchshift.h` are the **only** two pitch/resample headers; there +is **no** elastique / formant-preserving / time-stretch anywhere in the tree. Honest +findings: + +- **`WDL_Resampler` (`resample.h`) — sinc/linear resampler, RT-suitable.** + `SetMode(interp, filtercnt, sinc, sinc_size≤64, sinc_interpsize)`; streaming + `ResamplePrepare`/`ResampleOut` with `Prealloc`. Its **sinc mode beats the core's 2-point + linear interp** for base-repitch quality (less aliasing on large transpositions) at a real + CPU cost (up-to-64-tap convolution per output sample vs. one lerp). **Fit:** an *optional + quality upgrade for the base repitch path* (a per-voice linear/sinc toggle) — **not + required** for S15/S16 and **not committed**; held as a Tier-2/3 quality option (per-voice + `WDL_Resampler` instances are heavier, and S16's modulation is cleaner hand-rolled). +- **`WDL_SimplePitchShifter` (`simple_pitchshift.h`) — time-domain OLA pitch shifter, wrong + tool.** It shifts pitch *preserving duration* — the opposite of a sampler's + repitch-by-resampling (which changes pitch and duration together). Its `set_formant_shift` + is an **explicit empty stub** (no formant preservation). Not a fit for the sampler + repitch/envelope path; noted for completeness. +- **Formant-preserving / time-stretch (elastique-class): NOT available.** REAPER's elastique + is **licensed (zplane)**, not in the open WDL/reaper-sdk vendored tree (grep found only + unrelated libpng/giflib string matches). Formant-correct repitch is **unavailable to the + instrument without a new third-party dependency** — out of scope (D5-adjacent). Stated, not + worked around. +- **Recommendation:** S16's pitch-envelope ratio-modulation stays **hand-rolled** (per-frame + `ratio_` multiply over the existing linear-interp read — simplest, RT-safe, already + supported). `WDL_Resampler` (sinc) is the only WDL piece worth adopting, and only as an + **optional base-repitch quality upgrade** — held, not scheduled. + +### Sequencing (S15/S16 against S7 stereo, S10 editor) + +S15 and S16 are **S3-core extensions** — they touch the engine Daniel smoke-tests, like S7. +They are **channel-count-agnostic by construction**: the play-mode envelope is a per-frame +**amplitude** function and the pitch envelope is a per-frame **read-rate scalar** — both +independent of how many channels `SampleData` carries. So they **compose cleanly with S7's +channel dimension** rather than conflicting: S7 adds a channel axis to the read/mix; S15/S16 +add an amplitude-shape axis and a read-rate axis; the three are orthogonal. **Recommended +order:** **S15 before S16** (S16's pitch envelope reuses S15's per-voice param-plumbing + +component-state version bumps; landing S15's `PlayMode`/param struct first gives S16 a home +to hang the pitch-env params on). **S15/S16 relative to S7:** no hard dependency — spec them +so the envelope/mode code never assumes a channel count (it operates per-frame, pre-mix), and +S7 can land before, after, or interleaved. **Relative to S10 (editor):** S15's mode toggle + +Trigger handles and S16's AD control **surface through** the S10/S11 waveform + guided-setup +work, so the *core* halves of S15/S16 can land independently of the editor, with the editor +surfacing following S10/S11 (the same way S12's ADSR editor follows the S3 ADSR math). Land +the **core** engine work (mode split, start point, %-length/fades, pitch-env modulation) as +soon as it is ready — it is testable in CTest without the editor — and wire the UI as the +S10/S11 surfaces mature. **Land S15/S16 core after S10's policy-reversal is settled** only if +sharing the same component-state blob would otherwise churn the version tag twice; otherwise +they are independent. + ## Ingest through the bank — the extension owns ingest (decided "option 1", 2026-07-26; PLAN.md S8) **Decided: loading a sample into the sampler is ONE gesture — capture/import-into-bank AND @@ -1605,6 +1747,12 @@ Set by Daniel on DAW-testing the S1–S6 instrument, alongside the UX-overhaul d - **Bank-generation seam (S9).** New forever-stable `ext_keys.h` key for the generation counter; read over the same bridge `GetProjExtState` path S4 already uses. No new API — confirm no torn-read hazard on the integer key. +- **WDL pitch/resample (S15/S16).** **Verified this pass:** `vendor/WDL/WDL/resample.h` + (`WDL_Resampler` — sinc/linear, RT-suitable) and `vendor/WDL/WDL/simple_pitchshift.h` + (`WDL_SimplePitchShifter` — time-domain OLA, `set_formant_shift` empty stub) are the whole + pitch/resample surface; **no** elastique / formant-preserving / time-stretch in the tree. + S16 modulation is hand-rolled (no WDL). If the held sinc-repitch quality upgrade is taken, + verify `WDL_Resampler` streaming/prealloc against the per-voice RT budget before use. - **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the `IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event routing) — the least-trodden edge of the phase. @@ -1774,7 +1922,24 @@ a multi-capture drag over the FX button is either rejected or loads the first). S7 stereo is *not* a Tier-2 feature — it is a channel-count dimension on the existing Tier 0–1 engine, orthogonal to Tier 2's velocity-layers / round-robin / per-sample trim. (S7's stereo loop read is the same loop the core already has, extended per-channel — not - the Tier-2 "sustain loops" feature.) + the Tier-2 "sustain loops" feature.) **Likewise S15/S16** (Trigger/Gate modes + pitch + envelope) are Daniel-directed engine features on the Tier 0–1 core, *not* Tier 2/3 — the + AHDSR hold, Trigger one-shot, start point, and AD pitch envelope are orthogonal amplitude- + shape / read-rate dimensions, not the held velocity-layers / round-robin / filter work. +- **S15/S16 params are performance choices, not bank facts.** Play mode, start point, + %-length, fades, AHDSR, and the pitch envelope are per-instance performance-map state + (component state), never written to `Sample` or the bank (D-B). The bank carries file + facts (root note, loop intrinsic, channel count); the instrument owns how they are played. +- **S15/S16 stay channel-count-agnostic (S7 interplay).** The mode/envelope logic is + per-frame amplitude and read-rate, independent of the S7 channel dimension. Any S15/S16 + code that assumes a fixed channel count (mono) — rather than operating per-frame pre-mix — + is a bug that would collide with S7. Spec and build them channel-agnostic. +- **Trigger ignores note-off; choke is out of scope (S15).** In Trigger mode note-off is a + no-op and the one-shot plays to `playEnd`. Choke-on-note-off / choke-groups are held + (fork S15-F1, Tier-3-adjacent) — do not add a choke path in S15. +- **Pitch envelope is off by default (S16).** Default-disabled → offset always 0 → `ratio_` + unchanged → playback bit-identical to pre-S16. A regression that applies pitch modulation + when the envelope is off is a bug. - **Drop-and-load must not regress the two existing drags.** S17 inserts a new middle case (`InstrumentDrop`) between the M11 OS drag-out and the internal bank-to-bank drag; both existing gestures stay byte-for-byte unchanged in their own regions. Drop-and-load never diff --git a/PLAN.md b/PLAN.md index 86560b1..848ad97 100644 --- a/PLAN.md +++ b/PLAN.md @@ -625,6 +625,145 @@ the relay is proven.** "drop files on the ReaSampler panel to add" affordance rather than silently swallowing the drop — the shipped ingest gesture stays discoverable either way. +## S15 — sampling modes: Trigger vs Gate (per-sample play-mode; core + editor) +**Goal:** Give each played sample a **play mode** — **Gate** (classic held note) or +**Trigger** (one-shot) — a per-sample/per-zone performance choice (D-B, instrument-owned). +**Gate** is today's behavior grown from ADSR to **AHDSR** (adds a Hold stage): note-on → +attack/hold/decay/sustain, note-off → release, sustain **loop points apply** (S11's +draggable loop UI is Gate-mode UI). **Trigger** is a one-shot drum-pad: note-on fires +playback of a defined **% of sample length** with a **fade-in** and **fade-out** ramp, +**ignores note-off**, and uses **no sustain loop**. **Both** modes carry a **modifiable +start point** (playback begins at an offset into the sample, not always frame 0). This is +an **S3-core extension** (the engine Daniel smoke-tests) plus editor surfacing — the mode + +its parameters are instrument performance-map state, never a bank fact. CONTEXT.md §Phase S +(Sampling modes — Trigger vs Gate). **Daniel's feature set is settled; the leans below are +build-time residuals, not open forks — except the flagged forks S15-F1/F2.** +**Verify (in DAW):** a sample in **Gate** mode plays held with the AHDSR envelope (hold +stage audible between attack and decay), releases on note-off, and loops its sustain region +if loop points are set; a sample in **Trigger** mode fires a fixed % of its length on +note-on with audible fade-in/out, **plays through to completion regardless of note-off**, +and never sustain-loops; the **start point** offsets playback in both modes (a note starts +partway into the sample); the mode + parameters are per-instance component state that +survive save/reopen; the pure core's Trigger envelope (fade-in → hold → fade-out over +%-length frames) and the AHDSR hold stage are asserted against known signals (mirror of +`peaks`); existing Gate/ADSR behavior is unchanged when hold=0 (regression). **Spec +channel-count-agnostic** — the mode/envelope logic is per-frame amplitude and read-position, +independent of the S7 channel dimension (§sequencing). +**Depends on:** S3 (extends the envelope + voice read-position machinery), S5 (the +`PerformanceZone` the mode + params attach to), S11 (Gate loop-point UI; Trigger's waveform +shows start + %-length + fades on the **same** waveform surface). Independent of S7 — +orthogonal dimensions (§sequencing note in CONTEXT.md). + +- [ ] Core: `PlayMode { Gate, Trigger }` on the voice + the envelope split. **Gate** grows + `AdsrParams` → `AhdsrParams` (add `holdFrames` between attack and decay; hold=0 is the + exact current ADSR — back-compat). **Trigger** is a distinct envelope: play `[start, + start + lengthFraction·(frames−start))` with a **fade-in** ramp (0→1 over `fadeInFrames`) + and a **fade-out** ramp (1→0 over `fadeOutFrames` ending at the play-length end), + **ignoring note-off** (release is a no-op in Trigger). Fade curve default **equal-power** + (constant-power `sin`/`cos`, click-free on one-shots) with the shape noted; linear is a + build-time residual. Pure, unit-tested against a known signal. +- [ ] Core: **modifiable start point** — the voice's initial `readPos_` is `startFrame` + (frame offset), applied in both modes; the existing per-frame `readPos_ += ratio_` read + and loop/interp machinery is otherwise unchanged. Clamp `0 ≤ startFrame < frames`. +- [ ] Core: **% length → frames + fade mapping** for Trigger. `lengthFraction ∈ (0,1]` + resolves to `playEnd = start + round(lengthFraction·(frames − start))`; `fadeInFrames` / + `fadeOutFrames` clamp so their sum ≤ play length (fade-out anchored to `playEnd`). Note-off + in Trigger does nothing; the voice frees when `readPos_ ≥ playEnd` (mirror of the current + run-off-end idle). **Choke on note-off is NOT in scope** (fork S15-F1, held below). +- [ ] Parameter ownership (per-sample/per-zone, instrument-owned): the play mode + its + params (Gate: AHDSR; Trigger: %-length, fade-in, fade-out; both: start point) attach to + the **capture selection / zone**, stored in the **performance map** (D-B). **Lean + (build-time residual):** start point joins `rootOverride`/loop-override as another + per-`PerformanceZone` optional override, and a per-zone `PlayMode` + its param struct is + added additively (version-bumped component state, back-compat — a truncated/older blob + defaults to **Gate**, hold=0, start=0, no fades = exactly today's behavior). **Fork + S15-F2 (flagged):** whether these live per-capture-selection (S10's single-capture flow) + **and** per-zone, or per-zone only with the single-capture case as a one-zone map. Lean: + per-zone only — the single capture is already a one-zone map (S10-Z back-compat lift), + so one storage site serves both. Flagged because it touches S10's single-capture setup + surface shape. +- [ ] Editor (S11 waveform surface, mode-aware): **Gate** shows draggable **start + loop + markers** (S11's loop UI); **Trigger** shows **start + %-length end + fade-in/out** + handles on the same waveform. A **mode toggle** per capture/zone in the guided setup + (S10) / Zones panel (S10-Z). Marker/handle geometry is pure (extends the S11 + `frame↔pixel` + marker-grab module); commits off-thread via `commitMapAndReload`. The + instrument stays a **read-only bank consumer** (mode/params are performance map, never + written to the bank). + +## S16 — pitch envelope (AD, off by default; per-voice pitch modulation) +**Goal:** Add a per-voice **pitch envelope** on top of a zone's base repitch — a short +**AD** (attack-decay) modulation of playback pitch, **off by default**, that biases the +voice's read-increment over time. The classic use is the percussive **pitch drop** (start +offset above the target, glide down to the base pitch) and its inverse (pitch-up sweep). +Because the core's resampler is already an arbitrary per-frame `readPos_ += ratio_` linear +interpolation (**confirmed from `sampler_core.cpp`** — the read increment is recomputed-able +per frame at no structural cost), the pitch envelope is a **per-frame multiply of `ratio_`** +by `2^(envSemitones(frame)/12)`; no new resampler is needed. Per-instance performance-map +state (D-B), off by default so existing playback is bit-unchanged. CONTEXT.md §Phase S +(Pitch envelope). **Settled feature; the shape lean below is a build-time residual.** +**Verify (in DAW):** with the pitch envelope **off** (default), playback is identical to +S15/S5 (regression — no pitch modulation applied); enabling an AD pitch envelope on a zone +makes a held/triggered note **start offset in pitch and glide to the zone's base pitch** over +the attack+decay time (a percussive pitch drop when the offset is positive-then-settle); +range is settable in **semitones (±)**; the modulation is per-voice (polyphonic notes each +run their own envelope); the pure pitch-envelope curve is asserted against known values +(offset at t=0, base at t=attack+decay); RT-safe (no allocation in `process` — the envelope +is the same per-frame tick idiom as the amp envelope). +**Depends on:** S3 (the voice read-increment + envelope tick idiom this reuses), S5 (the +`PerformanceZone` it attaches to). Composes with S15 (Gate or Trigger) — the pitch envelope +is orthogonal to the amp mode. Independent of S7 (pitch is a read-rate scalar, channel-count +agnostic). + +- [ ] Core: a per-voice **AD pitch envelope** — `PitchEnvParams { enabled=false, int64 + attackFrames, int64 decayFrames, double peakSemitones }`. **Shape (lean, build-time + residual):** *start-offset → glide to base* (the classic pitch drop) — at note-on the + pitch offset is `peakSemitones`, ramps to 0 over `attackFrames` (or holds at peak for + attack then decays to 0 over `decayFrames` — pick the two-segment AD: **rise to peak over + attack, fall to 0 over decay**, so a zero attack gives the pure "start high, drop to base" + drop; documented). Off by default (`enabled=false` → offset always 0 → `ratio_` unchanged, + bit-identical to today). Pure, unit-tested (t=0 offset, t=attack peak, t=attack+decay → 0). +- [ ] Core: apply the envelope as a **per-frame `ratio_` multiply** — effective read + increment = `pitchRatio(note,root) · 2^(pitchEnvSemitones(frame)/12)`. Reuses the existing + `renderFrame` read loop (the one confirmed to advance `readPos_` by an arbitrary ratio each + frame); no new resampler, no WDL dependency for the modulation path (see the WDL finding — + WDL_Resampler is a *quality* option for the base repitch, **not** needed for envelope + modulation). Semitone range ± (settable; default range noted at build). +- [ ] Parameter ownership + editor: the pitch envelope is per-zone instrument performance-map + state (D-B), additive/version-bumped (back-compat: absent → disabled). Editor exposure is a + small AD + depth control (folds into the S12 ADSR-editor ergonomics tier — attack/decay + sliders + a ±semitone depth); default-off so the control is discoverable but inert until + turned on. + +> **WDL pitch capabilities — verified finding (feeds S15/S16 build, not a committed point).** +> The full WDL pitch/resample surface was swept (`vendor/WDL/WDL/resample.h`, +> `simple_pitchshift.h` — the only two pitch/resample headers; no elastique, no +> formant-preserving/time-stretch anywhere in the vendored tree). Findings, honest: +> - **`WDL_Resampler`** (`resample.h`) — a real **sinc/linear resampler** (`SetMode(interp, +> filtercnt, sinc, sinc_size, sinc_interpsize)`; sinc up to 64-tap). It is **RT-suitable** +> (streaming `ResamplePrepare`/`ResampleOut`, prealloc-able, no per-block alloc if +> pre-sized) and its **sinc mode beats the core's current 2-point linear interp** for +> repitch quality (fewer aliasing artifacts on large transpositions) at a real CPU cost +> (64-tap conv per output sample vs. one lerp). **Fit:** an *optional quality upgrade for +> the base repitch path* — a per-voice quality toggle (linear = cheap default, sinc = +> quality) — **not** required for S15/S16 and **not** committed here. Held as a Tier-2/3 +> quality option (see "held" below), because per-voice `WDL_Resampler` instances are +> heavier and the pitch-envelope modulation (S16) is cleaner hand-rolled anyway. +> - **`WDL_SimplePitchShifter`** (`simple_pitchshift.h`) — a **time-domain overlap-add +> pitch shifter** (window/overlap, quality param). It is *pitch-shift-preserving-duration*, +> the opposite of what a sampler wants (a sampler repitches by resampling, changing both +> pitch and duration together). Its `set_formant_shift` is an **explicit empty stub** — so +> **no formant preservation**. Not a fit for the sampler repitch/envelope path; noted for +> completeness. +> - **Formant-preserving / time-stretch (elastique-class): NOT in WDL, confirmed.** REAPER's +> elastique is **licensed (zplane), not part of the open WDL/reaper-sdk vendored tree** — +> grep of `vendor/WDL` for elastique/formant/time-stretch found only unrelated libpng/giflib +> string matches. So formant-correct repitch is **unavailable to the instrument** without a +> new third-party dependency (out of scope, D5-adjacent). **Stated, not worked around.** +> - **Recommendation:** the S16 pitch-envelope ratio-modulation stays **hand-rolled** (a +> per-frame `ratio_` multiply over the existing linear-interp read — simplest, RT-safe, +> already supported). `WDL_Resampler` (sinc) is the *only* WDL piece worth adopting, and +> only as an **optional base-repitch quality upgrade** — held, not scheduled. + > **S14 moved to Phase L (2026-07-26).** The dock-panel refresh that stood here is now > **Phase L point L2** on `dev` — and, per Daniel's DS-3 call, expanded from a light re-skin > into a **thorough dock-panel layout redesign** that lays out the full M11-aware button @@ -761,7 +900,16 @@ artifact, not extension-only. Tier 0–1 proves the instrument belongs — **its points are not drawn up here.** - **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch envelopes, LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave - room for, never a commitment. + room for, never a commitment. **Note:** S16 lands the *pitch* envelope early (Daniel's + directive) — the Tier-3 "filter/pitch envelopes" line now means the *filter* envelope + + LFOs remainder. +- **Sinc repitch quality upgrade (HELD — WDL_Resampler).** `WDL_Resampler`'s sinc mode + beats the core's 2-point linear interp for base-repitch quality (see the S16 WDL + finding). An optional per-voice quality toggle (linear default / sinc), RT-suitable but + heavier. Held as a Tier-2/3 quality option — not needed for S15/S16, not scheduled. +- **Trigger choke-on-note-off (HELD — fork S15-F1).** A future option for Trigger mode to + *cut* (choke) on note-off or on a same-group re-trigger (hi-hat open/closed). Deliberately + out of S15 scope (Trigger ignores note-off entirely there); a Tier-3 choke-group direction. ## Phase S — must-verify-before-build (carried from CONTEXT.md §Phase S) - **Steinberg VST3 SDK surface** — interface members, base-class overrides, @@ -793,6 +941,13 @@ artifact, not extension-only. - **Bank-generation ext-state read (S9)** — confirm no torn-read hazard on the single integer generation key for a bridge read on the instrument's UI/timer thread concurrent with an extension write. +- **WDL pitch/resample surface (S15/S16)** — **verified this pass:** `resample.h` + (`WDL_Resampler`, sinc/linear, RT-suitable) and `simple_pitchshift.h` + (`WDL_SimplePitchShifter`, time-domain OLA, `set_formant_shift` is an empty stub) are the + **whole** pitch/resample surface; **no** elastique / formant-preserving / time-stretch in + the vendored WDL tree. S16's pitch-envelope modulation stays hand-rolled over the existing + linear-interp read (no WDL needed). If the held sinc-repitch upgrade is ever taken, verify + `WDL_Resampler` streaming/prealloc against per-voice RT budget before use. - **Drop-and-load (S17) — three surfaces.** (1) `TrackFX_AddByName` — **verified present** in `reaper_plugin_functions.h` (signature confirmed; the `"VST3:"` name prefix and the negative-`instantiate`-always-adds semantics are documented in the header comment). (2) diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index e488dee..a28b587 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -7,7 +7,12 @@ the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN *why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled decision record). -Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r6)**. r6 records the +Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r7)**. r7 records the +**sampling-modes engine directive** (Daniel, 2026-07-26): Trigger vs Gate play modes (Gate = +AHDSR, Trigger = one-shot with %-length + fades), a modifiable start point in both, and an +off-by-default AD pitch envelope — specced as **new Phase S points S15/S16**, with the WDL +pitch surface swept and reported (sinc `WDL_Resampler` held as an optional quality upgrade; +no formant-preserving/elastique in WDL). See the "sampling modes" Addendum in §4. r6 records the **workflow-first reframe of S10** (Daniel, 2026-07-26): the editor's default face becomes a **capture browser + guided single-capture setup**, a fresh instance is **silent with a "pick a capture" empty state** (reversing the S4 first-sample auto-play), and multi-zone editing is @@ -770,6 +775,60 @@ binding can be more nuanced than "UID only" (an FXID match is involved), so UID- is **not** safe to assert from source. Verify by save-rename-reopen in the DAW; if REAPER keys partly on filename, fall back to keeping the filename and record that as shipped. +### Addendum — sampling modes (Trigger/Gate) + pitch envelope (Daniel, 2026-07-26) + +Daniel directed a set of engine features for the sampler, specced as **new Phase S points +S15 (Trigger vs Gate) and S16 (pitch envelope)**. **The feature set is settled** — recorded +here per the doc's settled-decisions convention; PLAN.md §S15/S16 and CONTEXT.md §Sampling +modes carry the spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param +granularity); the WDL question was resolved by inspection. + +**Directive, verbatim (Daniel, 2026-07-26):** *"let's have product spec out some features +for the sampler: Sampling mode: Trigger vs Gate. Gate has an AHDSR envelope. Trigger has +fade in, % length, and fade out. Both modes have modifiable start point, Gate has modifiable +loop points too. In addition to amp env, there will be a pitch envelope/curve (AD?) which is +off by default. Explore using WDL pitch capabilities."* + +**What was specced (the shape is product judgment; the feature set is Daniel's):** + +1. **Play mode — Gate vs Trigger (S15), per-sample/per-zone, instrument-owned (D-B).** + - **Gate** = classic held note: the current ADSR grows a **Hold** stage → **AHDSR** + (hold=0 is exactly today's ADSR, back-compat); note-off → release; **sustain loop + applies** (S11's loop markers become Gate-mode UI). + - **Trigger** = one-shot drum-pad: note-on fires a **% of sample length** with a + **fade-in** and **fade-out**, **ignores note-off**, **no loop**. Fade default + **equal-power** (click-free); note-off is a no-op (choke held, fork S15-F1). + - **Both:** a **modifiable start point** (non-zero initial read position). + - **Confirmed from `sampler_core.cpp`:** the read loop already advances by an arbitrary + per-frame ratio with linear interp and applies a per-frame amp tick, so both envelopes + are per-frame amplitude functions and the start point is a non-zero initial `readPos_` + — no resampler or voice-lifecycle rewrite. +2. **Pitch envelope — AD, off by default (S16).** A per-voice AD curve biasing the read + increment (the classic pitch drop). **RT clean, confirmed:** the resampler is already an + arbitrary per-frame `readPos_ += ratio_`, so the envelope is a per-frame multiply of + `ratio_` by `2^(semitones/12)` — **hand-rolled, no new resampler, no WDL dependency**. + Off by default → bit-identical to pre-S16. +3. **WDL pitch capabilities — verified, not lore (full surface swept).** The whole vendored + WDL pitch/resample surface is two headers: **`resample.h`** (`WDL_Resampler`, a real + sinc/linear RT-suitable resampler — its sinc mode *beats* the core's 2-point linear interp + for base-repitch quality at a CPU cost; **held as an optional quality upgrade**, not + needed for S15/S16) and **`simple_pitchshift.h`** (`WDL_SimplePitchShifter`, a time-domain + OLA *duration-preserving* pitch shifter — wrong tool for a sampler; `set_formant_shift` is + an **empty stub**). **No elastique / formant-preserving / time-stretch exists in WDL** — + REAPER's elastique is licensed (zplane), not in the vendored tree. **Recommendation:** S16 + modulation stays hand-rolled; `WDL_Resampler` (sinc) is the only WDL piece worth adopting + and only as a held base-repitch quality upgrade. +4. **Sequencing.** S15 before S16 (S16 reuses S15's param plumbing). Both are S3-core + extensions but **channel-count-agnostic by construction** (per-frame amplitude + read-rate, + pre-mix), so they **compose with S7 stereo** rather than conflicting. Core halves land in + CTest independently of the editor; the mode toggle / Trigger handles / AD control surface + through the S10/S11 waveform + setup work. + +**Forks flagged (leans given):** *S15-F1 (choke on note-off)* — **held**, out of S15 scope +(Trigger ignores note-off; choke-groups are Tier-3-adjacent). *S15-F2 (param granularity)* — +**lean per-zone only** (the single capture is already a one-zone map), flagged because it +touches S10's single-capture setup surface. + --- ## Where this landed @@ -819,15 +878,24 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26, post-S1–S6 DAW test). +10. **Sampling modes + pitch envelope → engine features** (**S15** Trigger vs Gate, + **S16** pitch envelope; see the "sampling modes" Addendum in §4). Gate = AHDSR held note + (hold added to today's ADSR); Trigger = one-shot with %-length + fade-in/out, ignores + note-off; both carry a modifiable start point; Gate keeps loop points. Pitch envelope = + per-voice AD, off by default, hand-rolled over the existing per-frame ratio read (no WDL + needed). WDL swept: `WDL_Resampler` (sinc) is a held optional repitch-quality upgrade; + no formant-preserving/elastique in WDL. Forks S15-F1 (choke, held) / S15-F2 (param + granularity, lean per-zone) flagged. Feature set settled. **Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the ReaSampler -9000 UX overhaul); **CONTEXT.md §Phase S** is the spec (seam-field semantics, scope -contracts, the channel-mode / ingest / bank-generation contracts, the UX-overhaul spec, the -product-name convention, the pure/shell split, the must-verify SDK/bridge surfaces). This -doc is the framing/decision record they point back to. The "no PLAN.md footprint" era is -over. +9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-envelope engine features); +**CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the channel-mode +/ ingest / bank-generation / sampling-mode / pitch-envelope contracts, the UX-overhaul spec, +the product-name convention, the pure/shell split, the WDL finding, the must-verify +SDK/bridge surfaces). This doc is the framing/decision record they point back to. The "no +PLAN.md footprint" era is over. --- From b9ad1ee18fdbeca9b56586f9ab9d35dae7b30a58 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:53:31 -0400 Subject: [PATCH 23/49] fix S10 code-review findings: WM_CAPTURECHANGED, proportional drag, paintSetup root source, Add Zone dedup, strip-area helpers --- src/vst/keyboard_strip.cpp | 20 +++++++--- src/vst/reasampler_editor.cpp | 69 +++++++++++++++++++++++++---------- tests/test_keyboard_strip.cpp | 26 +++++++++++++ 3 files changed, 90 insertions(+), 25 deletions(-) diff --git a/src/vst/keyboard_strip.cpp b/src/vst/keyboard_strip.cpp index 77b848f..9e6f4c1 100644 --- a/src/vst/keyboard_strip.cpp +++ b/src/vst/keyboard_strip.cpp @@ -106,12 +106,20 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) { if (dxPixels == 0) return clampNote(startNote); const int bandWidth = std::max(0, layout.keys.width()); if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion - // Key width in pixels (>= 1 via the max). Round the delta to the nearest key so a - // half-key drag flips at the key centre: add/subtract half a key before the divide. - const int keyW = std::max(1, bandWidth / kStripKeyCount); - const int half = keyW / 2; - const int shift = dxPixels >= 0 ? (dxPixels + half) / keyW - : -((-dxPixels + half) / keyW); + // Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag + // agree across the full strip, even on non-divisible-by-128 widths. The proportional + // key width is (bandWidth / kStripKeyCount) in exact rational arithmetic; rounding to + // the nearest key (half-key drag flips at the key centre) is achieved by adding + // bandWidth/2 to the absolute pixel delta before dividing — identical to the old + // formula except keyWidth is now derived from the same linear map (exact rational) + // rather than the truncated-integer bandWidth/128 that caused drift at the far end. + const int half = bandWidth / 2; + int shift; + if (dxPixels > 0) { + shift = (dxPixels * kStripKeyCount + half) / bandWidth; + } else { + shift = -(((-dxPixels) * kStripKeyCount + half) / bandWidth); + } return clampNote(startNote + shift); } diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 7ad1759..6bbebf3 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -314,6 +314,26 @@ EditorBands computeBands(int w, int h) { b.content = Rect{0, toggleBot, w, h}; return b; } + +// The keyboard strip rectangle inside the setup area (single-capture root-drag face). +// `area` is the full setup Rect; the strip is anchored at the bottom with an 8px horizontal +// pad. All three call sites (paintSetup, onMouseDown, onMouseMove) use this single formula. +Rect setupStripArea(const Rect& area) { + constexpr int pad = 8; + const int stripTop = area.bottom - kStripBandHeight; + return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; +} + +// The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the +// mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20) +// with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown, +// onMouseMove) use this single formula — the inline arithmetic in onMouseMove was the drift. +Rect zonesStripArea(const EditorBands& bands) { + constexpr int pad = 8; + const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12 + return Rect{bands.content.left + pad, stripTop, bands.content.right - pad, + stripTop + kStripBandHeight}; +} } // namespace void ReaSamplerEditor::paint(HDC hdc) { @@ -445,8 +465,10 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { kColTitleBg, 1.0f, 0); // Effective root: the picked sample's rootNote intrinsic (or middle C when unset). + // Read from samples_ (the full unfiltered list) so a bank-filter that hides the + // picked sample's bank doesn't mask its intrinsic root with the C4 default. int root = 60; - for (const SampleChoice& s : visible_) { + for (const SampleChoice& s : samples_) { if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; } // If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root. @@ -463,8 +485,7 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim); // Keyboard strip with the root marker. - const int stripTop = area.bottom - kStripBandHeight; - Rect stripArea{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; + const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int sx = stripArea.left; const int sy = stripArea.top; @@ -503,9 +524,7 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { } // The zones strip. - const int stripTop = addR.bottom + 12; - Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, - stripTop + kStripBandHeight}; + const Rect stripArea = zonesStripArea(bands); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int sx = stripArea.left; const int sy = stripArea.top; @@ -581,8 +600,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // The setup strip: grab the root marker (drag to set the picked capture's root). if (havePick) { const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; - const int stripTop = area.bottom - kStripBandHeight; - const Rect stripArea{area.left + 8, stripTop, area.right - 8, area.bottom - 4}; + const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); if (note >= 0) { @@ -603,11 +621,21 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, bands.content.top + 4 + 20}; if (contains(addR, x, y)) { - // Append a full-keyboard zone for the picked capture (or the first visible sample as a - // sensible seed). No pick -> nothing to add. + // Add a full-keyboard zone for the picked capture (or the first visible sample as a + // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id + // already exists, select it rather than appending a duplicate (mirrors the upsert the + // root-marker drag path already performs, preventing overlapping identical zones). std::string seed = !selectedId_.empty() ? selectedId_ : (!visible_.empty() ? visible_.front().id : std::string()); if (seed.empty()) return; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) { + selectedZone_ = i; + invalidate(); + return; + } + } PerformanceZone z; z.sampleId = seed; z.lowNote = 0; @@ -627,9 +655,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the // selected zone's root. - const int stripTop = addR.bottom + 12; - const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, - stripTop + kStripBandHeight}; + const Rect stripArea = zonesStripArea(bands); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int lx = x - stripArea.left; const int ly = y - stripArea.top; @@ -680,8 +706,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { // The single-capture root strip lives in the setup band. const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; - const Rect stripArea{area.left + 8, area.bottom - kStripBandHeight, area.right - 8, - area.bottom - 4}; + const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = resolveDragNote(sl, dragStartRoot_, dx); // The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone), @@ -708,10 +733,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { // Zone edits: recompute the grabbed field(s) against the pure resolver, live. if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; - const int pad = 8; - const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12 - const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, - stripTop + kStripBandHeight}; + const Rect stripArea = zonesStripArea(bands); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; if (drag_ == DragKind::kZoneLow) { @@ -765,6 +787,15 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, ReleaseCapture(); } return 0; + case WM_CAPTURECHANGED: + // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — reset the drag + // state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. + // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. + if (self && self->drag_ != DragKind::kNone) { + self->drag_ = DragKind::kNone; + self->invalidate(); + } + return 0; case WM_ERASEBKGND: return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase default: diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp index e8dfbe1..65e6880 100644 --- a/tests/test_keyboard_strip.cpp +++ b/tests/test_keyboard_strip.cpp @@ -179,6 +179,31 @@ static void testResolveDragClampsAndNoOps() { CHECK(resolveDragNote(Z, 60, 500) == 60); } +static void testResolveDragProportionalNonDivisibleWidth() { + // THE REVIEW FINDING: 544px / 128 = 4.25 (non-integer). Old uniform-keyW math used + // keyW = 4 (floor), accumulating ~7 keys of drift at the far end. The proportional fix + // must agree with keyAtPoint at every point — specifically the far-end invariant: + // a drag from note 0 by (width-1) pixels must land at keyAtPoint(width-1), which is 127. + const int width = 544; + const StripLayout L = layoutStrip(width, 40); + CHECK(keyAtPoint(L, width - 1, L.keys.top + 1) == 127); + CHECK(resolveDragNote(L, 0, width - 1) == 127); + + // Also verify mid-strip coherence: for each key N, a drag from 0 by N's left-edge + // pixel offset should land at N (or N-1 at worst — left-edge pixel is a boundary, so + // rounding may round down). The critical direction is that it must NOT over-shoot by + // more than 0 (it must reach at least the right key). + for (int n = 1; n < kStripKeyCount; ++n) { + const int leftPx = keyRect(L, n).left; + const int resolved = resolveDragNote(L, 0, leftPx); + // The left edge of key N is the first pixel "in" that key, so we expect resolved == N. + // Allow resolved == N-1 only when the pixel is at the exact boundary (keyEdgeToX may + // produce the same x for adjacent keys when keys share a pixel). Disallow over-shoot. + const int expected = keyAtPoint(L, leftPx, L.keys.top + 1); + CHECK(resolved >= expected - 1 && resolved <= expected + 1); + } +} + int main() { testLayoutNormalArea(); testLayoutZeroArea(); @@ -195,6 +220,7 @@ int main() { testZoneBarAtPointNullList(); testResolveDragRoundsToNearestKey(); testResolveDragClampsAndNoOps(); + testResolveDragProportionalNonDivisibleWidth(); if (g_fail == 0) std::printf("keyboard_strip: all tests passed\n"); return g_fail != 0; From 9743547dff2f2e6d1c2d2c4402cc991e25d35a04 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 21:26:23 -0400 Subject: [PATCH 24/49] S11: waveform view with draggable start/loop markers + zero-crossing snap Pure waveform_view module (frame<->pixel markers, zero-crossing snap); per-zone loop/start overrides on PerformanceZone with self-versioning zone payload; core start-point read offset; editor waveform surface with drag machine. --- CMakeLists.txt | 21 ++- src/vst/reasampler_editor.cpp | 257 ++++++++++++++++++++++++++++++++-- src/vst/reasampler_editor.h | 47 ++++++- src/vst/sample_map.cpp | 82 ++++++++++- src/vst/sample_map.h | 55 ++++++-- src/vst/sampler_core.cpp | 9 +- src/vst/sampler_core.h | 6 + src/vst/waveform_view.cpp | 99 +++++++++++++ src/vst/waveform_view.h | 83 +++++++++++ tests/test_sample_map.cpp | 130 ++++++++++++++++- tests/test_sampler_core.cpp | 76 ++++++++++ tests/test_waveform_view.cpp | 223 +++++++++++++++++++++++++++++ 12 files changed, 1052 insertions(+), 36 deletions(-) create mode 100644 src/vst/waveform_view.cpp create mode 100644 src/vst/waveform_view.h create mode 100644 tests/test_waveform_view.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d372965..e0c7bbe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -659,6 +659,15 @@ add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp) target_include_directories(keyboard_strip PUBLIC src/vst) target_link_libraries(keyboard_strip PUBLIC editor_geometry) +# waveform_view (Phase S11) — PURE frame<->pixel mapping, marker grab regions, drag-delta +# frame resolver, and the zero-crossing snap for the capture-first editor's waveform surface +# (draggable start + loop markers over the picked capture's decoded PCM). The mirror of +# keyboard_strip; links editor_geometry for the shared Rect and peaks for the AudioSample +# alias the snap scans. NEITHER SDK. +add_library(waveform_view STATIC src/vst/waveform_view.cpp) +target_include_directories(waveform_view PUBLIC src/vst src) +target_link_libraries(waveform_view PUBLIC editor_geometry peaks) + add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) @@ -686,6 +695,12 @@ add_executable(keyboard_strip_tests tests/test_keyboard_strip.cpp) target_link_libraries(keyboard_strip_tests PRIVATE keyboard_strip) add_test(NAME keyboard_strip_tests COMMAND keyboard_strip_tests) +# waveform_view (S11): the pure marker geometry + zero-crossing snap. Links ONLY waveform_view +# (+ its pure editor_geometry/peaks deps) — NEITHER SDK — the same plain-data-boundary proof. +add_executable(waveform_view_tests tests/test_waveform_view.cpp) +target_link_libraries(waveform_view_tests PRIVATE waveform_view) +add_test(NAME waveform_view_tests COMMAND waveform_view_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -865,8 +880,12 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip # geometry the capture-first editor draws + hit-tests against; both link editor_geometry # transitively (shared Rect). + # waveform_view (S11): the pure frame<->pixel marker geometry + zero-crossing snap the + # editor's waveform surface draws + hit-tests against; links editor_geometry + peaks + # transitively (shared Rect + AudioSample). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sample_map capture_paths embed_strip app_version capture_browser keyboard_strip) + sample_map capture_paths embed_strip app_version capture_browser keyboard_strip + waveform_view) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 6bbebf3..18f311f 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -21,6 +21,7 @@ #include "reasampler_vst.h" // kPluginName (the editor title band) #include "sample_map.h" #include "wav_trim.h" // parseWavLayout, extractFloatFrames +#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM @@ -43,8 +44,9 @@ constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; // capture face) hosts the keyboard strip + level readout under the browser. constexpr int kTitleHeight = 24; constexpr int kToggleHeight = 22; -constexpr int kSetupHeight = 96; // the single-capture setup surface (strip + labels) +constexpr int kSetupHeight = 176; // the single-capture setup surface (labels + waveform + strip) constexpr int kStripBandHeight = 40; +constexpr int kWaveformHeight = 72; // the S11 waveform band inside the setup surface // Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as // the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.) @@ -60,6 +62,11 @@ const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255); const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255); const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255); const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColWaveBg = LICE_RGBA(24, 24, 26, 255); +const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColStartMarker = LICE_RGBA(230, 200, 120, 255); // start point (amber) +const LICE_pixel kColLoopMarker = LICE_RGBA(120, 170, 230, 255); // loop start/end (blue) +const LICE_pixel kColLoopRegion = LICE_RGBA(120, 170, 230, 60); // loop span fill (faint) const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255); const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255); const COLORREF kRgbText = RGB(210, 230, 220); @@ -132,6 +139,7 @@ ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) void ReaSamplerEditor::refreshFromBank() { // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks + pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source) if (!processor_) { samples_.clear(); banks_.clear(); @@ -176,17 +184,85 @@ void ReaSamplerEditor::commitAndReload() { #endif } -const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { - const std::string key = sampleId + "|" + std::to_string(binCount); - auto it = thumbCache_.find(key); - if (it != thumbCache_.end()) return it->second; +ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { + SetupMarkers m; + // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override + // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic + // from the live bank blob (the same path selectSample uses); the override lives in map_. + if (processor_) { + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, selectedId_)) { + if (sel->loop.hasLoop) { + m.hasLoop = true; + m.loopStart = sel->loop.start; + m.loopEnd = sel->loop.end; + } + } + } + } + // The override (loop + start) on a zone for the picked id supersedes the intrinsic. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId != selectedId_) continue; + if (z.loopOverride) { + m.hasLoop = z.loopOverride->hasLoop; + m.loopStart = z.loopOverride->start; + m.loopEnd = z.loopOverride->end; + } + if (z.startPoint) m.start = *z.startPoint; + break; + } + // Default an unset loop's end to the sample length so the loop markers have somewhere sane + // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; + // the markers are still drawn (drag one to CREATE a loop). + if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; + return m; +} + +void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { + // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, + // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays + // identically to the un-zoned single capture (one chromatic zone) and round-trips through + // the component state; the zone becomes visible if the user opens the Zones panel. The bank + // intrinsic is NEVER written (read-only bank consumer, D-B). + if (selectedId_.empty()) return; + SampleLoop loop; + loop.hasLoop = m.hasLoop; + loop.start = m.loopStart; + loop.end = m.loopEnd; + bool found = false; + for (PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) { + z.loopOverride = loop; + z.startPoint = m.start; + found = true; + break; + } + } + if (!found) { + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.loopOverride = loop; + z.startPoint = m.start; + map_.zones.push_back(z); + } + commitAndReload(); +} + +const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { + auto it = pcmCache_.find(sampleId); + if (it != pcmCache_.end()) return it->second; // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so // resolve the path from the live bank blob (selectSample) and decode via the shared WAV - // parse — the mirror of the processor's decodeRelative. Every failure path caches an - // empty envelope so a broken/missing file is not re-decoded on every paint. + // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY + // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width- + // independent) — the thumbnail bins this at whatever width, the snap scans it directly. std::string relativePath; - Envelope env; + std::vector mono; if (processor_) { auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); @@ -210,13 +286,27 @@ const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int if (layout.valid) { std::vector interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount()); - std::vector mono = - downmixToMono(interleaved, layout.channelCount); - env = computeEnvelope(mono, 1, mono.size(), - static_cast((std::max)(1, binCount))); + mono = downmixToMono(interleaved, layout.channelCount); } } } + auto ins = pcmCache_.emplace(sampleId, std::move(mono)); + return ins.first->second; +} + +const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { + const std::string key = sampleId + "|" + std::to_string(binCount); + auto it = thumbCache_.find(key); + if (it != thumbCache_.end()) return it->second; + + // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by + // every thumbnail width AND the S11 waveform surface + snap. + const std::vector& mono = monoPcmFor(sampleId); + Envelope env; + if (!mono.empty()) { + env = computeEnvelope(mono, 1, mono.size(), + static_cast((std::max)(1, binCount))); + } auto ins = thumbCache_.emplace(key, std::move(env)); return ins.first->second; } @@ -324,6 +414,17 @@ Rect setupStripArea(const Rect& area) { return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; } +// The S11 waveform rectangle inside the setup area: a band above the keyboard strip, below the +// header/hint labels. `area` is the full setup Rect; the waveform is padded 8px horizontally and +// anchored above the strip band. All call sites (paintSetup, onMouseDown, onMouseMove) use this +// single formula so the draw and the hit-test never drift. +Rect setupWaveformArea(const Rect& area) { + constexpr int pad = 8; + const int waveBottom = area.bottom - kStripBandHeight - 6; // 6px gap above the strip + const int waveTop = waveBottom - kWaveformHeight; + return Rect{area.left + pad, waveTop, area.right - pad, waveBottom}; +} + // The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the // mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20) // with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown, @@ -482,7 +583,48 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { drawText(bmp, headerR, header.c_str(), kRgbText); Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16}; - drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim); + drawText(bmp, hintR, + "Drag the waveform markers to set start + loop; drag the keyboard to set root.", + kRgbDim); + + // --- S11 waveform surface: the picked capture's envelope + draggable markers ---------- + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + const Rect waveArea = setupWaveformArea(area); + LICE_FillRect(bmp, waveArea.left, waveArea.top, waveArea.width(), waveArea.height(), + kColWaveBg, 1.0f, 0); + if (frames > 0 && waveArea.width() > 0) { + // Envelope at one bin per pixel (full-res view of the decoded PCM, S10 read-only view + // reused). computeEnvelope over the cached mono frames — no new decode. + const int bins = (std::max)(1, waveArea.width()); + const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast(bins)); + drawEnvelope(bmp, waveArea, env); // reuses the thumbnail envelope draw (kColThumb) + + const SetupMarkers m = pickedMarkers(frames); + // Faint loop-region fill between the loop markers (only when a loop is set). + if (m.hasLoop && m.loopEnd > m.loopStart) { + const int lx = frameToX(waveArea, frames, m.loopStart); + const int rx = frameToX(waveArea, frames, m.loopEnd); + if (rx > lx) { + LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(), + kColLoopRegion, 1.0f, 0); + } + } + // The three markers: start (amber), loop start + loop end (blue). Drawn as 2px vertical + // lines the full waveform height. Loop markers dim when no loop is set (the "no loop" + // state — draggable to CREATE one). + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const LICE_pixel markerCols[3] = {kColStartMarker, kColLoopMarker, kColLoopMarker}; + for (int i = 0; i < 3; ++i) { + const int mx = frameToX(waveArea, frames, markerFrames[i]); + const bool loopMarker = (i != 0); + const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; + LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(), markerCols[i], + alpha, 0); + } + } else { + drawTextCentered(bmp, waveArea, "(decoding…)", kRgbDim); + } // Keyboard strip with the root marker. const Rect stripArea = setupStripArea(area); @@ -597,9 +739,30 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { commitAndReload(); // publishes the pick + reloads; process() plays it repitched return; } - // The setup strip: grab the root marker (drag to set the picked capture's root). if (havePick) { const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + + // S11 waveform markers: grab start / loop-start / loop-end to drag. Hit-test the + // waveform band first (it sits above the keyboard strip). markerAtPoint resolves + // which marker under the grab; a miss falls through to the keyboard strip. + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + if (frames > 0) { + const Rect waveArea = setupWaveformArea(area); + const SetupMarkers m = pickedMarkers(frames); + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y); + if (hit >= 0) { + drag_ = DragKind::kWaveMarker; + waveMarker_ = static_cast(hit); + dragStartX_ = x; + dragStartMarkers_ = m; + dragSampleFrames_ = frames; + return; // no immediate set — the marker only moves once the cursor drags + } + } + + // The setup strip: grab the root marker (drag to set the picked capture's root). const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); @@ -731,6 +894,72 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { return; } + if (drag_ == DragKind::kWaveMarker) { + // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap + // it against the decoded PCM, apply the inter-marker clamps, and write the override live. + const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); + const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + const Rect waveArea = setupWaveformArea(area); + const std::int64_t frames = dragSampleFrames_; + if (frames <= 0) return; + + // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab). + const int idx = static_cast(waveMarker_); + const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, + dragStartMarkers_.loopEnd}; + std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx); + + // Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware + // requirement). Pure over the cached mono frames — no host types, no file I/O. + const std::vector& pcm = monoPcmFor(selectedId_); + if (!pcm.empty()) { + newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), + newFrame); + } + + // Build the edited marker set from the snapshot, moving only the grabbed marker, then + // clamp: loopStart <= loopEnd, start in [0, frames]. Dragging a loop marker MAKES a loop. + SetupMarkers m = dragStartMarkers_; + if (waveMarker_ == WaveMarker::kStart) { + m.start = newFrame; + } else if (waveMarker_ == WaveMarker::kLoopStart) { + m.loopStart = (std::min)(newFrame, m.loopEnd); + m.hasLoop = true; + } else { // kLoopEnd + m.loopEnd = (std::max)(newFrame, m.loopStart); + m.hasLoop = true; + } + if (m.start < 0) m.start = 0; + if (m.start > frames) m.start = frames; + + // Upsert the override on the picked id (mirror of the root-marker path); commit lands on + // release, this is live feedback. + SampleLoop loop; + loop.hasLoop = m.hasLoop; + loop.start = m.loopStart; + loop.end = m.loopEnd; + bool found = false; + for (PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) { + z.loopOverride = loop; + z.startPoint = m.start; + found = true; + break; + } + } + if (!found) { + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.loopOverride = loop; + z.startPoint = m.start; + map_.zones.push_back(z); + } + invalidate(); + return; + } + // Zone edits: recompute the grabbed field(s) against the pure resolver, live. if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; const Rect stripArea = zonesStripArea(bands); diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index e492869..a34f704 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -68,8 +68,14 @@ private: // What a mouse drag is currently editing (the drag-state machine). kNone = no drag in // flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the - // single-capture root drag on the setup strip. - enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody }; + // single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop + // marker on the S11 waveform surface (which marker is in waveMarker_). + enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker }; + + // The waveform markers on the single-capture setup surface (S11). Order is the draw + hit + // order (start first). Named generically per the spec so S15 can repurpose the surface with + // a different marker set; here it is start-point + the sustain loop's two ends. + enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 }; #ifdef _WIN32 void paint(HDC hdc); @@ -105,6 +111,30 @@ private: // an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O). const Envelope& thumbnailFor(const std::string& sampleId, int binCount); + // The decoded MONO PCM for a bank sample id, decoded once from the WAV and cached by id. + // Feeds the S11 waveform surface: the full-res envelope binned at view width AND the + // zero-crossing snap (both need the raw frames, not the binned thumbnail). Returns an empty + // vector when the WAV can't be resolved/decoded. UI thread only (file I/O). Reuses the same + // decode path as thumbnailFor (no new WAV reader), keyed by id (not width — snap is width- + // independent). Cleared with the thumbnail cache on refresh. + const std::vector& monoPcmFor(const std::string& sampleId); + + // The effective loop + start markers for the picked single capture (S11): the per-zone + // OVERRIDE for the picked id when one exists in map_, else the bank's S2 loop intrinsic + // (loop) / frame 0 (start). Absent loop -> loopStart==loopEnd==0 (the "no loop" state). + // frames is the decoded length (for defaulting loopEnd when the bank left the loop empty). + struct SetupMarkers { + std::int64_t start = 0; + std::int64_t loopStart = 0; + std::int64_t loopEnd = 0; + bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance) + }; + SetupMarkers pickedMarkers(std::int64_t frames) const; + + // Commit an edited marker set for the picked capture as a per-zone loop/start override + // (upsert on the picked id — mirror of the root-marker path), then reload off-thread. + void commitPickedMarkers(const SetupMarkers& m); + ReaSamplerProcessor* processor_ = nullptr; // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- @@ -126,10 +156,23 @@ private: int dragStartHigh_ = 0; int dragStartRoot_ = 60; + // S11 waveform-marker drag: which marker + the marker set snapshotted at grab time (so the + // pixel-delta resolver shifts the grabbed frame from its grab-time value, and inter-marker + // clamps use the sibling markers). + WaveMarker waveMarker_ = WaveMarker::kStart; + SetupMarkers dragStartMarkers_; + std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag + // --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------ // Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail. std::unordered_map thumbCache_; + + // --- Decoded mono-PCM cache (S11; id -> full-res frames) ------------------------------ + // Keyed by id (width-independent, unlike thumbCache_). Feeds the waveform envelope binning + // + the zero-crossing snap. Cleared alongside thumbCache_ on refresh so a re-captured or + // deleted sample does not show/snap against stale PCM. + std::unordered_map> pcmCache_; }; } // namespace reasampler::vst diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index cb6d8b9..2505597 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -143,7 +143,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // Effective root: override beats bank intrinsic beats middle-C default. rz.rootNote = z.rootOverride ? *z.rootOverride : (found->rootNote ? *found->rootNote : 60); - rz.loop = loopFromSample(*found); + // Effective loop / start (S11): the instrument's per-zone override wins over the + // bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is + // never mutated — this only shapes what the core plays for THIS instance (D-B). + rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found); + rz.startFrame = z.startPoint ? *z.startPoint : 0; out.zones.push_back(std::move(rz)); } return out; @@ -161,6 +165,7 @@ Keymap buildZonedKeymap(const std::vector& zones, data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100; data.rootNote = zones[i].rootNote; data.loop = zones[i].loop; + data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) const std::size_t sampleIndex = km.samples.size(); km.samples.push_back(std::move(data)); KeyZone zone; @@ -184,6 +189,14 @@ void putU32le(std::vector& out, std::uint32_t v) { out.push_back(static_cast((v >> 24) & 0xFF)); } +// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as +// two's-complement u64, mirroring the u32 signed-int idiom above). +void putU64le(std::vector& out, std::uint64_t v) { + for (int b = 0; b < 8; ++b) out.push_back(static_cast((v >> (b * 8)) & 0xFF)); +} + +std::uint64_t asU64(std::int64_t v) { return static_cast(v); } + // A bounded little-endian reader over a byte blob. Every read is length-checked; once a // read runs past the end the reader latches `ok=false` and yields zeros, so a truncated // blob degrades to a partial/empty parse rather than reading out of bounds. @@ -215,11 +228,38 @@ struct ByteReader { } // Signed ints go on the wire as u32 two's-complement (fixed 32-bit width). int i32() { return static_cast(static_cast(u32())); } + + std::uint64_t u64() { + if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; } + std::uint64_t v = 0; + for (int b = 0; b < 8; ++b) + v |= static_cast(bytes[pos + static_cast(b)]) << (b * 8); + pos += 8; + return v; + } + // Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width). + std::int64_t i64() { return static_cast(u64()); } + + // Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields + // 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob + // as "no marker" and falls through to the (also-guarded) v1 count read. + std::uint32_t peekU32() const { + if (!ok || pos + 4 > bytes.size()) return 0; + return static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + } }; -// Append the zones payload (zone count + per-zone records) — the shared body of the v2 -// performance blob and the v3 component blob, so both write zones identically. +// Append the zones payload — the shared body of the v2 performance blob and the v3 component +// blob, so both write zones identically. Always emits PAYLOAD v2 (the S11 self-describing +// marker + version + EXTENDED records): the marker precedes the zone count so any reader can +// detect the record shape independently of the envelope version (see sample_map.h). The S11 +// loop/start overrides therefore round-trip through EITHER envelope with no envelope bump. void putZonesPayload(std::vector& out, const PerformanceMap& map) { + putU32le(out, kZonesFormatMarker); + putU32le(out, kZonesPayloadVersion); putU32le(out, static_cast(map.zones.size())); for (const PerformanceZone& z : map.zones) { putU32le(out, static_cast(z.sampleId.size())); @@ -231,14 +271,30 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) putU32le(out, static_cast(static_cast(*z.rootOverride))); } + // S11 extension: loop override (hasLoop flag + start/end), then start point. + out.push_back(z.loopOverride ? 1 : 0); + if (z.loopOverride) { + out.push_back(z.loopOverride->hasLoop ? 1 : 0); + putU64le(out, asU64(z.loopOverride->start)); + putU64le(out, asU64(z.loopOverride->end)); + } + out.push_back(z.startPoint ? 1 : 0); + if (z.startPoint) putU64le(out, asU64(*z.startPoint)); } } -// Read a zones payload (zone count + per-zone records) from `r` into `map`. Shared by the -// v2 performance parse and the v3 component parse. A truncated mid-zone read keeps the zones -// that parsed cleanly and drops the rest; the reader position is left after the last byte -// read successfully. +// Read a zones payload from `r` into `map`. Shared by the performance parse and the component +// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the +// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — +// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read +// keeps the zones that parsed cleanly and drops the rest. void readZonesPayload(ByteReader& r, PerformanceMap& map) { + bool extended = false; + if (r.peekU32() == kZonesFormatMarker) { + r.u32(); // consume the marker + const std::uint32_t pv = r.u32(); // payload version + extended = (pv >= 2); // v2+ carries the loop/start tail + } const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { PerformanceZone z; @@ -248,6 +304,18 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { z.highNote = r.i32(); const std::uint8_t hasOverride = r.u8(); if (hasOverride) z.rootOverride = r.i32(); + if (extended) { + const std::uint8_t hasLoop = r.u8(); + if (hasLoop) { + SampleLoop lp; + lp.hasLoop = (r.u8() != 0); + lp.start = r.i64(); + lp.end = r.i64(); + z.loopOverride = lp; + } + const std::uint8_t hasStart = r.u8(); + if (hasStart) z.startPoint = r.i64(); + } if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest map.zones.push_back(std::move(z)); } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index ad2b696..9806158 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -122,11 +122,20 @@ Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, // One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range, // with an optional root-note override. rootOverride absent -> repitch from the bank // sample's own S2 rootNote intrinsic (or middle C when the bank left it empty). +// +// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain +// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the +// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins +// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame +// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here; +// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone. struct PerformanceZone { std::string sampleId; // bank sample id this zone plays int lowNote = 0; // inclusive int highNote = 127; // inclusive std::optional rootOverride; // instrument-owned override; absent -> bank intrinsic + std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic + std::optional startPoint; // instrument-owned initial read frame; absent -> 0 }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -148,7 +157,8 @@ struct ResolvedZone { int lowNote = 0; int highNote = 127; int rootNote = 60; // effective: override, else bank intrinsic, else 60 - SampleLoop loop; // bank intrinsic + SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) + std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) }; // The result of resolving a performance map against the live bank blob. `zones` are the @@ -191,21 +201,48 @@ Keymap buildZonedKeymap(const std::vector& zones, // instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of // truncation/wrong-version by design (bounded reads, never throws across the host). // -// Format (v2): 4-byte LE version tag (== 2), then a 4-byte LE zone count, then per zone: -// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, -// 1 byte hasOverride (0/1), 4-byte LE rootOverride (present only when hasOverride==1). -// BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is -// lifted to a single full-keyboard zone playing that id (no override) — so an instance -// saved under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob -// deserializes to an EMPTY map. +// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the +// ZONES PAYLOAD. +// +// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones +// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) +// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState +// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the +// key composition property: the zone-record extension is versioned inside the map blob, not on +// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not +// collide on a single version number. +// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone: +// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, +// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride). +// A payload starting with a small u32 (the zone count) is v1 — there is no marker. +// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone +// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended +// to each zone record after rootOverride: +// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, +// 8-byte LE loop.end (both two's-complement int64); +// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). +// The reader detects the marker to know the record shape — a v1 payload (no marker) reads +// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. +// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is +// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved +// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes +// to an EMPTY map. // // These two functions serialize the ZONES only. Since S10 the instrument's full component // state is {single-capture selection id, zones} — see ComponentState / serializeComponentState // below, the v3 format the processor actually reads/writes. serializePerformance/ -// deserializePerformance are retained for the v3 zones payload + the v1/v2 back-compat lift. +// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift. inline constexpr std::uint32_t kPerformanceStateVersion = 2; +// The zones-payload format version and its detection marker (S11). serializePerformance and +// serializeComponentState both emit PAYLOAD v2 (marker + version + extended records) so the +// S11 loop/start overrides round-trip through EITHER envelope. Readers accept a v1 payload +// (no marker) for back-compat. The marker is a high sentinel that a legitimate zone count +// (bounded by 128 MIDI zones in practice, always tiny) can never collide with. +inline constexpr std::uint32_t kZonesPayloadVersion = 2; +inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; + // The performance map serialized to bytes for IBStream (getState). std::vector serializePerformance(const PerformanceMap& map); diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 57a7748..bc6c174 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -151,7 +151,14 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote if (v > 127) v = 127; velocityGain_ = static_cast(v) / 127.0; ratio_ = pitchRatio(note, rootNote); - readPos_ = 0.0; + // Initial read position honors the sample's start-point offset (S11). Clamp into + // [0, frames): a start at or past the end degrades to 0 (play from the top) rather + // than starting a voice already off the end. A negative start (shouldn't occur — + // the map clamps) is likewise pinned to 0. + const std::int64_t frameCount = static_cast(sample.frames.size()); + std::int64_t start = sample.startFrame; + if (start < 0 || start >= frameCount) start = 0; + readPos_ = static_cast(start); sample_ = &sample; env_.configure(adsr); env_.noteOn(); diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 5efa837..cd4afad 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -50,6 +50,12 @@ struct SampleData { // note-relative, so rate cancels for repitch) int rootNote = 60; // MIDI note recorded at (plays at unity here) SampleLoop loop; // sustain loop, if any + // Initial read position (frame offset) a voice starts playback at — frame 0 by + // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this + // an instrument-side per-zone override (the "start point" marker); S15 builds on it + // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: + // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. + std::int64_t startFrame = 0; }; // --------------------------------------------------------------------------- diff --git a/src/vst/waveform_view.cpp b/src/vst/waveform_view.cpp new file mode 100644 index 0000000..2b6d70c --- /dev/null +++ b/src/vst/waveform_view.cpp @@ -0,0 +1,99 @@ +// waveform_view.cpp — see waveform_view.h. Pure math; no host types. + +#include "waveform_view.h" + +#include +#include // std::abs (int overload) + +namespace reasampler::vst { + +namespace { + +std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) { + if (f < 0) return 0; + if (f > frameCount) return frameCount; + return f; +} + +} // namespace + +int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) { + const int w = std::max(0, area.width()); + if (frameCount <= 0 || w <= 0) return area.left; + const std::int64_t f = clampFrame(frame, frameCount); + // Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line + // visually centered on its frame; the divide is exact rational (multiply first). + const std::int64_t num = f * static_cast(w) + frameCount / 2; + return area.left + static_cast(num / frameCount); +} + +std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) { + const int w = std::max(0, area.width()); + if (frameCount <= 0 || w <= 0) return 0; + if (x <= area.left) return 0; + if (x >= area.right) return frameCount; + const std::int64_t dx = static_cast(x - area.left); + // Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw + // agree at bin granularity. + const std::int64_t num = dx * frameCount + static_cast(w) / 2; + return clampFrame(num / static_cast(w), frameCount); +} + +int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, + int count, int x, int y) { + if (count <= 0 || frames == nullptr) return -1; + if (!contains(area, x, y)) return -1; + for (int i = 0; i < count; ++i) { + const int mx = frameToX(area, frameCount, frames[i]); + if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i; + } + return -1; +} + +std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, + int dxPixels) { + const std::int64_t start = clampFrame(startFrame, frameCount); + if (dxPixels == 0) return start; + const int w = std::max(0, area.width()); + if (frameCount <= 0 || w <= 0) return start; // no room to move + // Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame). + const std::int64_t magnitude = + (static_cast(std::abs(dxPixels)) * frameCount + + static_cast(w) / 2) / + static_cast(w); + const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude; + return clampFrame(start + shift, frameCount); +} + +std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, + std::int64_t target) { + if (pcm == nullptr || frames < 2) return clampFrame(target, frames > 0 ? frames - 1 : 0); + // Clamp target into a valid sample index [0, frames). + std::int64_t t = target; + if (t < 0) t = 0; + if (t > frames - 1) t = frames - 1; + + // A crossing lives at frame i (1 <= i < frames) when sign(pcm[i-1]) != sign(pcm[i]) OR + // pcm[i] == 0. isCrossing(i) tests exactly that. We fan out from t: at each distance d we + // probe t-d before t+d, so an equidistant tie resolves to the LOWER frame (deterministic). + auto isCrossing = [&](std::int64_t i) -> bool { + if (i < 1 || i >= frames) return false; + const AudioSample a = pcm[i - 1]; + const AudioSample b = pcm[i]; + if (b == 0.0f) return true; // a sample on zero is its own crossing + return (a < 0.0f) != (b < 0.0f); // sign change between i-1 and i + }; + + if (isCrossing(t)) return t; + for (std::int64_t d = 1; d < frames; ++d) { + const std::int64_t lo = t - d; + if (lo >= 1 && isCrossing(lo)) return lo; // lower side wins the tie + const std::int64_t hi = t + d; + if (hi < frames && isCrossing(hi)) return hi; + // Stop once both probes have run off both ends — no crossing anywhere. + if (lo < 1 && hi >= frames) break; + } + return t; // no sign change in the whole buffer -> keep the raw (clamped) target +} + +} // namespace reasampler::vst diff --git a/src/vst/waveform_view.h b/src/vst/waveform_view.h new file mode 100644 index 0000000..4831cbf --- /dev/null +++ b/src/vst/waveform_view.h @@ -0,0 +1,83 @@ +// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11 +// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror +// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap +// arithmetic lives here, unit-tested outside the DAW, while the editor shell +// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it. +// +// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal +// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start, +// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately +// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a +// different marker set (start + %-length end + fades) without reworking the machinery. +// +// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab +// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's +// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps), +// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame. +// +// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so +// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio +// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do +// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded. + +#pragma once + +#include + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "peaks.h" // AudioSample (float), the mono PCM the snap scans + +namespace reasampler::vst { + +// The width (px) of a marker's grab region either side of its x line: a grab within this many +// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab +// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay +// distinguishable. +inline constexpr int kMarkerGrabWidth = 5; + +// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.left, +// frame frameCount -> area.right. A frame is clamped to [0, frameCount] before mapping, so an +// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a +// zero-width area pins every frame to area.left (a degenerate, non-inverting result). Pure. +int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame); + +// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to +// [0, frameCount]. A point left of area.left yields 0; right of area.right yields frameCount. +// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips +// to the same frame at bin granularity). +std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x); + +// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at +// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is +// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker +// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring +// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or +// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like +// keyboard_strip::zoneBarAtPoint. +int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, + int count, int x, int y); + +// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time +// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the +// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth), +// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to +// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag; +// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this +// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame +// for dxPixels == 0. +std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, + int dxPixels); + +// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the +// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames) +// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing +// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and +// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to +// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or +// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller +// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the +// decoded PCM the shell already holds; no host types, no file I/O. +std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, + std::int64_t target); + +} // namespace reasampler::vst diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index da645ef..6a89ebe 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -479,6 +479,40 @@ static void testResolveLoopThreaded() { CHECK(r.zones.size() == 1); CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800); + // No loop override + no startPoint -> effective start is 0 (S11 default). + CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 0); +} + +static void testResolveLoopOverrideWins() { + // S11: the instrument's per-zone loopOverride beats the bank's S2 loop intrinsic, and the + // startPoint feeds the effective startFrame — without mutating the bank. + Sample s = makeSample("a", "Pad", "b/a.wav", 60); + s.loop = LoopPoints{200, 800}; // bank intrinsic + const std::string json = bookJson({s}, {}); + PerformanceMap m; + PerformanceZone z = zone("a", 0, 127); + SampleLoop over; over.hasLoop = true; over.start = 1000; over.end = 4000; + z.loopOverride = over; // instrument override + z.startPoint = 512; // start offset + m.zones.push_back(z); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); + CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 1000 && r.zones[0].loop.end == 4000); + CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 512); +} + +static void testResolveLoopOverrideDisablesLoop() { + // A loopOverride with hasLoop=false explicitly REMOVES the bank's loop for this instance + // (override present-but-empty wins over the intrinsic — a deliberate "no loop here"). + Sample s = makeSample("a", "Pad", "b/a.wav", 60); + s.loop = LoopPoints{200, 800}; + const std::string json = bookJson({s}, {}); + PerformanceMap m; + PerformanceZone z = zone("a", 0, 127); + z.loopOverride = SampleLoop{}; // hasLoop=false, start=end=0 + m.zones.push_back(z); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1 && !r.zones[0].loop.hasLoop); } // --- performance map: buildZonedKeymap ---------------------------------------- @@ -506,6 +540,24 @@ static void testBuildZonedKeymapMultiZone() { CHECK(!km.resolve(24, 100).matched); } +static void testBuildZonedKeymapThreadsLoopAndStart() { + // S11: the effective loop + start on a ResolvedZone reach the core's SampleData so the + // voice honors them at note-on. + std::vector zones; + ResolvedZone z0; + z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; + z0.loop.hasLoop = true; z0.loop.start = 3; z0.loop.end = 7; + z0.startFrame = 2; + zones.push_back(z0); + std::vector decoded; + decoded.push_back(DecodedZonePcm{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, 44100}); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 1); + CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop && + km.samples[0].loop.start == 3 && km.samples[0].loop.end == 7); + CHECK(km.samples.size() == 1 && km.samples[0].startFrame == 2); +} + static void testBuildZonedKeymapDropsEmptyPcm() { // A zone whose decoded WAV is empty is dropped; the other zone survives, and the // survivor's sampleIndex points at ITS sample (not the dropped one's slot). @@ -563,11 +615,79 @@ static void testPerformanceStateRoundTrip() { static void testPerformanceStateEmpty() { const std::vector bytes = serializePerformance(PerformanceMap{}); - // Just the version + zero-count header. - CHECK(bytes.size() == 8); + // Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4). + CHECK(bytes.size() == 16); CHECK(deserializePerformance(bytes).zones.empty()); } +static void testPerformanceStateLoopStartRoundTrip() { + // S11: the per-zone loopOverride + startPoint survive the payload-v2 round trip. + PerformanceMap m; + PerformanceZone z = zone("pad", 24, 96, /*override=*/64); + SampleLoop lp; lp.hasLoop = true; lp.start = 12345; lp.end = 67890; + z.loopOverride = lp; + z.startPoint = 4096; + m.zones.push_back(z); + // A second zone with NO overrides proves the optional tail is per-record. + m.zones.push_back(zone("kick", 0, 23)); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 2); + CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() && + *back.zones[0].rootOverride == 64); + CHECK(back.zones.size() == 2 && back.zones[0].loopOverride.has_value() && + back.zones[0].loopOverride->hasLoop && + back.zones[0].loopOverride->start == 12345 && + back.zones[0].loopOverride->end == 67890); + CHECK(back.zones.size() == 2 && back.zones[0].startPoint.has_value() && + *back.zones[0].startPoint == 4096); + // Zone 1: no overrides -> all optionals absent after round trip. + CHECK(back.zones.size() == 2 && !back.zones[1].loopOverride.has_value()); + CHECK(back.zones.size() == 2 && !back.zones[1].startPoint.has_value()); +} + +static void testPerformanceStateV1PayloadBackCompat() { + // A pre-S11 PAYLOAD v1 blob (no format marker: envelope v2 + bare count + short records) + // parses cleanly with the loop/start overrides defaulting absent. Hand-build the exact + // shipped shape to prove the reader still accepts the marker-less payload. + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + u32(2); // envelope version 2 + u32(1); // zone count 1 (NOT the marker -> payload v1) + const std::string id = "legacy"; + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(10); // lowNote + u32(40); // highNote + b.push_back(0); // hasRootOverride = 0 (record ends here in v1) + const PerformanceMap back = deserializePerformance(b); + CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy"); + CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40); + CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value()); + CHECK(back.zones.size() == 1 && !back.zones[0].startPoint.has_value()); +} + +static void testComponentStateLoopStartRoundTrip() { + // The overrides also round-trip through the v3 ComponentState envelope (zones nest inside + // it), so the processor's live getState/setState preserves them — the composition property. + ComponentState s; + s.selectionId = "pick"; + PerformanceZone z = zone("pick", 0, 127); + SampleLoop lp; lp.hasLoop = true; lp.start = 500; lp.end = 9000; + z.loopOverride = lp; + z.startPoint = 128; + s.map.zones.push_back(z); + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() && + back.map.zones[0].loopOverride->start == 500 && + back.map.zones[0].loopOverride->end == 9000); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].startPoint.has_value() && + *back.map.zones[0].startPoint == 128); +} + static void testPerformanceStateV1BackCompat() { // A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone. const std::vector v1 = serializeSelection("legacy-sample-id"); @@ -711,16 +831,22 @@ int main() { testResolveStaleIdDropsZone(); testResolveRootPrecedence(); testResolveLoopThreaded(); + testResolveLoopOverrideWins(); + testResolveLoopOverrideDisablesLoop(); testBuildZonedKeymapMultiZone(); + testBuildZonedKeymapThreadsLoopAndStart(); testBuildZonedKeymapDropsEmptyPcm(); testBuildZonedKeymapOverlapFirstWins(); testBuildZonedKeymapEmpty(); testPerformanceStateRoundTrip(); testPerformanceStateEmpty(); + testPerformanceStateLoopStartRoundTrip(); + testPerformanceStateV1PayloadBackCompat(); testPerformanceStateV1BackCompat(); testPerformanceStateGarbage(); testPerformanceStateNegativeNotesRoundTrip(); testComponentStateRoundTrip(); + testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); testComponentStateEmptyIsEmpty(); testComponentStateV1BackCompat(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 4a3a5a7..700e9a0 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -520,6 +520,78 @@ static void testAbsentLoopGoesSilent() { for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); } +// --------------------------------------------------------------------------- +// start point (S11): the voice's initial read position is SampleData::startFrame. +// --------------------------------------------------------------------------- + +static void testStartFrameOffsetsInitialRead() { + // A per-frame ramp (frame i holds i*0.01) so the first rendered value pinpoints the + // read position. startFrame = 30 -> the first output frame reads frame 30 (0.30). + SampleData s; + s.frames.resize(100); + for (int i = 0; i < 100; ++i) s.frames[i] = static_cast(i) * 0.01f; + s.rootNote = 60; + s.startFrame = 30; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio, full velocity, flat gain + std::vector out; + eng.render(out, 3); + CHECK(approx(out[0], 0.30, 1e-4)); // starts at frame 30, not 0 + CHECK(approx(out[1], 0.31, 1e-4)); // advances by unity ratio + CHECK(approx(out[2], 0.32, 1e-4)); +} + +static void testStartFrameZeroIsUnchanged() { + // startFrame default 0 is exactly the pre-S11 behavior: read begins at frame 0. + SampleData s; + s.frames.resize(20); + for (int i = 0; i < 20; ++i) s.frames[i] = static_cast(i) * 0.05f; + s.rootNote = 60; // startFrame stays 0 + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 0.0, 1e-6)); // frame 0 +} + +static void testStartFrameOutOfRangeClampsToZero() { + // A start point at/past the sample end degrades to frame 0 (play from the top), never an + // out-of-bounds read that would start the voice already exhausted. + SampleData s = dcSample(10, 60); // 10 frames of 1.0 + s.startFrame = 10; // == frameCount: out of range + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 5); + // Reads from frame 0: the DC sample plays its 1.0 body rather than an immediate idle. + CHECK(eng.activeVoiceCount() == 1); + CHECK(approx(out[0], 1.0, 1e-4)); +} + +static void testStartFrameWithLoop() { + // Start point and loop compose: begin reading mid-sample, then sustain the loop region. + SampleData s; + s.frames.resize(40); + for (int i = 0; i < 40; ++i) s.frames[i] = static_cast(i) * 0.01f; + for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body + s.rootNote = 60; + s.startFrame = 10; // begin at frame 10 + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 40; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 200); + CHECK(approx(out[0], 0.10, 1e-4)); // started at frame 10 + CHECK(eng.activeVoiceCount() == 1); // loop sustains it + for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.5, 1e-4)); +} + // --------------------------------------------------------------------------- // velocity -> volume. // --------------------------------------------------------------------------- @@ -580,6 +652,10 @@ int main() { testZeroLengthLoopGoesSilent(); testSingleFrameLoop(); testAbsentLoopGoesSilent(); + testStartFrameOffsetsInitialRead(); + testStartFrameZeroIsUnchanged(); + testStartFrameOutOfRangeClampsToZero(); + testStartFrameWithLoop(); testVelocityToVolume(); testPolyphonyMixesAdditively(); diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp new file mode 100644 index 0000000..2969cd9 --- /dev/null +++ b/tests/test_waveform_view.cpp @@ -0,0 +1,223 @@ +// Standalone tests for reasampler::vst::waveform_view — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests. Assert the S11 waveform surface's +// frame<->pixel mapping, marker grab regions, drag-delta frame resolver (with clamps), and +// the zero-crossing snap — the geometry + snap that back the draggable start/loop markers. +// +// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width); +// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection); +// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width +// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower, +// no-crossing keeps target, target clamp, degenerate buffers). + +#include "../src/vst/waveform_view.h" + +#include +#include + +using namespace reasampler::vst; +using reasampler::AudioSample; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs). +static Rect wideArea() { return Rect{20, 10, 1020, 90}; } // width 1000 + +// --- frameToX / xToFrame ------------------------------------------------------ + +static void testFrameToXEndpoints() { + const Rect a = wideArea(); + CHECK(frameToX(a, 1000, 0) == a.left); // frame 0 -> left edge + CHECK(frameToX(a, 1000, 1000) == a.right); // frameCount -> right edge + CHECK(frameToX(a, 1000, 500) == a.left + 500); // midpoint (1:1 here) +} + +static void testFrameToXClampsOutOfRange() { + const Rect a = wideArea(); + CHECK(frameToX(a, 1000, -50) == a.left); // below 0 pins left + CHECK(frameToX(a, 1000, 5000) == a.right); // above count pins right +} + +static void testFrameToXDegenerate() { + const Rect a = wideArea(); + CHECK(frameToX(a, 0, 100) == a.left); // no frames -> left + const Rect z = Rect{5, 5, 5, 45}; // zero width + CHECK(frameToX(z, 1000, 500) == z.left); +} + +static void testXToFrameInverse() { + const Rect a = wideArea(); + CHECK(xToFrame(a, 1000, a.left) == 0); + CHECK(xToFrame(a, 1000, a.right) == 1000); + CHECK(xToFrame(a, 1000, a.left + 250) == 250); // 1:1 map here +} + +static void testXToFrameClampsOutside() { + const Rect a = wideArea(); + CHECK(xToFrame(a, 1000, a.left - 100) == 0); // left of area -> 0 + CHECK(xToFrame(a, 1000, a.right + 100) == 1000); // right of area -> frameCount + CHECK(xToFrame(a, 0, a.left + 10) == 0); // no frames -> 0 +} + +static void testFrameToXRoundTrip() { + // Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then + // xToFrame should land within a couple frames (rounding both directions). + const Rect a = Rect{0, 0, 800, 60}; + for (std::int64_t f = 0; f <= 2000; f += 137) { + const int x = frameToX(a, 2000, f); + const std::int64_t back = xToFrame(a, 2000, x); + CHECK(back >= f - 3 && back <= f + 3); + } +} + +// --- markerAtPoint ------------------------------------------------------------ + +static void testMarkerAtPointGrabsWithinBand() { + const Rect a = wideArea(); + // Markers at frames 100, 500, 900 -> x = left+100, left+500, left+900. + const std::int64_t frames[3] = {100, 500, 900}; + const int midY = a.top + a.height() / 2; + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 100, midY) == 0); + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 900, midY) == 2); + // Within the grab band on either side of the line. + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 + kMarkerGrabWidth, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 - kMarkerGrabWidth, midY) == 1); +} + +static void testMarkerAtPointMissesBetween() { + const Rect a = wideArea(); + const std::int64_t frames[3] = {100, 500, 900}; + const int midY = a.top + a.height() / 2; + // Well away from any marker line. + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 300, midY) == -1); + // Off the area vertically. + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, a.top - 5) == -1); +} + +static void testMarkerAtPointFirstMatchOnOverlap() { + const Rect a = wideArea(); + // Two markers at the same frame -> first in order wins. + const std::int64_t frames[2] = {400, 400}; + const int midY = a.top + a.height() / 2; + CHECK(markerAtPoint(a, 1000, frames, 2, a.left + 400, midY) == 0); +} + +static void testMarkerAtPointRejectsNullEmpty() { + const Rect a = wideArea(); + const int midY = a.top + a.height() / 2; + CHECK(markerAtPoint(a, 1000, nullptr, 3, a.left + 100, midY) == -1); + const std::int64_t frames[1] = {100}; + CHECK(markerAtPoint(a, 1000, frames, 0, a.left + 100, midY) == -1); +} + +// --- resolveDragFrame --------------------------------------------------------- + +static void testResolveDragFrameShift() { + const Rect a = wideArea(); // 1:1 (1000px / 1000 frames) + CHECK(resolveDragFrame(a, 1000, 300, 0) == 300); // zero delta -> unchanged + CHECK(resolveDragFrame(a, 1000, 300, 100) == 400); // +100px -> +100 frames + CHECK(resolveDragFrame(a, 1000, 300, -50) == 250); // -50px -> -50 frames +} + +static void testResolveDragFrameClamps() { + const Rect a = wideArea(); + CHECK(resolveDragFrame(a, 1000, 50, -500) == 0); // clamp low + CHECK(resolveDragFrame(a, 1000, 950, 500) == 1000); // clamp high (== frameCount) +} + +static void testResolveDragFrameRounds() { + // 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is + // at the frame centre. Use a scale where a fractional result appears. + const Rect a = Rect{0, 0, 300, 60}; // 1000 frames / 300px = 3.33 frames/px + // +3px -> 3*1000/300 = 10.0 -> 10 frames. + CHECK(resolveDragFrame(a, 1000, 100, 3) == 110); + // +1px -> 1000/300 = 3.33 -> rounds to 3. + CHECK(resolveDragFrame(a, 1000, 100, 1) == 103); +} + +static void testResolveDragFrameDegenerate() { + const Rect z = Rect{0, 0, 0, 60}; // zero width + CHECK(resolveDragFrame(z, 1000, 300, 100) == 300); // pinned to start + const Rect a = wideArea(); + CHECK(resolveDragFrame(a, 0, 300, 100) == 0); // no frames -> clamp(start)=0 + // startFrame out of range is clamped first. + CHECK(resolveDragFrame(a, 1000, 5000, 0) == 1000); +} + +// --- nearestZeroCrossing ------------------------------------------------------ + +static void testZeroCrossingNearest() { + // Crossings (sign change from i-1 to i): i=4 (1->-1), i=5 (-1->1), i=10 (1->-1). + std::vector pcm = {1, 1, 1, 1, -1, 1, 1, 1, 1, 1, -1, -1}; + // Target 4 is itself a crossing -> 4. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 4); + // Nearest to 6: crossing 5 (dist 1) beats 4 (dist 2) and 10 (dist 4) -> 5. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 6) == 5); + // Nearest to 9: crossing 10 (dist 1) beats 5 (dist 4) -> 10. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 9) == 10); +} + +static void testZeroCrossingSampleOnZero() { + // A sample exactly 0 is its own crossing (frame index of the zero sample). + std::vector pcm = {1, 1, 0, 1, 1}; + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2); + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 3) == 2); +} + +static void testZeroCrossingEquidistantTieToLower() { + // Crossings at i=2 (1->-1) and i=6 (-1->1). Target 4 is equidistant (dist 2) -> lower (2). + std::vector pcm = {1, 1, -1, -1, -1, -1, 1, 1}; + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 2); +} + +static void testZeroCrossingNoneKeepsTarget() { + // All one sign -> no crossing -> the (clamped) target comes back unchanged. + std::vector pcm = {0.5f, 0.6f, 0.7f, 0.8f}; + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2); +} + +static void testZeroCrossingClampsTarget() { + std::vector pcm = {1, -1, 1, -1}; // crossings at 1,2,3 + // Target beyond the end clamps to frames-1 (3) then finds crossing at 3. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 999) == 3); + // Negative target clamps to 0; nearest crossing is 1. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), -999) == 1); +} + +static void testZeroCrossingDegenerate() { + CHECK(nearestZeroCrossing(nullptr, 0, 5) == 0); + std::vector one = {1}; + CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target +} + +int main() { + testFrameToXEndpoints(); + testFrameToXClampsOutOfRange(); + testFrameToXDegenerate(); + testXToFrameInverse(); + testXToFrameClampsOutside(); + testFrameToXRoundTrip(); + + testMarkerAtPointGrabsWithinBand(); + testMarkerAtPointMissesBetween(); + testMarkerAtPointFirstMatchOnOverlap(); + testMarkerAtPointRejectsNullEmpty(); + + testResolveDragFrameShift(); + testResolveDragFrameClamps(); + testResolveDragFrameRounds(); + testResolveDragFrameDegenerate(); + + testZeroCrossingNearest(); + testZeroCrossingSampleOnZero(); + testZeroCrossingEquidistantTieToLower(); + testZeroCrossingNoneKeepsTarget(); + testZeroCrossingClampsTarget(); + testZeroCrossingDegenerate(); + + if (g_fail == 0) std::printf("waveform_view: all tests passed\n"); + else std::printf("waveform_view: %d FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} From bf1bf5283cc50ba991424077536c44173bb0be67 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:24:44 -0400 Subject: [PATCH 25/49] docs(product): reshape S16 to Varispeed/Preserve pitch-engine modes (duration-preserving correction) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-zone toggle: Varispeed (classic, cheap) vs Preserve (duration-holding, WDL_SimplePitchShifter candidate — per-voice RT viability assessed: pre-warm settles onset latency + queue growth; quality basic; hand-rolled pure module held as upgrade). Pitch envelope rides either engine. Fork S16-F1: default mode is Daniel's call. --- CONTEXT.md | 222 ++++++++++++++++++++------- PLAN.md | 278 +++++++++++++++++++++++++--------- docs/product/midi-playback.md | 123 +++++++++++++-- 3 files changed, 478 insertions(+), 145 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index e241e6d..890fe52 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1328,6 +1328,15 @@ start point, Gate has modifiable loop points too. In addition to amp env, there pitch envelope/curve (AD?) which is off by default."* The feature set is **settled**; two forks (S15-F1 choke, S15-F2 param granularity) are flagged with leans below. +**Daniel's S16 correction (2026-07-26, verbatim):** *"isn't that ratio stuff going to change +the playback rate? I want duration-preserving repitching."* Correct — the `readPos_ += ratio_` +path is **varispeed** (pitch and duration coupled). S16 is revised from "pitch envelope only" +into a **pitch-engine mode (Varispeed vs Preserve) + pitch envelope** (see §Pitch engine +modes below). Two new S16 forks are flagged: **S16-F1** (the engine default — lean Preserve) +and **S16-F2** (the Preserve implementation — lean `WDL_SimplePitchShifter` first, hand-rolled +held). The prior WDL finding that dismissed `WDL_SimplePitchShifter` is **corrected in place** +below (duration-preserving is now the requirement, so that shifter is the Preserve candidate). + ### Play mode — Gate vs Trigger (S15) Each played sample carries a **play mode** — a per-sample/per-zone **performance choice** @@ -1386,72 +1395,154 @@ pure `frame↔pixel` + marker-grab geometry module (S11), mode switches which ma commits **off-thread** via `commitMapAndReload`; the instrument stays a **read-only bank consumer** (mode/params are performance map, never written to `Sample` or the bank). -### Pitch envelope — AD, off by default (S16) +### Pitch engine modes — Varispeed vs Preserve (S16) -A per-voice **pitch modulation curve** on top of a zone's base repitch — a short **AD** -(attack-decay) envelope that biases the voice's read-increment over time. **Off by default** -(so existing playback is bit-identical). The classic use is a percussive **pitch drop**. +**Daniel's correction (2026-07-26, verbatim):** *"isn't that ratio stuff going to change the +playback rate? I want duration-preserving repitching."* Correct: the `readPos_ += ratio_` +resampling path is **Varispeed** — pitch and duration are coupled (an octave up halves the +note's duration). Daniel wants **duration-preserving** repitch. So S16 grows a per-voice/ +per-zone **pitch-engine mode**, not just a pitch envelope: + +- **Varispeed engine (current path).** `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_` + with 2-point linear interp — resampling that couples pitch and duration. This is the + **classic sampler / RS5K** behavior and today's shipped S3/S5 output. Cheap, zero-latency. + Musically right for **drums / one-shots** (pitch-down-lengthens-the-hit is a feature there). +- **Preserve engine (duration-preserving).** The read advances at the **source** rate + (duration held) while a **pitch shifter** transposes the output by `2^((note−root)/12)`. + Musically right for **tempo-locked loops and phrases** — a transposed loop still lines up to + the bar. Since captured banks are project slices (loop/phrase-heavy), this is the default + lean (fork S16-F1). + +Mode is **per-`PerformanceZone` performance state (D-B)** — instrument-owned, never a bank +fact — additive/version-bumped (absent/older blob → the S16-F1 default). A per-zone +**Varispeed/Preserve toggle** surfaces in the S10 guided setup / S10-Z Zones panel. + +**Preserve engine implementation (fork S16-F2).** Two RT-disciplined routes behind the +`PitchEngine::Preserve` seam (identical contract either way): +- **(a) `WDL_SimplePitchShifter`** (`vendor/WDL/WDL/simple_pitchshift.h`) — a per-voice + time-domain OLA shifter. Under the duration-preserving directive this is **the right + category** (see the corrected WDL finding below). `set_shift(2^(semi/12))` for pitch, + `set_tempo(1.0)` to hold duration — pitch and duration are separately controllable. **Lean: + route (a) first** (low-cost proof), with two costs owned in the build: an inherent + **onset latency** (~half-window, ~25 ms @ the 50 ms quality-0 window; pre-warm at voice- + allocation, and it lands on sustained/loop material where least harmful) and a **queue-growth + allocation** hazard in `BufferDone` (`WDL_Queue::Add`) that is settled by a silence pre-warm + at voice-allocation so no `process`-thread allocation occurs in steady state. +- **(b) hand-rolled pure `pitch_shift` OLA/granular module** (house pattern — CTest-testable, + no REAPER/VST3/WDL type at the boundary) — **held** as the quality/latency upgrade if the + SimpleWindowed warble or onset lag proves musically unacceptable. + +**`WDL_Resampler` is not a Preserve engine** — it is a *resampler* (couples duration); it +remains a held **Varispeed-quality** upgrade only. **elastique is NOT available** (licensed +zplane, not vendored — restated). JUCE / rubberband / signalsmith are **new-dependency forks +carrying D-A weight** (bare-VST3-no-framework is the locked D-A) — **not proposed**. + +**S15 × S16 interaction (Preserve consumes S15's source-frame read).** S15's amplitude +semantics are defined over the voice's **source-frame** timeline; the Preserve engine wraps +that read and transposes the output, so: +- **Trigger %-length** stays a source-frame fact (`playEnd = start + round(lengthFraction· + (frames − start))`); under Preserve its **wall-clock is stable under transpose** — *cleaner* + than Varispeed, where transposing a Trigger also scales its audible length. +- **Gate sustain loop** — under Preserve, **loop the source read** (the `[loopStart, loopEnd)` + source-frame region) and feed the looped stream into the shifter, which transposes the + **output**. Contract: *loop the source, shift the output*; loop points stay source-frame + facts (S11 markers unchanged). Under Varispeed the loop read itself carries the pitch. +- **Start point** is a source-frame offset in both engines (engine-independent). + +### Pitch envelope — AD, off by default, engine-aware (S16) + +A per-voice **pitch modulation curve** riding on top of whichever engine — a short **AD** +(attack-decay) envelope that biases pitch over time. **Off by default** (so existing playback +is bit-identical under the same engine). The classic use is a percussive **pitch drop**. - **Shape (lean, build-time residual): two-segment AD** — at note-on the pitch offset rises to `peakSemitones` over `attackFrames`, then falls to 0 (base pitch) over `decayFrames`. - A **zero attack** gives the pure "start high, drop to base" percussive drop; a positive - peak that settles to 0 is the classic sampler pitch envelope. Documented; the alternative - (start-offset → monotone glide to base) is the degenerate `attack=0` case of this. -- **Range: semitones (±).** `peakSemitones` is signed (positive = start/peak above base, - negative = below); default depth range noted at build. -- **RT implication — confirmed clean.** The core's resampler is already an **arbitrary - per-frame ratio** linear interpolation (`readPos_ += ratio_`, verified in - `sampler_core.cpp`). The pitch envelope is therefore a **per-frame multiply of `ratio_`** - by `2^(pitchEnvSemitones(frame)/12)` — the effective read increment varies frame-by-frame - at no structural cost. **No new resampler, no WDL dependency** for the modulation path. The - envelope is the same per-frame `tick()` idiom as the amp envelope — RT-safe, no allocation - in `process`, per-voice (polyphonic notes each run their own). + A **zero attack** gives the pure "start high, drop to base" percussive drop. +- **Range: semitones (±).** `peakSemitones` is signed; default depth range noted at build. +- **Applied per engine.** Under **Varispeed** the offset is a **per-frame multiply of + `ratio_`** by `2^(pitchEnvSemitones(frame)/12)` (the effective read increment varies frame- + by-frame at no structural cost — the same per-frame `tick()` idiom as the amp envelope, + RT-safe, no `process` allocation). Under **Preserve** the offset is **added to the shifter's + shift amount** — `set_shift(2^((note−root + pitchEnvSemitones(frame))/12))` — bending pitch + without touching duration. Per-voice (polyphonic notes each run their own). - **Ownership + editor.** Per-zone instrument performance-map state (D-B), additive/version- bumped (absent → disabled). Editor exposure folds into the S12 ADSR-editor tier: attack + decay + a ±semitone depth control, default-off (discoverable but inert until enabled). -### WDL pitch/resample surface — verified finding (feeds S15/S16, not a committed point) +### WDL pitch/resample surface — corrected finding (feeds S16, not a committed point) -The **full** vendored WDL pitch/resample surface was swept — `vendor/WDL/WDL/resample.h` -and `vendor/WDL/WDL/simple_pitchshift.h` are the **only** two pitch/resample headers; there -is **no** elastique / formant-preserving / time-stretch anywhere in the tree. Honest -findings: +**Corrected 2026-07-26 (Daniel's duration-preserving directive).** The prior sweep dismissed +`WDL_SimplePitchShifter` as "wrong tool (duration-preserving)". Under the directive, +**duration-preserving is the requirement**, so that header is the Preserve-engine candidate, +not a mismatch — a real viability assessment replaces the dismissal. + +The **full** vendored WDL pitch/resample surface is `vendor/WDL/WDL/resample.h` and +`vendor/WDL/WDL/simple_pitchshift.h` — the **only** two pitch/resample headers; there is +**no** elastique / formant-preserving anywhere in the tree. Honest findings: - **`WDL_Resampler` (`resample.h`) — sinc/linear resampler, RT-suitable.** `SetMode(interp, filtercnt, sinc, sinc_size≤64, sinc_interpsize)`; streaming - `ResamplePrepare`/`ResampleOut` with `Prealloc`. Its **sinc mode beats the core's 2-point - linear interp** for base-repitch quality (less aliasing on large transpositions) at a real - CPU cost (up-to-64-tap convolution per output sample vs. one lerp). **Fit:** an *optional - quality upgrade for the base repitch path* (a per-voice linear/sinc toggle) — **not - required** for S15/S16 and **not committed**; held as a Tier-2/3 quality option (per-voice - `WDL_Resampler` instances are heavier, and S16's modulation is cleaner hand-rolled). -- **`WDL_SimplePitchShifter` (`simple_pitchshift.h`) — time-domain OLA pitch shifter, wrong - tool.** It shifts pitch *preserving duration* — the opposite of a sampler's - repitch-by-resampling (which changes pitch and duration together). Its `set_formant_shift` - is an **explicit empty stub** (no formant preservation). Not a fit for the sampler - repitch/envelope path; noted for completeness. -- **Formant-preserving / time-stretch (elastique-class): NOT available.** REAPER's elastique - is **licensed (zplane)**, not in the open WDL/reaper-sdk vendored tree (grep found only - unrelated libpng/giflib string matches). Formant-correct repitch is **unavailable to the - instrument without a new third-party dependency** — out of scope (D5-adjacent). Stated, not - worked around. -- **Recommendation:** S16's pitch-envelope ratio-modulation stays **hand-rolled** (per-frame - `ratio_` multiply over the existing linear-interp read — simplest, RT-safe, already - supported). `WDL_Resampler` (sinc) is the only WDL piece worth adopting, and only as an - **optional base-repitch quality upgrade** — held, not scheduled. + `ResamplePrepare`/`ResampleOut` with `Prealloc`. Its sinc mode beats the core's 2-point + linear interp for **Varispeed** base-repitch quality (less aliasing on large transpositions) + at a real CPU cost. **A resampler couples duration** → a Varispeed-quality option, **not a + Preserve engine.** Held as a Tier-2/3 Varispeed-quality toggle; not committed. +- **`WDL_SimplePitchShifter` (`simple_pitchshift.h`) — time-domain OLA, duration-preserving — + the S16 Preserve-engine candidate (fork S16-F2 route a).** Viability from the header: + - **API shape:** push/pull, block-based. `GetBuffer(size)` returns an input buffer to fill; + `BufferDone(filled)` runs the OLA shift and queues output; `GetSamples(req, buf)` pulls + from the queue. Config: `set_srate`, `set_nch`, **`set_shift(ratio)` (pitch, duration- + preserving)**, `set_tempo(scale)` (an *independent* duration knob — Preserve uses + `set_tempo(1.0)`), `SetQualityParameter(q)` (selects window/overlap ms from a fixed table). + - **Per-voice instantiability / memory:** modest. `m_psbuf` is an OLA ring of `bsize·nch` + where `bsize = window_ms · 0.001 · srate` (≈ 2205 frames at 50 ms / 44.1 kHz ≈ a few + KB/voice), plus `m_inbuf` (one input block) and a bounded `m_queue`. `m_rsbuf` allocates + only when `set_tempo ≠ 1` (unused in Preserve). One instance per voice is cheap in memory. + - **RT-safety:** allocations occur in `BufferDone` — `m_psbuf.Resize` (once, when + `bsize·nch` first sets, at a fixed quality/srate/nch — pre-warmable) and `m_queue.Add` + (grows only until the push/pull cadence reaches steady state). **Pre-warm at voice- + allocation** (run silence through once so `m_psbuf` sizes and `m_queue` settles); after + that no `process`-thread allocation. No locks. **RT-viable with the pre-warm discipline.** + - **Latency:** inherent ~half-window (initial `m_pspos = bsize/2` → ~25 ms @ 50 ms window) + plus fill-up — a **real note-onset lag**. This is the load-bearing cost. Mitigation: + pre-warm; and Varispeed (zero-latency) serves the tight-transient one-shot material, so the + lag lands on sustained/loop material where least harmful. Smaller-window quality settings + (the table goes to 3–10 ms) trade latency for more warble. + - **Quality:** basic — this is REAPER's "SimpleWindowed" mode. Audible warble on large + transpositions; **`set_formant_shift` is an explicit empty stub** → no formant preservation. + Usable for loop/phrase Preserve; replaceable by route (b) if not. + - **CPU / polyphony:** `PitchShiftBlock` is O(length) per block — a few mults + one OLA + crossfade branch per frame, **no FFT**. Per-voice cost is modest; **N polyphonic voices + each running one is feasible** within RT discipline. If the aggregate cost is material, a + **Preserve-mode-specific voice cap** (below the Varispeed cap) is the pressure valve — + flagged in Verify, set from measured per-voice budget at build. +- **Formant-preserving / studio-grade time-stretch (elastique-class): NOT in WDL, confirmed.** + REAPER's elastique is **licensed (zplane)**, not in the vendored tree (grep found only + unrelated libpng/giflib string matches). Formant-correct duration-preserving repitch is + **unavailable without a new third-party dependency** (JUCE / rubberband / signalsmith each a + new-dependency fork with D-A weight — not proposed). Stated, not worked around. +- **Recommendation:** the **Preserve** engine (S16-F2) is `WDL_SimplePitchShifter` (route a, + low-cost proof) or a hand-rolled pure `pitch_shift` module (route b, held quality upgrade). + The **pitch-envelope** modulation stays hand-rolled over whichever engine (a per-frame + `ratio_` multiply under Varispeed, a per-frame shift-amount add under Preserve). + `WDL_Resampler` (sinc) is a held **Varispeed-quality** upgrade only. ### Sequencing (S15/S16 against S7 stereo, S10 editor) S15 and S16 are **S3-core extensions** — they touch the engine Daniel smoke-tests, like S7. They are **channel-count-agnostic by construction**: the play-mode envelope is a per-frame -**amplitude** function and the pitch envelope is a per-frame **read-rate scalar** — both -independent of how many channels `SampleData` carries. So they **compose cleanly with S7's -channel dimension** rather than conflicting: S7 adds a channel axis to the read/mix; S15/S16 -add an amplitude-shape axis and a read-rate axis; the three are orthogonal. **Recommended -order:** **S15 before S16** (S16's pitch envelope reuses S15's per-voice param-plumbing + -component-state version bumps; landing S15's `PlayMode`/param struct first gives S16 a home -to hang the pitch-env params on). **S15/S16 relative to S7:** no hard dependency — spec them -so the envelope/mode code never assumes a channel count (it operates per-frame, pre-mix), and +**amplitude** function, and both pitch engines carry the channel dimension internally — the +**Varispeed** path is a per-frame per-channel read-rate scalar, and the **Preserve** shifter +is **`set_nch`-aware** (one shifter instance per voice transposes all its channels together). +So S15/S16 **compose cleanly with S7's channel dimension** rather than conflicting: S7 adds a +channel axis to the read/mix; S15 adds an amplitude-shape axis; S16 adds a pitch-engine + +read-rate axis; all orthogonal. **Recommended order:** **S15 before S16** (S16 reuses S15's +per-voice param-plumbing + component-state version bumps; landing S15's `PlayMode`/param +struct first gives S16 a home to hang the pitch-engine mode + pitch-env params on). **S16 is +now meaningfully heavier than the prior "just an envelope" framing** — the Preserve engine is +a per-voice DSP object with its own RT budget, pre-warm, and possible voice-cap; treat S16's +Preserve-engine point as the phase's next real DSP spike, not a thin add-on. **S15/S16 +relative to S7:** no hard dependency — spec them so the envelope/mode code never assumes a +channel count (it operates per-frame, pre-mix; the Preserve shifter is `set_nch`-driven), and S7 can land before, after, or interleaved. **Relative to S10 (editor):** S15's mode toggle + Trigger handles and S16's AD control **surface through** the S10/S11 waveform + guided-setup work, so the *core* halves of S15/S16 can land independently of the editor, with the editor @@ -1748,11 +1839,17 @@ Set by Daniel on DAW-testing the S1–S6 instrument, alongside the UX-overhaul d counter; read over the same bridge `GetProjExtState` path S4 already uses. No new API — confirm no torn-read hazard on the integer key. - **WDL pitch/resample (S15/S16).** **Verified this pass:** `vendor/WDL/WDL/resample.h` - (`WDL_Resampler` — sinc/linear, RT-suitable) and `vendor/WDL/WDL/simple_pitchshift.h` - (`WDL_SimplePitchShifter` — time-domain OLA, `set_formant_shift` empty stub) are the whole - pitch/resample surface; **no** elastique / formant-preserving / time-stretch in the tree. - S16 modulation is hand-rolled (no WDL). If the held sinc-repitch quality upgrade is taken, - verify `WDL_Resampler` streaming/prealloc against the per-voice RT budget before use. + (`WDL_Resampler` — sinc/linear resampler, couples duration → **Varispeed** path) and + `vendor/WDL/WDL/simple_pitchshift.h` (`WDL_SimplePitchShifter` — time-domain OLA, + **duration-preserving** → the S16 **Preserve**-engine candidate, fork S16-F2 route a) are + the whole pitch/resample surface; **no** elastique / formant-preserving in the tree. **S16 + Preserve-engine (route a) must-verify at build:** (i) **pre-warm** `WDL_SimplePitchShifter` + at voice-allocation (run silence so `m_psbuf` sizes and `m_queue` reaches steady state) → + **no `process`-thread `WDL_Queue::Add` growth**; (ii) measure **per-voice CPU + onset + latency** (window·srate) against the polyphony cap; (iii) set a **Preserve-mode-specific + voice cap** if the per-voice cost demands one. The pitch-envelope modulation is hand-rolled + over whichever engine. If the held sinc **Varispeed**-quality upgrade is taken, verify + `WDL_Resampler` streaming/prealloc against the per-voice RT budget before use. - **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the `IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event routing) — the least-trodden edge of the phase. @@ -1937,9 +2034,20 @@ a multi-capture drag over the FX button is either rejected or loads the first). - **Trigger ignores note-off; choke is out of scope (S15).** In Trigger mode note-off is a no-op and the one-shot plays to `playEnd`. Choke-on-note-off / choke-groups are held (fork S15-F1, Tier-3-adjacent) — do not add a choke path in S15. -- **Pitch envelope is off by default (S16).** Default-disabled → offset always 0 → `ratio_` - unchanged → playback bit-identical to pre-S16. A regression that applies pitch modulation - when the envelope is off is a bug. +- **Pitch envelope is off by default (S16).** Default-disabled → offset always 0 → the + engine's un-modulated output → playback bit-identical to the same engine pre-envelope. A + regression that applies pitch modulation when the envelope is off is a bug. +- **Pitch engine is a per-zone performance choice, not a bank fact (S16).** Varispeed vs + Preserve is per-`PerformanceZone` component state (D-B), never written to `Sample` or the + bank. The engine default is fork S16-F1 (**lean Preserve** — Daniel's call), with a + prominent per-zone toggle so drum/one-shot zones opt into Varispeed cheaply. +- **Preserve engine is RT-disciplined (S16).** The `WDL_SimplePitchShifter` (or hand-rolled) + Preserve path **pre-warms at voice-allocation** and does **no allocation in `process`** — a + `WDL_Queue::Add` or `Resize` on the audio thread in steady state is a bug. Preserve's onset + latency (shifter window) is an accepted property, **not** a defect; a note-onset **click or + smear** from a cold-started (un-pre-warmed) shifter **is** a bug. +- **`WDL_Resampler` is not a Preserve engine (S16).** It is a resampler (couples duration) — + a held Varispeed-quality option only. Do not wire it as the duration-preserving path. - **Drop-and-load must not regress the two existing drags.** S17 inserts a new middle case (`InstrumentDrop`) between the M11 OS drag-out and the internal bank-to-bank drag; both existing gestures stay byte-for-byte unchanged in their own regions. Drop-and-load never diff --git a/PLAN.md b/PLAN.md index 848ad97..08a2366 100644 --- a/PLAN.md +++ b/PLAN.md @@ -690,51 +690,156 @@ orthogonal dimensions (§sequencing note in CONTEXT.md). instrument stays a **read-only bank consumer** (mode/params are performance map, never written to the bank). -## S16 — pitch envelope (AD, off by default; per-voice pitch modulation) -**Goal:** Add a per-voice **pitch envelope** on top of a zone's base repitch — a short -**AD** (attack-decay) modulation of playback pitch, **off by default**, that biases the -voice's read-increment over time. The classic use is the percussive **pitch drop** (start -offset above the target, glide down to the base pitch) and its inverse (pitch-up sweep). -Because the core's resampler is already an arbitrary per-frame `readPos_ += ratio_` linear -interpolation (**confirmed from `sampler_core.cpp`** — the read increment is recomputed-able -per frame at no structural cost), the pitch envelope is a **per-frame multiply of `ratio_`** -by `2^(envSemitones(frame)/12)`; no new resampler is needed. Per-instance performance-map -state (D-B), off by default so existing playback is bit-unchanged. CONTEXT.md §Phase S -(Pitch envelope). **Settled feature; the shape lean below is a build-time residual.** -**Verify (in DAW):** with the pitch envelope **off** (default), playback is identical to -S15/S5 (regression — no pitch modulation applied); enabling an AD pitch envelope on a zone -makes a held/triggered note **start offset in pitch and glide to the zone's base pitch** over -the attack+decay time (a percussive pitch drop when the offset is positive-then-settle); -range is settable in **semitones (±)**; the modulation is per-voice (polyphonic notes each -run their own envelope); the pure pitch-envelope curve is asserted against known values -(offset at t=0, base at t=attack+decay); RT-safe (no allocation in `process` — the envelope -is the same per-frame tick idiom as the amp envelope). -**Depends on:** S3 (the voice read-increment + envelope tick idiom this reuses), S5 (the -`PerformanceZone` it attaches to). Composes with S15 (Gate or Trigger) — the pitch envelope -is orthogonal to the amp mode. Independent of S7 (pitch is a read-rate scalar, channel-count -agnostic). +> **S15 × S16 pitch-engine interaction (informs the S16 Preserve engine).** S15's amplitude +> semantics are defined over the voice's **source-frame** timeline, which the S16 pitch-engine +> mode (Varispeed vs Preserve) changes underneath them. Contracts to hold: +> - **Trigger %-length** — under **Preserve**, %-length is measured in **source frames** +> (`playEnd = start + round(lengthFraction·(frames − start))`, unchanged) but wall-clock is +> now **stable under transpose** (a transposed Trigger keeps its %-length duration). This is +> *cleaner* than Varispeed, where transposing a Trigger also scales its audible length. So +> S15's %-length spec is unchanged; Preserve just makes it pitch-independent. +> - **Gate sustain loop** — under **Preserve**, loop the **source read** (the `[loopStart, +> loopEnd)` source-frame region S15/S2 already defines) and feed the looped source stream +> into the shifter; the shifter transposes the **output**. Contract: *loop the source, shift +> the output* — the loop points stay source-frame facts (S11's markers are unchanged), and +> the Preserve engine sits after the loop read. Under Varispeed the loop read itself carries +> the pitch (today's behavior). +> - **Start point** — unaffected by engine: it is a source-frame offset (initial read +> position) in both, independent of how pitch is applied. +> These are S16-owned build details (the Preserve engine consumes S15's source-frame read); +> S15 lands its amplitude/read machinery source-frame-defined and channel-agnostic (S7), and +> S16's engine wraps it — no change to S15's committed points. -- [ ] Core: a per-voice **AD pitch envelope** — `PitchEnvParams { enabled=false, int64 - attackFrames, int64 decayFrames, double peakSemitones }`. **Shape (lean, build-time - residual):** *start-offset → glide to base* (the classic pitch drop) — at note-on the - pitch offset is `peakSemitones`, ramps to 0 over `attackFrames` (or holds at peak for - attack then decays to 0 over `decayFrames` — pick the two-segment AD: **rise to peak over - attack, fall to 0 over decay**, so a zero attack gives the pure "start high, drop to base" - drop; documented). Off by default (`enabled=false` → offset always 0 → `ratio_` unchanged, - bit-identical to today). Pure, unit-tested (t=0 offset, t=attack peak, t=attack+decay → 0). -- [ ] Core: apply the envelope as a **per-frame `ratio_` multiply** — effective read - increment = `pitchRatio(note,root) · 2^(pitchEnvSemitones(frame)/12)`. Reuses the existing - `renderFrame` read loop (the one confirmed to advance `readPos_` by an arbitrary ratio each - frame); no new resampler, no WDL dependency for the modulation path (see the WDL finding — - WDL_Resampler is a *quality* option for the base repitch, **not** needed for envelope - modulation). Semitone range ± (settable; default range noted at build). -- [ ] Parameter ownership + editor: the pitch envelope is per-zone instrument performance-map - state (D-B), additive/version-bumped (back-compat: absent → disabled). Editor exposure is a - small AD + depth control (folds into the S12 ADSR-editor ergonomics tier — attack/decay - sliders + a ±semitone depth); default-off so the control is discoverable but inert until - turned on. +## S16 — pitch engine modes (Varispeed vs Preserve) + pitch envelope (per-voice) +**Goal:** Give the sampler **two pitch behaviors** and a pitch envelope that rides whichever +is chosen. Repitch today is **Varispeed** — resampling that couples pitch and duration (an +octave up halves the note's duration; the classic sampler / RS5K default). Daniel's directive +(2026-07-26, verbatim: *"isn't that ratio stuff going to change the playback rate? I want +duration-preserving repitching"*) adds **Preserve** — duration-preserving repitch, where a +transposed note keeps its original length. Both are musically legitimate: **drums / one-shots +often want varispeed character** (the pitch-down-lengthens-the-hit sound), **tempo-locked +loops and phrases want Preserve** (a repitched loop still lines up to the bar). So the shape +is a **per-zone/per-capture pitch-engine mode** — a **Varispeed** engine (current, cheap, +`readPos_ += ratio_` resampling) vs a **Preserve** engine (duration-preserving pitch shift). +On top of either engine rides a per-voice **AD pitch envelope**, **off by default** — a short +attack-decay pitch modulation (the classic percussive **pitch drop**): under Varispeed it +biases `ratio_`; under Preserve it biases the shifter's shift amount. Per-instance +performance-map state (D-B). CONTEXT.md §Phase S (Pitch engine modes + pitch envelope). +**Feature settled per the directive; the engine default is a flagged Daniel fork (S16-F1), +the Preserve-engine implementation choice is a flagged fork (S16-F2).** +**Verify (in DAW):** +- **Varispeed engine** (per-zone): a note an octave up plays **half as long** as the root + note (pitch and duration coupled) — the current behavior, now explicitly the Varispeed mode. +- **Preserve engine** (per-zone): a note an octave up plays at the **same duration** as the + root note (pitch shifted, length held) — a Gate held note sustains as long as held; a + Trigger one-shot at %-length keeps its %-length wall-clock regardless of transpose. +- **Pitch envelope off** (default) under **either** engine: playback is identical to the + engine's un-modulated output (regression — no pitch modulation applied). +- **Pitch envelope on**: an AD envelope makes a note **start offset in pitch and glide to the + zone's base pitch** over attack+decay (percussive drop when the offset is positive-then- + settle); range settable in **semitones (±)**; per-voice (polyphonic notes each run their own). +- **Preserve CPU / voice-cap:** with a chord of Preserve-mode voices, CPU stays within budget + and no audio dropout at the polyphony cap; if Preserve is materially heavier, a **Preserve- + mode-specific voice cap** kicks in (below the Varispeed cap) rather than glitching. +- **Latency honesty:** a Preserve note has a small onset latency (the shifter's window); the + spec accepts this as a Preserve-mode property, and a **note-onset click/smear is absent** + (the shifter is pre-warmed at voice-allocation, not cold-started in `process`). +- **RT-safety:** no allocation in `process` for **either** engine — the Varispeed path is the + same per-frame tick idiom as the amp envelope; the Preserve shifter's buffers are + pre-sized/pre-warmed at voice allocation and reused (no queue growth in steady state). +- Pure-core assertions: the pitch-envelope curve against known values (offset at t=0, base at + t=attack+decay); the Varispeed ratio math; the Preserve engine's duration invariance (a + transposed render is the same frame-length as the un-transposed render). +**Depends on:** S3 (the voice read-increment + envelope tick idiom; the Varispeed path *is* +the current read loop), S5 (the `PerformanceZone` the mode + envelope attach to), S15 (the +per-zone param plumbing + component-state version bump the mode/envelope hang on; and the +Gate-loop / Trigger-%-length semantics the Preserve engine must honor — see S15 interaction +below). Independent of S7 (both engines operate per-frame, channel-count agnostic). -> **WDL pitch capabilities — verified finding (feeds S15/S16 build, not a committed point).** +- [ ] Core: **pitch-engine mode on the voice/zone** — `PitchEngine { Varispeed, Preserve }`. + **Varispeed** = today's path (`readPos_ += ratio_`, `ratio_ = pitchRatio(note,root)`), pitch + and duration coupled. **Preserve** = duration-preserving: the read advances at the **source** + rate (duration held) while a pitch shifter transposes the output by + `2^((note−root)/12)`. Mode is per-`PerformanceZone` performance state (D-B), additive/ + version-bumped; **default is S16-F1 (flagged Daniel fork — lean Preserve, argued below).** + Absent/older blob → the fork's default. Pure where possible: the Varispeed math and the + duration-invariance contract are unit-tested; the Preserve DSP core is unit-tested for + duration invariance and transpose correctness against a known signal. +- [ ] Core: **Preserve engine implementation (fork S16-F2, flagged).** Two viable routes, both + RT-disciplined (pre-allocated, no locks, no `process` allocation): + - **(a) `WDL_SimplePitchShifter`** (`vendor/WDL/WDL/simple_pitchshift.h`) — a per-voice + time-domain OLA shifter. **Now the right category** (duration-preserving is the + requirement, not the wrong tool it was under the varispeed-only framing). Viability from + the header (assessed this pass): push/pull block API (`GetBuffer`/`BufferDone`/ + `GetSamples`), `set_shift(2^(semi/12))` for pitch with `set_tempo(1.0)` for held duration + — pitch and duration are **separately controllable**, exactly Preserve. Per-instance memory + is modest (an OLA ring `bsize = window_ms·srate` ≈ a few KB/voice at the ~50 ms quality-0 + window, plus a bounded output queue). CPU is cheap (O(length), a few mults + one OLA + crossfade per frame — REAPER's "SimpleWindowed" mode, known-basic but usable), so N + polyphonic voices each running one is **feasible within RT discipline**. **Costs, stated:** + (i) **latency** — inherent ~half-window onset delay (~25 ms at the 50 ms window) plus + fill-up, so Preserve notes have a real onset lag; mitigated by pre-warming the shifter at + voice-allocation, and it lands mostly on sustained/loop material (Varispeed serves the + tight-transient one-shots); (ii) **queue allocation** — `BufferDone` grows `m_queue` via + `WDL_Queue::Add`, an RT hazard *only* until steady state; pre-warm with silence at voice- + allocation so the buffers settle and stop growing; (iii) **quality** — basic OLA, audible + warble on large transpositions and no formant preservation (`set_formant_shift` is an + empty stub), acceptable for the loop/phrase use case. + - **(b) hand-rolled OLA/granular pitch core** as a **pure module** (`pitch_shift`, mirroring + the house pattern — CTest-testable, no REAPER/VST3/WDL type at the boundary). More work, + but full control over latency/window/crossfade, RT-shape owned by us, and it sits natively + alongside `peaks`/`wav_trim`. **Lean: start with (a)** to prove the Preserve mode end-to- + end at low cost, and hold (b) as the quality/latency upgrade if the SimpleWindowed warble + or onset lag proves musically unacceptable — the mode's *contract* is identical either way, + so the engine swap is behind the `PitchEngine::Preserve` seam. **`WDL_Resampler` does not + apply here** — it is a *resampler* (couples duration), a Varispeed-quality option, not a + Preserve engine. **elastique is NOT available** (licensed zplane, not in the vendored tree + — restated, not worked around); JUCE / rubberband / signalsmith are **new-dependency forks + carrying full D-A weight** (bare-VST3-no-framework is locked D-A) — not proposed. +- [ ] Core: a per-voice **AD pitch envelope**, engine-aware — `PitchEnvParams { enabled=false, + int64 attackFrames, int64 decayFrames, double peakSemitones }`. **Shape (lean, build-time + residual):** two-segment AD — at note-on the pitch offset rises to `peakSemitones` over + `attackFrames`, then falls to 0 over `decayFrames` (**zero attack** = the pure "start high, + drop to base" percussive drop). Off by default (`enabled=false` → offset always 0). **Applied + per engine:** under **Varispeed** the offset multiplies `ratio_` by + `2^(pitchEnvSemitones(frame)/12)` (the read-rate bias, as before); under **Preserve** the + offset is **added to the shifter's shift amount** — `set_shift(2^((note−root + + pitchEnvSemitones(frame))/12))` — so the pitch bends without touching duration. Pure, unit- + tested (t=0 offset, t=attack peak, t=attack+decay → 0; and the semitone→shift/ratio mapping + for both engines). +- [ ] Parameter ownership + editor: pitch-engine mode + pitch envelope are per-zone + instrument performance-map state (D-B), additive/version-bumped (absent → engine default per + S16-F1, envelope disabled). Editor exposure: a **per-zone Varispeed/Preserve toggle** in the + S10 guided setup / S10-Z Zones panel (a two-state control next to the S15 mode toggle), plus + a small AD + ±semitone depth control for the envelope (folds into the S12 ADSR-editor tier). + Default-off envelope so the control is discoverable but inert until enabled. The instrument + stays a **read-only bank consumer** (mode/envelope are performance map, never written to the + bank). + +> **S16-F1 (FLAGGED — Daniel fork): the default pitch engine.** **Lean: Preserve default.** +> Argued honestly both ways: +> - **For Preserve default** (the lean): Daniel asked for duration-preserving *unprompted*, +> which reads as the behavior he expects; and the capture workflow is **loop/phrase-heavy** +> (banks are captured slices of a project — tempo-locked material that benefits from +> duration preservation when transposed). For that material, Varispeed's tempo-drift on +> transpose is the surprising/wrong-feeling result. +> - **For Varispeed default** (the honest counter): **Varispeed is the classic-sampler +> expectation** (RS5K, hardware samplers, the whole tradition default to it); it is **cheaper +> and zero-latency** (no shifter, no onset lag); and it is bit-identical to the current +> shipped S3/S5 behavior, so a Preserve default is a *behavior change* for any existing feel. +> Percussive one-shot material specifically *wants* the varispeed character. +> - **Recommendation:** default **Preserve** because Daniel asked for it and the material skews +> loops, but make the per-zone toggle **prominent and cheap to flip** so drum/one-shot zones +> trivially opt into Varispeed. **Daniel's call.** + +> **S16-F2 (FLAGGED): the Preserve engine implementation.** `WDL_SimplePitchShifter` (route a, +> low-cost proof) vs a hand-rolled pure `pitch_shift` OLA/granular module (route b, more work, +> full control, house-native + CTest-testable). **Lean: (a) first, (b) as the held quality/ +> latency upgrade** — same `PitchEngine::Preserve` contract behind the seam either way. Not +> load-bearing for the *feature* decision (S16-F1); a build-time route choice flagged because +> it sets whether a new vendored-WDL usage or a new pure module enters the tree. + +> **WDL pitch capabilities — verified finding (feeds S16 build, not a committed point).** > The full WDL pitch/resample surface was swept (`vendor/WDL/WDL/resample.h`, > `simple_pitchshift.h` — the only two pitch/resample headers; no elastique, no > formant-preserving/time-stretch anywhere in the vendored tree). Findings, honest: @@ -744,25 +849,36 @@ agnostic). > pre-sized) and its **sinc mode beats the core's current 2-point linear interp** for > repitch quality (fewer aliasing artifacts on large transpositions) at a real CPU cost > (64-tap conv per output sample vs. one lerp). **Fit:** an *optional quality upgrade for -> the base repitch path* — a per-voice quality toggle (linear = cheap default, sinc = -> quality) — **not** required for S15/S16 and **not** committed here. Held as a Tier-2/3 -> quality option (see "held" below), because per-voice `WDL_Resampler` instances are -> heavier and the pitch-envelope modulation (S16) is cleaner hand-rolled anyway. -> - **`WDL_SimplePitchShifter`** (`simple_pitchshift.h`) — a **time-domain overlap-add -> pitch shifter** (window/overlap, quality param). It is *pitch-shift-preserving-duration*, -> the opposite of what a sampler wants (a sampler repitches by resampling, changing both -> pitch and duration together). Its `set_formant_shift` is an **explicit empty stub** — so -> **no formant preservation**. Not a fit for the sampler repitch/envelope path; noted for -> completeness. -> - **Formant-preserving / time-stretch (elastique-class): NOT in WDL, confirmed.** REAPER's -> elastique is **licensed (zplane), not part of the open WDL/reaper-sdk vendored tree** — -> grep of `vendor/WDL` for elastique/formant/time-stretch found only unrelated libpng/giflib -> string matches. So formant-correct repitch is **unavailable to the instrument** without a -> new third-party dependency (out of scope, D5-adjacent). **Stated, not worked around.** -> - **Recommendation:** the S16 pitch-envelope ratio-modulation stays **hand-rolled** (a -> per-frame `ratio_` multiply over the existing linear-interp read — simplest, RT-safe, -> already supported). `WDL_Resampler` (sinc) is the *only* WDL piece worth adopting, and -> only as an **optional base-repitch quality upgrade** — held, not scheduled. +> the **Varispeed** base repitch path* — a per-voice quality toggle (linear = cheap default, +> sinc = quality) — **not** required for S16 and **not** committed here. A resampler couples +> duration, so it is **not** a Preserve engine. Held as a Tier-2/3 Varispeed-quality option. +> - **`WDL_SimplePitchShifter`** (`simple_pitchshift.h`) — a **time-domain overlap-add, +> duration-preserving pitch shifter** (push/pull block API; `set_shift` for pitch and +> `set_tempo` as an independent duration knob; quality parameter selecting window/overlap +> sizes). **Under Daniel's duration-preserving directive this is the right category** — the +> candidate Preserve engine (fork S16-F2, route a). **Viability (from the header):** per-voice +> instantiable at modest memory (an OLA ring ≈ window·srate, a few KB/voice at the ~50 ms +> quality-0 window, plus a bounded output queue); CPU is cheap (O(length), a few mults + one +> OLA crossfade per frame — no FFT); N polyphonic voices each running one is **feasible in +> RT discipline** with **two caveats:** (i) **inherent latency** ~half-window (~25 ms @ 50 ms +> window) + fill-up → a real note-onset lag (pre-warm at voice-allocation; it lands on +> sustained/loop material where it is least harmful); (ii) `BufferDone` grows `m_queue` via +> `WDL_Queue::Add` — an allocation hazard **only until steady state**, pre-warmed away with a +> silence pass at voice-allocation. **Quality is basic** (SimpleWindowed warble on large +> transpositions) and **`set_formant_shift` is an empty stub** (no formant preservation) — +> acceptable for the loop/phrase Preserve use, replaceable by the hand-rolled route (b) if not. +> - **Formant-preserving / high-quality time-stretch (elastique-class): NOT in WDL, confirmed.** +> REAPER's elastique is **licensed (zplane), not part of the open WDL/reaper-sdk vendored +> tree** — grep of `vendor/WDL` for elastique/formant/time-stretch found only unrelated +> libpng/giflib string matches. Formant-correct / studio-grade duration-preserving repitch is +> **unavailable to the instrument** without a new third-party dependency (JUCE / rubberband / +> signalsmith would each be a **new-dependency fork carrying D-A weight** — bare-VST3-no- +> framework is the locked D-A choice — and are **not proposed**). **Stated, not worked around.** +> - **Recommendation:** the **Preserve** engine (S16-F2) is either `WDL_SimplePitchShifter` +> (route a, low-cost proof) or a hand-rolled pure `pitch_shift` module (route b, held quality +> upgrade). The **pitch-envelope** modulation stays hand-rolled over whichever engine (a per- +> frame `ratio_` multiply under Varispeed, a per-frame shift-amount add under Preserve). +> `WDL_Resampler` (sinc) remains a held **Varispeed-quality** upgrade only. > **S14 moved to Phase L (2026-07-26).** The dock-panel refresh that stood here is now > **Phase L point L2** on `dev` — and, per Daniel's DS-3 call, expanded from a light re-skin @@ -900,13 +1016,20 @@ artifact, not extension-only. Tier 0–1 proves the instrument belongs — **its points are not drawn up here.** - **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch envelopes, LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave - room for, never a commitment. **Note:** S16 lands the *pitch* envelope early (Daniel's - directive) — the Tier-3 "filter/pitch envelopes" line now means the *filter* envelope + - LFOs remainder. -- **Sinc repitch quality upgrade (HELD — WDL_Resampler).** `WDL_Resampler`'s sinc mode - beats the core's 2-point linear interp for base-repitch quality (see the S16 WDL - finding). An optional per-voice quality toggle (linear default / sinc), RT-suitable but - heavier. Held as a Tier-2/3 quality option — not needed for S15/S16, not scheduled. + room for, never a commitment. **Note:** S16 lands the *pitch* envelope + the Varispeed/ + Preserve pitch-engine mode early (Daniel's directive) — the Tier-3 "filter/pitch envelopes" + line now means the *filter* envelope + LFOs remainder. +- **Sinc Varispeed-quality upgrade (HELD — WDL_Resampler).** `WDL_Resampler`'s sinc mode + beats the core's 2-point linear interp for **Varispeed** base-repitch quality (see the S16 + WDL finding). An optional per-voice quality toggle (linear default / sinc), RT-suitable but + heavier. Held as a Tier-2/3 quality option — not needed for S15/S16, not scheduled. (A + resampler couples duration, so it is a Varispeed-quality option only, **not** a Preserve + engine.) +- **Hand-rolled Preserve pitch core (HELD — fork S16-F2 route b).** A pure `pitch_shift` + OLA/granular module (house pattern, CTest-testable) as the quality/latency upgrade for the + S16 Preserve engine if `WDL_SimplePitchShifter`'s SimpleWindowed warble or onset lag proves + musically unacceptable. Same `PitchEngine::Preserve` contract behind the seam. Held — the + build starts with route a (WDL) to prove Preserve at low cost. - **Trigger choke-on-note-off (HELD — fork S15-F1).** A future option for Trigger mode to *cut* (choke) on note-off or on a same-group re-trigger (hi-hat open/closed). Deliberately out of S15 scope (Trigger ignores note-off entirely there); a Tier-3 choke-group direction. @@ -942,12 +1065,17 @@ artifact, not extension-only. integer generation key for a bridge read on the instrument's UI/timer thread concurrent with an extension write. - **WDL pitch/resample surface (S15/S16)** — **verified this pass:** `resample.h` - (`WDL_Resampler`, sinc/linear, RT-suitable) and `simple_pitchshift.h` - (`WDL_SimplePitchShifter`, time-domain OLA, `set_formant_shift` is an empty stub) are the - **whole** pitch/resample surface; **no** elastique / formant-preserving / time-stretch in - the vendored WDL tree. S16's pitch-envelope modulation stays hand-rolled over the existing - linear-interp read (no WDL needed). If the held sinc-repitch upgrade is ever taken, verify - `WDL_Resampler` streaming/prealloc against per-voice RT budget before use. + (`WDL_Resampler`, sinc/linear, RT-suitable — a *resampler*, couples duration → Varispeed + path) and `simple_pitchshift.h` (`WDL_SimplePitchShifter`, time-domain OLA, **duration- + preserving** — the S16 Preserve-engine candidate, fork S16-F2 route a) are the **whole** + pitch/resample surface; **no** elastique / formant-preserving in the vendored WDL tree. + **S16 Preserve-engine (route a) must-verify at build:** pre-warm `WDL_SimplePitchShifter` + at voice-allocation so `m_queue` reaches steady state (no `process`-thread `WDL_Queue::Add` + growth); measure the per-voice CPU + onset latency (window·srate) against the polyphony cap; + set the **Preserve-mode-specific voice cap** if the per-voice cost demands one. The pitch- + envelope modulation stays hand-rolled over whichever engine. If the held sinc Varispeed- + quality upgrade is ever taken, verify `WDL_Resampler` streaming/prealloc against per-voice + RT budget before use. - **Drop-and-load (S17) — three surfaces.** (1) `TrackFX_AddByName` — **verified present** in `reaper_plugin_functions.h` (signature confirmed; the `"VST3:"` name prefix and the negative-`instantiate`-always-adds semantics are documented in the header comment). (2) diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index a28b587..88b3a92 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -7,12 +7,20 @@ the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN *why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled decision record). -Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r7)**. r7 records the +Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r8)**. r8 records +Daniel's **duration-preserving correction** (2026-07-26, verbatim: *"isn't that ratio stuff +going to change the playback rate? I want duration-preserving repitching"*): the ratio path is +**varispeed** (pitch/duration coupled), so S16 is reshaped from "pitch envelope only" into a +**pitch-engine mode — Varispeed vs Preserve — plus the pitch envelope** riding either engine. +The WDL verdict flips: `WDL_SimplePitchShifter` (duration-preserving OLA), previously dismissed +as the wrong tool, is **now the Preserve-engine candidate** and got a real per-voice RT +viability assessment. Two S16 forks flagged: **S16-F1** (engine default — lean Preserve) and +**S16-F2** (Preserve implementation — WDL shifter first, hand-rolled pure module held). See the +r8 Addendum in §4. r7 records the **sampling-modes engine directive** (Daniel, 2026-07-26): Trigger vs Gate play modes (Gate = AHDSR, Trigger = one-shot with %-length + fades), a modifiable start point in both, and an off-by-default AD pitch envelope — specced as **new Phase S points S15/S16**, with the WDL -pitch surface swept and reported (sinc `WDL_Resampler` held as an optional quality upgrade; -no formant-preserving/elastique in WDL). See the "sampling modes" Addendum in §4. r6 records the +pitch surface swept and reported. See the "sampling modes" Addendum in §4. r6 records the **workflow-first reframe of S10** (Daniel, 2026-07-26): the editor's default face becomes a **capture browser + guided single-capture setup**, a fresh instance is **silent with a "pick a capture" empty state** (reversing the S4 first-sample auto-play), and multi-zone editing is @@ -777,6 +785,12 @@ partly on filename, fall back to keeping the filename and record that as shipped ### Addendum — sampling modes (Trigger/Gate) + pitch envelope (Daniel, 2026-07-26) +> **Superseded in part by the r8 Addendum below (2026-07-26).** Daniel's duration-preserving +> correction reshaped S16 from "pitch envelope only" into a Varispeed/Preserve pitch-engine +> mode, and **flipped this addendum's WDL verdict** — `WDL_SimplePitchShifter` (called the +> "wrong tool" in item 3 below) is now the Preserve-engine candidate. Read this as the r7 +> point-in-time record; the r8 Addendum carries the current S16 shape. + Daniel directed a set of engine features for the sampler, specced as **new Phase S points S15 (Trigger vs Gate) and S16 (pitch envelope)**. **The feature set is settled** — recorded here per the doc's settled-decisions convention; PLAN.md §S15/S16 and CONTEXT.md §Sampling @@ -829,6 +843,81 @@ off by default. Explore using WDL pitch capabilities."* **lean per-zone only** (the single capture is already a one-zone map), flagged because it touches S10's single-capture setup surface. +### Addendum — duration-preserving correction: S16 becomes pitch-engine modes (Daniel, 2026-07-26, r8) + +**Correction, verbatim (Daniel, 2026-07-26):** *"isn't that ratio stuff going to change the +playback rate? I want duration-preserving repitching."* Daniel is right about the mechanics. +The r7 S16 spec modulated pitch by biasing the per-frame read ratio (`readPos_ += ratio_`) — +that is **varispeed**: pitch and duration are coupled (an octave up halves the note's +duration). Daniel wants **duration-preserving** repitch (a transposed note keeps its length). +This reshapes S16 and **flips the r7 WDL verdict** on `WDL_SimplePitchShifter`. + +**What changed (the reframe, then the spec):** + +1. **The reframe — this is a mode, not a replacement.** Both behaviors are musically + legitimate, so the answer is not "swap varispeed for preserve" but **a per-zone/per-capture + pitch-engine mode**: + - **Varispeed** (current path, cheap, zero-latency) — pitch/duration coupled. The + **classic sampler / RS5K** default; right for **drums / one-shots** (pitch-down-lengthens- + the-hit is a feature there). + - **Preserve** (duration-preserving) — a pitch shifter transposes the output while the read + holds the source duration. Right for **tempo-locked loops and phrases** (a transposed loop + still lines up to the bar) — which is what captured banks skew toward (project slices). + The pitch envelope (r7's S16 body) then rides **either** engine: under Varispeed it biases + the read ratio (as specced); under Preserve it biases the shifter's shift amount. So the + envelope is preserved, re-homed onto the engine seam. + +2. **Fork S16-F1 (Daniel's call): the default engine. Lean Preserve.** Argued honestly: + Preserve because Daniel asked for it **unprompted** (reads as his expectation) and the + capture workflow is **loop/phrase-heavy**; but Varispeed is the **classic-sampler + expectation**, is **cheaper + zero-latency**, is **bit-identical to today's shipped feel**, + and is what percussive one-shots want. Recommendation: **default Preserve, prominent cheap + per-zone toggle to Varispeed.** Daniel's call. + +3. **WDL verdict corrected — `WDL_SimplePitchShifter` is now the right category.** Under + "duration-preserving is the requirement," r7's dismissal ("wrong tool, duration-preserving + OLA") inverts: **duration-preserving is exactly what we need.** A real per-voice RT + viability assessment (from `vendor/WDL/WDL/simple_pitchshift.h`): + - **API:** push/pull block (`GetBuffer`/`BufferDone`/`GetSamples`); `set_shift(2^(semi/12))` + for pitch with an **independent** `set_tempo(1.0)` duration knob — pitch and duration + separately controllable, exactly Preserve. + - **Per-voice:** modest memory (OLA ring ≈ window·srate ≈ a few KB/voice at the 50 ms + quality-0 window). CPU cheap (O(length), a few mults + one OLA crossfade/frame, **no + FFT**) → **N polyphonic voices each running one is feasible** in RT discipline. + - **Costs owned:** (i) **onset latency** ~half-window (~25 ms @ 50 ms) — the load-bearing + cost; pre-warm at voice-allocation, and it lands on sustained/loop material (Varispeed + serves tight one-shots); (ii) **queue-growth** allocation in `BufferDone` — settled by a + silence pre-warm so no `process`-thread allocation in steady state; (iii) **basic + quality** (SimpleWindowed warble on big transpositions; `set_formant_shift` is an empty + stub → no formant preservation) — acceptable for loops, replaceable by route (b). + - **Fork S16-F2:** **route (a)** `WDL_SimplePitchShifter` (low-cost proof) vs **route (b)** a + hand-rolled pure `pitch_shift` OLA/granular module (house pattern, CTest-testable, full + control). **Lean (a) first, (b) held** as the quality/latency upgrade — same + `PitchEngine::Preserve` contract behind the seam. + +4. **Not proposed / restated ceilings.** `WDL_Resampler` is a *resampler* (couples duration) — + a held **Varispeed-quality** upgrade, **not** a Preserve engine. **elastique is NOT + available** (licensed zplane, not vendored). JUCE / rubberband / signalsmith are each a + **new-dependency fork carrying full D-A weight** (bare-VST3-no-framework is the locked D-A + choice) — **not proposed**. + +5. **S15 interaction (cleaner under Preserve).** Trigger's **%-length** becomes **pitch- + independent** under Preserve (wall-clock stable under transpose — cleaner than Varispeed, + where transposing a Trigger also scales its length); Gate's **sustain loop** contract under + Preserve is *loop the source read, shift the output* (loop points stay source-frame facts); + the **start point** is engine-independent (a source-frame offset). Channel-agnostic for S7 + (the shifter is `set_nch`-aware; one instance per voice carries all channels). + +6. **RT/CPU honesty.** Preserve is **meaningfully heavier** than varispeed — a per-voice DSP + object with its own budget, pre-warm, and a possible **Preserve-mode-specific voice cap** + (below the Varispeed cap) if per-voice cost demands it. Put in Verify: pre-warm → no + `process` allocation; measure per-voice CPU + onset latency against the polyphony cap. Treat + S16's Preserve-engine point as the phase's next real DSP spike, not a thin envelope add-on. + +**Where the spec lives:** PLAN.md §S16 (reshaped to "pitch engine modes + pitch envelope", +with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16 interaction note; +CONTEXT.md §Pitch engine modes — Varispeed vs Preserve + the corrected WDL surface finding. + --- ## Where this landed @@ -878,21 +967,29 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26, post-S1–S6 DAW test). -10. **Sampling modes + pitch envelope → engine features** (**S15** Trigger vs Gate, - **S16** pitch envelope; see the "sampling modes" Addendum in §4). Gate = AHDSR held note - (hold added to today's ADSR); Trigger = one-shot with %-length + fade-in/out, ignores - note-off; both carry a modifiable start point; Gate keeps loop points. Pitch envelope = - per-voice AD, off by default, hand-rolled over the existing per-frame ratio read (no WDL - needed). WDL swept: `WDL_Resampler` (sinc) is a held optional repitch-quality upgrade; - no formant-preserving/elastique in WDL. Forks S15-F1 (choke, held) / S15-F2 (param - granularity, lean per-zone) flagged. Feature set settled. +10. **Sampling modes + pitch engine → engine features** (**S15** Trigger vs Gate, **S16** + pitch-engine modes + pitch envelope; see the "sampling modes" r7 + "duration-preserving" + r8 Addenda in §4). Gate = AHDSR held note (hold added to today's ADSR); Trigger = one-shot + with %-length + fade-in/out, ignores note-off; both carry a modifiable start point; Gate + keeps loop points. **S16 reshaped (r8, Daniel's duration-preserving correction):** a per- + zone **pitch-engine mode — Varispeed** (current, cheap, pitch/duration coupled — classic + sampler, right for drums) **vs Preserve** (duration-preserving via a per-voice pitch + shifter — right for tempo-locked loops/phrases). Pitch envelope = per-voice AD, off by + default, riding either engine (biases `ratio_` under Varispeed, the shift amount under + Preserve). WDL verdict corrected: **`WDL_SimplePitchShifter` is the Preserve-engine + candidate** (duration-preserving OLA — RT-viable per-voice with pre-warm; the load-bearing + cost is onset latency), `WDL_Resampler` (sinc) held as a Varispeed-quality upgrade only; no + formant-preserving/elastique in WDL. Forks: **S16-F1** (engine default — lean Preserve, + Daniel's call), **S16-F2** (Preserve impl — WDL shifter first / hand-rolled held), plus + S15-F1 (choke, held) / S15-F2 (param granularity, lean per-zone). Feature set settled; + the engine default is Daniel's fork. **Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the ReaSampler -9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-envelope engine features); +9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine features); **CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the channel-mode -/ ingest / bank-generation / sampling-mode / pitch-envelope contracts, the UX-overhaul spec, +/ ingest / bank-generation / sampling-mode / pitch-engine contracts, the UX-overhaul spec, the product-name convention, the pure/shell split, the WDL finding, the must-verify SDK/bridge surfaces). This doc is the framing/decision record they point back to. The "no PLAN.md footprint" era is over. From 4a3551b3b9395be7d1418ae4aaaf9ee7f744f43f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 21:26:21 -0400 Subject: [PATCH 26/49] =?UTF-8?q?S7:=20stereo=20channel=20mode=20=E2=80=94?= =?UTF-8?q?=20core=20channel=20dimension,=20v4=20mode=20state,=20VST3=20bu?= =?UTF-8?q?s=20negotiation,=20editor=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-instance mono|stereo (default mono, byte-identical). Stereo grows SampleData a 2nd channel + a per-channel VoiceEngine render; decodeChannels applies the cross-mode policy; setBusArrangements pins the mode's arrangement and restartComponent(kIoChanged) re-negotiates. --- PLAN.md | 41 ++++---- src/vst/reasampler_editor.cpp | 49 +++++++++- src/vst/reasampler_editor.h | 1 + src/vst/reasampler_processor.cpp | 144 +++++++++++++++++++++++----- src/vst/reasampler_processor.h | 32 +++++++ src/vst/sample_map.cpp | 68 ++++++++++++- src/vst/sample_map.h | 71 ++++++++++---- src/vst/sampler_core.cpp | 67 +++++++++++-- src/vst/sampler_core.h | 62 ++++++++++-- tests/test_sample_map.cpp | 158 +++++++++++++++++++++++++++++++ tests/test_sampler_core.cpp | 119 +++++++++++++++++++++++ 11 files changed, 727 insertions(+), 85 deletions(-) diff --git a/PLAN.md b/PLAN.md index 08a2366..eff680a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -285,25 +285,28 @@ unchanged (regression). **Depends on:** S3 (extends the core), S4 (extends the process/bus shell). Independent of S8/S9. -- [ ] Core channel dimension (pure, S3 extension): `SampleData` carries N-channel - (1 or 2) decoded PCM; `Voice::renderFrame` and `VoiceEngine::render` produce a - per-channel frame; stereo linear interpolation + loop read per channel. Mono stays the - degenerate case (single channel) — no behavior change for existing mono play. Tests: - stereo render asserted against a known 2-channel signal; mono render unchanged. -- [ ] Channel-mode toggle as per-instance state: `mono | stereo` in the instrument's own - component state (setState/getState, alongside the selected sample); default preserves - current behavior (mono). Cross-mode policy: **mono source + stereo mode → dual-mono** - (same signal both channels, centered); **stereo source + mono mode → downmix** (the - existing decode-side policy). The toggle lives in the instrument, never written to the - bank (a performance choice, not a file fact — D-B). -- [ ] Shell: decode fills 1- or 2-channel `SampleData` per the source's channel count - (the S2/bank channel-count intrinsic already exists); the process path renders the - active mode's channel count into the output bus. -- [ ] VST3 bus negotiation: implement `setBusArrangements` so the output bus reports - mono or stereo per the instance's channel mode, and REAPER's routing follows - automatically (no manual channel wiring). **Must-verify before build:** the - `setBusArrangements` / `getBusArrangement` contract and REAPER's mono/stereo instrument - bus expectations against the vendored Steinberg SDK + `reaper_vst3_interfaces.h`. +- [x] Core channel dimension (pure, S3 extension): `SampleData` carries 1- or 2-channel + decoded PCM (`frames` + optional length-matched `framesR`; `channelCount()`); + `Voice::renderFrameStereo` + a `VoiceEngine::render(left,right,n)` overload produce a + per-channel frame sharing one read head + one envelope tick; stereo linear interpolation + + loop read per channel. Mono stays the degenerate case (`renderFrame` reads channel 0 only, + byte-identical). Tests: stereo render asserted against a known 2-channel signal; dual-mono; + per-channel repitch + additive mix; mono render unchanged (regression) — sampler_core_tests. +- [x] Channel-mode toggle as per-instance state: `ChannelMode {Mono,Stereo}` in the + instrument's own component state (v4 = v3 + a channel-mode byte; setState/getState); + default mono. Cross-mode policy in `decodeChannels`: **mono source + stereo mode → + dual-mono**; **stereo source + mono mode → downmix** (existing decode-side policy). The + toggle lives in the instrument, never written to the bank (D-B). v1/v2/v3 blobs lift to v4 + with mono default; round-trip + lift tests — sample_map_tests. +- [x] Shell: `decodeRelative` fills 1- or 2-channel `DecodedZonePcm` per the active mode + (source channel count from the WAV layout); the process path renders the host's negotiated + output channel count (stereo into ch0/ch1, mono into ch0) — RT discipline unchanged. +- [x] VST3 bus negotiation: `setBusArrangements` accepts only the mode's arrangement + (kMono/kStereo), else rejects (kResultFalse) but keeps a valid mode arrangement so + `getBusArrangement` (base default) reports it; a runtime mode change repoints the output bus + + calls `restartComponent(kIoChanged)` so REAPER re-negotiates. **Verified** against the + vendored Steinberg SDK (`ivstaudioprocessor.h` contract, `vstsinglecomponenteffect.cpp` + base impl, `ivsteditcontroller.h` kIoChanged); see handoff notes. ## S8 — ingest through the bank (one gesture: capture/import into bank + assign to instance) **Goal:** Loading a sample into the sampler is **one gesture** — capture/import-into-bank diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 6bbebf3..b86008e 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -146,6 +146,7 @@ void ReaSamplerEditor::refreshFromBank() { banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; selectedId_ = processor_->selectedSampleId(); map_ = processor_->performanceMap(); + channelMode_ = processor_->channelMode(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; // Drop a filter that names a bank no longer present. if (!activeFilterBankId_.empty()) { @@ -334,6 +335,22 @@ Rect zonesStripArea(const EditorBands& bands) { return Rect{bands.content.left + pad, stripTop, bands.content.right - pad, stripTop + kStripBandHeight}; } + +// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's +// header row (same y as the sample-name header, so it reads as "this capture's output mode"). +// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide, +// kSegH tall, side by side. Kept to a small fenced block (S11 owns the waveform region). +constexpr int kChanSegW = 52; +constexpr int kChanSegH = 18; +struct ChannelToggleRects { Rect mono; Rect stereo; }; +ChannelToggleRects channelToggleRects(const Rect& area) { + constexpr int pad = 8; + const int top = area.top + 4; + const int right = area.right - pad; + const Rect stereo{right - kChanSegW, top, right, top + kChanSegH}; + const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH}; + return {mono, stereo}; +} } // namespace void ReaSamplerEditor::paint(HDC hdc) { @@ -477,10 +494,22 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { } const int pad = 8; - Rect headerR{area.left + pad, area.top + 4, area.right - pad, area.top + 22}; + // The mono/stereo toggle sits at the right of the header row; keep the name text clear of it. + const ChannelToggleRects chan = channelToggleRects(area); + Rect headerR{area.left + pad, area.top + 4, chan.mono.left - 8, area.top + 22}; std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root); drawText(bmp, headerR, header.c_str(), kRgbText); + // S7 mono | stereo output-mode toggle. The active segment highlights (kColTabActiveBg), + // the inactive is kColTabBg — the same visual grammar as the Browser/Zones toggle. + const bool isStereo = (channelMode_ == ChannelMode::Stereo); + LICE_FillRect(bmp, chan.mono.left, chan.mono.top, chan.mono.width(), chan.mono.height(), + isStereo ? kColTabBg : kColTabActiveBg, 1.0f, 0); + LICE_FillRect(bmp, chan.stereo.left, chan.stereo.top, chan.stereo.width(), + chan.stereo.height(), isStereo ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(bmp, chan.mono, "Mono", kRgbText); + drawTextCentered(bmp, chan.stereo, "Stereo", kRgbText); + Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16}; drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim); @@ -597,9 +626,25 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { commitAndReload(); // publishes the pick + reloads; process() plays it repitched return; } - // The setup strip: grab the root marker (drag to set the picked capture's root). + // The setup band: the mono/stereo toggle (header row), then the root-marker strip. if (havePick) { const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + // S7: a click on a channel-mode segment sets the instance mode (setChannelMode + // re-negotiates the bus + reloads; a no-op set for the already-active mode is ignored + // by the processor). Snapshot the new mode locally so the paint reflects it at once. + const ChannelToggleRects chan = channelToggleRects(area); + if (contains(chan.mono, x, y)) { + channelMode_ = ChannelMode::Mono; + processor_->setChannelMode(ChannelMode::Mono); + invalidate(); + return; + } + if (contains(chan.stereo, x, y)) { + channelMode_ = ChannelMode::Stereo; + processor_->setChannelMode(ChannelMode::Stereo); + invalidate(); + return; + } const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index e492869..303ada0 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -113,6 +113,7 @@ private: std::vector visible_; // samples_ narrowed by the active bank filter std::string selectedId_; // the single-capture pick ("" = empty state) PerformanceMap map_; // the opt-in zones (empty = no zones) + ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot // --- Transient UI state (not persisted; component state carries selection + zones) --- View view_ = View::kBrowser; // default face is the browser diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index daca7c7..7d65188 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -11,9 +11,12 @@ #include "pluginterfaces/base/ibstream.h" #include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate) #include "pluginterfaces/vst/ivstevents.h" #include "pluginterfaces/vst/vstspeaker.h" +#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr) + #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "ext_keys.h" // kProjExtBanksKey (shared wire contract) #include "reasampler_editor.h" @@ -63,12 +66,15 @@ std::vector readFileBytes(const std::string& path) { } // Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file -// I/O — off-thread only), and downmix to the core's mono contract. Returns nullopt when -// the path fails to resolve, the file is unreadable, the WAV is malformed, or the decode -// yields no frames — the caller drops the zone (zoned map) or plays silence (single capture). -// Shared by the zoned build and the single-capture path so both decode identically. +// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode +// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for +// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path +// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no +// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by +// the zoned build and the single-capture path so both decode identically for the active mode. std::optional decodeRelative(const std::string& projectDir, - const std::string& relativePath) { + const std::string& relativePath, + ChannelMode mode) { const std::string abs = resolveBankFile(projectDir, relativePath); if (abs.empty()) return std::nullopt; const std::vector bytes = readFileBytes(abs); @@ -76,11 +82,9 @@ std::optional decodeRelative(const std::string& projectDir, if (!layout.valid) return std::nullopt; std::vector interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount()); - std::vector mono = downmixToMono(interleaved, layout.channelCount); - if (mono.empty()) return std::nullopt; - DecodedZonePcm out; - out.monoFrames = std::move(mono); - out.sampleRate = static_cast(layout.sampleRate); + DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, + static_cast(layout.sampleRate)); + if (out.monoFrames.empty()) return std::nullopt; return out; } @@ -117,10 +121,15 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { // instrument still loads, it just has no live bank to play. bridge_.connect(context); - // Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio - // output, no audio input. This is the standard VSTi arrangement. + // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no + // audio input. The output arrangement follows the instance's channel mode (S7) — mono by + // default (kMono), stereo (kStereo) when the mode is stereo. addAudioOutput needs an initial + // arrangement; seed it at the mode's arrangement so getBusInfo is correct from the first + // query. (setState may later flip the mode and re-negotiate via setChannelMode.) addEventInput(STR16("MIDI In"), 16); - addAudioOutput(STR16("Stereo Out"), SpeakerArr::kStereo); + const ChannelMode mode = channelMode(); + addAudioOutput(STR16("Audio Out"), + mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono); return kResultOk; } @@ -175,6 +184,14 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { const ComponentState cs = deserializeComponentState(bytes); setSelectedSampleId(cs.selectionId); setPerformanceMap(cs.map); + // Restore the S7 channel mode and point the output bus at its arrangement so a reopened + // project comes back in the saved mode. setState runs before the host queries bus info, so + // seeding the arrangement here (rather than re-negotiating) is enough — no restartComponent. + { + std::lock_guard lock(channelModeMutex_); + channelMode_ = cs.channelMode; + } + applyOutputArrangement(cs.channelMode); // Rebuild from the restored state (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; @@ -190,6 +207,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { ComponentState state_out; state_out.selectionId = selectedSampleId(); state_out.map = performanceMap(); + state_out.channelMode = channelMode(); // S7: persist the per-instance mono/stereo mode const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), @@ -219,6 +237,57 @@ void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { performanceMap_ = map; } +ChannelMode ReaSamplerProcessor::channelMode() { + std::lock_guard lock(channelModeMutex_); + return channelMode_; +} + +void ReaSamplerProcessor::applyOutputArrangement(ChannelMode mode) { + // Set the single output bus's SpeakerArrangement to the mode's arrangement so getBusInfo / + // getBusArrangement report the right channel count. The default getBusArrangement (from the + // base) reads back exactly what we store here. No re-negotiation — the caller drives that. + BusList* outs = getBusList(kAudio, kOutput); + if (!outs || outs->empty()) return; + if (auto* bus = FCast(outs->at(0))) { + bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo + : SpeakerArr::kMono); + } +} + +void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { + { + std::lock_guard lock(channelModeMutex_); + if (channelMode_ == mode) return; // no-op: don't churn the bus / re-negotiate + channelMode_ = mode; + } + // The mode changed: repoint the output bus and ask the host to re-negotiate I/O so REAPER's + // routing follows (mono<->stereo). restartComponent is a main/UI-thread call; setChannelMode + // is driven from the editor, so this is safe. Then reload so the next block decodes the new + // channel count into the LoadedInstrument (off-thread, RT path untouched). + applyOutputArrangement(mode); + if (componentHandler) componentHandler->restartComponent(kIoChanged); + reloadFromBank(); +} + +tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( + SpeakerArrangement* inputs, int32 numIns, + SpeakerArrangement* outputs, int32 numOuts) { + // The instrument has ONE canonical arrangement per its channel mode (S7). We take NO audio + // input, so any inputs are rejected. For the single output bus: accept (kResultTrue) only + // when the host proposes exactly the mode's arrangement; otherwise reject (kResultFalse) but + // KEEP the mode's arrangement (per the VST3 contract, a plug-in that can't honor a proposal + // keeps a valid arrangement of its own). getBusArrangement then still reports the mode's + // channel count, so the host adapts its routing to us rather than forcing our channel count. + if (numIns < 0 || numOuts < 0) return kInvalidArgument; + if (numIns > 0) return kResultFalse; // no audio input bus to arrange + + const SpeakerArrangement want = + channelMode() == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono; + applyOutputArrangement(channelMode()); // keep the bus pinned to the mode's arrangement + if (numOuts == 1 && outputs && outputs[0] == want) return kResultTrue; + return kResultFalse; +} + 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 @@ -234,6 +303,9 @@ std::string ReaSamplerProcessor::reloadFromBank() { std::optional banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey); const std::string projectDir = bridge_.activeProjectDir(); + // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). + // Read once under its mutex, off the audio thread, before the decode loop. + const ChannelMode mode = channelMode(); std::string resolvedId; std::unique_ptr built; @@ -257,7 +329,7 @@ std::string ReaSamplerProcessor::reloadFromBank() { kept.reserve(resolved.zones.size()); for (const ResolvedZone& rz : resolved.zones) { std::optional pcm = - decodeRelative(projectDir, rz.relativePath); + decodeRelative(projectDir, rz.relativePath, mode); if (!pcm) continue; // unreadable WAV -> drop this zone kept.push_back(rz); decoded.push_back(std::move(*pcm)); @@ -279,10 +351,11 @@ std::string ReaSamplerProcessor::reloadFromBank() { selectSample(*banksJson, selectedSampleId()); if (sel) { std::optional pcm = - decodeRelative(projectDir, sel->relativePath); + decodeRelative(projectDir, sel->relativePath, mode); if (pcm) { km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, - sel->rootNote, sel->loop); + sel->rootNote, sel->loop, + std::move(pcm->framesR)); haveKeymap = true; resolvedId = selectedSampleId(); // the concrete pick that resolved } @@ -378,25 +451,48 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { 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. + // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked + // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here + // we simply match the buffers the host handed us: >=2 channels -> true stereo render into + // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the + // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; - if (ch0) { + float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; + if (ch0 && ch1) { + // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo + // path (both channels equal), so a mono capture in stereo mode is centered, not silent. + for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } + if (inst) { + inst->engine.render(ch0, ch1, static_cast(frames)); + } + // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). + for (int32 ch = 2; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; + if (a0 > peak) peak = a0; + if (a1 > peak) peak = a1; + } + embedPeak_.store(peak, std::memory_order_relaxed); + } else if (ch0) { + // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; + // the replicate is defensive for a host that still hands >1 channel on a mono bus). for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; if (inst) { inst->engine.render(ch0, static_cast(frames)); } - // Block peak (mono, pre-replicate) for the embedded strip's level indicator. A - // single scan of ch0 + one relaxed atomic store — RT-safe (no alloc/IO/lock). The - // UI thread reads it via embedActivityLevel(); a plain store is sufficient because - // the readout is advisory (no ordering dependency on other state). float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; if (a > peak) peak = a; } embedPeak_.store(peak, std::memory_order_relaxed); - // 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]; diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index d832d2a..a16e283 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -88,6 +88,16 @@ public: Steinberg::tresult PLUGIN_API process( Steinberg::Vst::ProcessData& data) override; + // S7 channel-mode bus negotiation. The instrument has ONE canonical output arrangement + // determined by its per-instance channel mode (mono -> kMono, stereo -> kStereo). We + // accept the host's proposal only when it matches that arrangement; otherwise we reject + // (kResultFalse) but keep the mode's arrangement, so getBusArrangement / getBusInfo always + // report the mode's channel count and REAPER routes accordingly. A runtime mode change + // updates the output bus + calls restartComponent(kIoChanged) to trigger re-negotiation. + Steinberg::tresult PLUGIN_API setBusArrangements( + Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns, + Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override; + //--- from IEditController ----------------------------------------------- // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; @@ -128,7 +138,23 @@ public: PerformanceMap performanceMap(); void setPerformanceMap(const PerformanceMap& map); + // The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread + // (the editor toggle) and read off-thread by getState/reloadFromBank; guarded by + // channelModeMutex_. NEVER read on the audio thread — process() renders against the host's + // negotiated output channel count, and reloadFromBank bakes the mode into the decode. + ChannelMode channelMode(); + // Sets the mode. When it CHANGES, updates the output bus arrangement (mono->kMono / + // stereo->kStereo) and asks the host to re-negotiate I/O via restartComponent(kIoChanged), + // then reloads the instrument so the next block decodes the new channel count. A no-op set + // (same mode) does neither. UI thread only. + void setChannelMode(ChannelMode mode); + private: + // Apply `mode` to the output audio bus's SpeakerArrangement (kMono / kStereo). Called from + // initialize (topology) and setChannelMode (runtime change). Does NOT re-negotiate — the + // caller drives restartComponent when appropriate. + void applyOutputArrangement(ChannelMode mode); + ReaperBridge bridge_; // --- The audio-thread handoff (S4 real-time discipline) ----------------- @@ -178,6 +204,12 @@ private: std::mutex performanceMutex_; PerformanceMap performanceMap_; + // The per-instance channel mode (S7). Off-thread only (UI + getState + reloadFromBank); + // guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read + // on the audio thread — process renders against the host's negotiated output channel count. + std::mutex channelModeMutex_; + ChannelMode channelMode_ = ChannelMode::Mono; + // Latched from setupProcessing so setActive/reload can size against it. Read // off-thread only. double sampleRate_ = 44100.0; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index cb6d8b9..e54e030 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -101,10 +101,48 @@ std::vector downmixToMono(const std::vector& interleav return out; } -Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, - int rootNote, const SampleLoop& loop) { +std::vector extractChannel(const std::vector& interleaved, + int channelCount, int which) { + std::vector out; + if (channelCount <= 0 || interleaved.empty()) return out; + const std::size_t stride = static_cast(channelCount); + // Clamp the requested channel into the source's range: a channel past the last one reads + // the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono). + std::size_t ch = which < 0 ? 0 : static_cast(which); + if (ch >= stride) ch = stride - 1; + const std::size_t frames = interleaved.size() / stride; + out.resize(frames); + for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch]; + return out; +} + +DecodedZonePcm decodeChannels(const std::vector& interleaved, + int sourceChannels, ChannelMode mode, int sampleRate) { + DecodedZonePcm out; + out.sampleRate = sampleRate > 0 ? sampleRate : 44100; + if (mode == ChannelMode::Mono) { + // MONO mode: the existing downmix policy (average all source channels), one channel out. + out.monoFrames = downmixToMono(interleaved, sourceChannels); + return out; // framesR stays empty + } + // STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0 + // duplicated when the source is mono (dual-mono, centered). extractChannel clamps the + // out-of-range channel request to the last channel, so a mono source yields L == R. + out.monoFrames = extractChannel(interleaved, sourceChannels, 0); + out.framesR = extractChannel(interleaved, sourceChannels, 1); + return out; +} + +Keymap buildTier0Keymap(std::vector frames, int sampleRate, + int rootNote, const SampleLoop& loop, + std::vector framesR) { SampleData data; - data.frames = std::move(monoFrames); + data.frames = std::move(frames); + // A second channel only counts when it length-matches channel 0 (else the sample stays + // mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays). + if (!framesR.empty() && framesR.size() == data.frames.size()) { + data.framesR = std::move(framesR); + } data.sampleRate = sampleRate > 0 ? sampleRate : 44100; data.rootNote = rootNote; data.loop = loop; @@ -158,6 +196,12 @@ Keymap buildZonedKeymap(const std::vector& zones, if (decoded[i].monoFrames.empty()) continue; SampleData data; data.frames = decoded[i].monoFrames; + // Carry the second channel only when it length-matches channel 0 (channelCount() + // enforces the same rule; a mismatched pair falls back to mono rather than half-play). + if (!decoded[i].framesR.empty() && + decoded[i].framesR.size() == data.frames.size()) { + data.framesR = decoded[i].framesR; + } data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100; data.rootNote = zones[i].rootNote; data.loop = zones[i].loop; @@ -292,6 +336,8 @@ PerformanceMap deserializePerformance(const std::vector& bytes) { std::vector serializeComponentState(const ComponentState& state) { std::vector out; putU32le(out, kComponentStateVersion); + // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. + out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0); // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — // unlike the v1 selection blob where the id ran to end-of-stream). putU32le(out, static_cast(state.selectionId.size())); @@ -324,10 +370,24 @@ ComponentState deserializeComponentState(const std::vector& bytes) } if (version == kPerformanceStateVersion) { readZonesPayload(r, out.map); // v2 body starts right after the version tag - return out; + return out; // channelMode stays Mono (pre-S7) + } + // BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO — + // the id length + id + zones body starts right after the version tag (no mode byte). + if (version == kSelectionZonesV3Version) { + const std::uint32_t idLen = r.u32(); + out.selectionId = r.str(idLen); + if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty + readZonesPayload(r, out.map); + return out; // channelMode stays Mono (pre-S7) } if (version != kComponentStateVersion) return out; // unknown -> empty + // v4: the channel-mode byte precedes the v3 body. A non-{0,1} byte is treated as mono + // (conservative default) rather than rejected — a corrupt mode never silences the instance. + const std::uint8_t modeByte = r.u8(); + if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) + out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index ad2b696..e280d3c 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -100,12 +100,23 @@ std::vector listBanks(const std::string& banksJson); std::vector downmixToMono(const std::vector& interleaved, int channelCount); -// Build the Tier-0 chromatic keymap for one decoded, mono sample: one zone spanning -// the whole keyboard, repitched from `rootNote`, looped per `loop`. This is the -// single-sample degenerate case (Keymap::singleSampleChromatic) with the S2 intrinsics -// threaded in. `monoFrames` is the downmixed PCM; `sampleRate` is the WAV's rate. -Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, - int rootNote, const SampleLoop& loop); +// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is +// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the +// source's last channel reads the last channel, so a mono source asked for channel 1 yields +// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure. +std::vector extractChannel(const std::vector& interleaved, + int channelCount, int which); + +// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole +// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case +// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0 +// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default), +// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length +// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad +// pair never half-plays. `sampleRate` is the WAV's rate. +Keymap buildTier0Keymap(std::vector frames, int sampleRate, + int rootNote, const SampleLoop& loop, + std::vector framesR = {}); // --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // @@ -178,12 +189,27 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the // map). Empty zones in -> empty Keymap (silence). struct DecodedZonePcm { - std::vector monoFrames; + std::vector monoFrames; // channel 0 (mono, or L of a stereo decode) int sampleRate = 44100; + std::vector framesR; // channel 1 (R); EMPTY for a mono decode }; Keymap buildZonedKeymap(const std::vector& zones, const std::vector& decoded); +// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding +// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's +// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode. +// * MONO mode -> downmix to one channel (the existing policy: average all source +// channels). framesR EMPTY. A mono or stereo source both collapse. +// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered). +// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels +// takes channels 0 and 1 (documented; the sampler's stereo image is +// the first two channels — no surround fold). +// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone +// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here. +DecodedZonePcm decodeChannels(const std::vector& interleaved, + int sourceChannels, ChannelMode mode, int sampleRate); + // --- Performance-map instance state (VST3 setState/getState) ----------------- // // The performance map is the instrument's OWN state (D-B), serialized to the VST3 @@ -222,20 +248,29 @@ PerformanceMap deserializePerformance(const std::vector& bytes); // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // state), never auto-playing sample #1. // -// Format (v3): 4-byte LE version tag (== 3), then a 4-byte LE selection-id length + -// id bytes, then the v2 zones payload (4-byte LE zone count + per-zone records, identical -// to serializePerformance's body). BACK-COMPAT on read: -// * v3 blob -> {selectionId, zones} parsed directly. -// * v2 blob -> {"", zones}: an S5 instance had zones but no separate selection. -// * v1 blob -> {id, one full-keyboard zone}: the S4 single-selection lift, so the -// old pick survives as both the selection AND a one-zone map. -// * empty/unknown -> {"", no zones}: EMPTY (the S10 silent empty state). +// Format (v4): 4-byte LE version tag (== 4), then a 1-byte channel-mode field (0 = mono, +// 1 = stereo), then a 4-byte LE selection-id length + id bytes, then the v2 zones payload +// (4-byte LE zone count + per-zone records, identical to serializePerformance's body). The +// channel-mode field is the ONLY v4 addition over v3 — the envelope grew a field, the zones +// payload is untouched (a PARALLEL track owns zone-record extension under the map's own +// versioning). BACK-COMPAT on read (every older blob lifts to channelMode = MONO, preserving +// current behavior for already-saved instances): +// * v4 blob -> {channelMode, selectionId, zones} parsed directly. +// * v3 blob -> {mono, selectionId, zones}: pre-S7 had no channel mode. +// * v2 blob -> {mono, "", zones}: an S5 instance had zones but no separate selection. +// * v1 blob -> {mono, id, one full-keyboard zone}: the S4 single-selection lift. +// * empty/unknown -> {mono, "", no zones}: EMPTY (the S10 silent empty state). struct ComponentState { - std::string selectionId; // the single-capture pick; "" = no pick (empty state) - PerformanceMap map; // the opt-in zones; empty = no zones + std::string selectionId; // the single-capture pick; "" = no pick + PerformanceMap map; // the opt-in zones; empty = no zones + ChannelMode channelMode = ChannelMode::Mono; // S7 output mode; default mono (D-E) }; -inline constexpr std::uint32_t kComponentStateVersion = 3; +inline constexpr std::uint32_t kComponentStateVersion = 4; + +// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named +// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. +inline constexpr std::uint32_t kSelectionZonesV3Version = 3; // The full instance state serialized to bytes for IBStream (getState). std::vector serializeComponentState(const ComponentState& state); diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 57a7748..f4bdbec 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -163,11 +163,23 @@ void Voice::release() { env_.noteOff(); } -AudioSample Voice::renderFrame() { - if (!active_ || sample_ == nullptr) return 0.0f; +AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { + // Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap, + // bracketing indices, interpolation partner) is computed ONCE and applied identically to + // every channel — only the PCM value read differs. The envelope ticks ONCE per frame and + // scales all channels equally (a voice is one envelope). The head advances by exactly one + // ratio step per call, so mono and stereo consume the sample at the same rate. + if (!active_ || sample_ == nullptr) { + if (stereo) outR = 0.0f; + return 0.0f; + } const std::vector& pcm = sample_->frames; const std::int64_t frameCount = static_cast(pcm.size()); + // Read the second channel only for a genuinely stereo sample; a mono sample plays + // dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case. + const bool haveR = stereo && sample_->channelCount() == 2; + const std::vector& pcmR = haveR ? sample_->framesR : pcm; // Loop-aware sustain: if a valid, non-zero-length loop exists and the read head // has advanced past the loop end, wrap it back into [start, end). A zero-length @@ -188,6 +200,7 @@ AudioSample Voice::renderFrame() { // Ran off the end with no usable loop -> voice is done. if (readPos_ >= static_cast(frameCount)) { active_ = false; + if (stereo) outR = 0.0f; return 0.0f; } @@ -201,20 +214,40 @@ AudioSample Voice::renderFrame() { } // i0 is always in [0, frameCount) after the early-out above; the guard is purely // defensive. i1 (the interpolation partner) can exceed frameCount when no loop - // wraps it — only that partner actually needs the clamp. - const double s0 = (i0 >= 0 && i0 < frameCount) ? static_cast(pcm[i0]) : 0.0; - const double s1 = (i1 >= 0 && i1 < frameCount) ? static_cast(pcm[i1]) : 0.0; - const double interp = s0 + (s1 - s0) * frac; + // wraps it — only that partner actually needs the clamp. framesR is length-matched + // to frames (channelCount() enforces it), so the same indices are valid in both. + const bool i0ok = (i0 >= 0 && i0 < frameCount); + const bool i1ok = (i1 >= 0 && i1 < frameCount); const double amp = env_.tick(); - const double out = interp * amp * velocityGain_; + const double gain = amp * velocityGain_; + + const double l0 = i0ok ? static_cast(pcm[i0]) : 0.0; + const double l1 = i1ok ? static_cast(pcm[i1]) : 0.0; + const double outL = (l0 + (l1 - l0) * frac) * gain; + + if (stereo) { + const double r0 = i0ok ? static_cast(pcmR[i0]) : 0.0; + const double r1 = i1ok ? static_cast(pcmR[i1]) : 0.0; + outR = static_cast((r0 + (r1 - r0) * frac) * gain); + } readPos_ += ratio_; if (env_.finished()) { active_ = false; } - return static_cast(out); + return static_cast(outL); +} + +AudioSample Voice::renderFrame() { + AudioSample discard = 0.0f; + return advanceFrame(/*stereo=*/false, discard); +} + +void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) { + r = 0.0f; + l = advanceFrame(/*stereo=*/true, r); } // --------------------------------------------------------------------------- @@ -303,6 +336,24 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) { } } +void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) { + // Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel + // contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice + // iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle + // discipline; only the per-frame call differs (renderFrameStereo vs renderFrame). + if (left == nullptr || right == nullptr || frameCount == 0) return; + for (Voice& voice : voices_) { + if (!voice.active()) continue; + for (std::size_t f = 0; f < frameCount; ++f) { + if (!voice.active()) break; + AudioSample l = 0.0f, r = 0.0f; + voice.renderFrameStereo(l, r); + left[f] += l; + right[f] += r; + } + } +} + void VoiceEngine::render(std::vector& out, std::size_t frameCount) { // Off-thread / test path: grow the buffer (this allocates — never call under // process), zero-fill the appended span, then delegate to the RT mix loop so both diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 5efa837..3d8b219 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -25,6 +25,14 @@ namespace reasampler { +// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 +// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders +// per-channel. A PERFORMANCE choice the instrument owns (component state), never written +// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain +// value so the shell (bus negotiation, state) and the engine share one spelling; the core +// itself never branches on it — the mode only picks which render overload the shell drives. +enum class ChannelMode { Mono, Stereo }; + // --------------------------------------------------------------------------- // Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that // govern playback. The shell decodes the on-disk WAV and fills this; the core @@ -40,16 +48,26 @@ struct SampleLoop { std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end }; -// One decoded audio sample the engine can voice. `frames` is DEINTERLEAVED-agnostic: -// the core plays a single mono stream per sample (Tier 0-1 scope), so `frames` is one -// channel's PCM at `sampleRate`. `rootNote` is the MIDI note the file was recorded at -// (S2 intrinsic) — the pitch that plays back at unity ratio. +// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is +// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). +// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise +// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both +// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical +// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was +// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. struct SampleData { - std::vector frames; // mono PCM, one value per frame - int sampleRate = 44100; // frames per second (for reference; ratio is - // note-relative, so rate cancels for repitch) - int rootNote = 60; // MIDI note recorded at (plays at unity here) - SampleLoop loop; // sustain loop, if any + std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) + std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample + int sampleRate = 44100; // frames per second (for reference; ratio is + // note-relative, so rate cancels for repitch) + int rootNote = 60; // MIDI note recorded at (plays at unity here) + SampleLoop loop; // sustain loop, if any + + // 2 iff a matching-length second channel exists; else 1. A framesR of a different + // length than frames is treated as absent (mono) — a malformed pair never half-plays. + int channelCount() const { + return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; + } }; // --------------------------------------------------------------------------- @@ -190,10 +208,25 @@ public: // Renders one frame's contribution, advancing the read head and envelope by one // output frame. Returns 0.0 (and goes idle) once the envelope finishes or the // sample runs out with no loop. The value is already velocity- and - // envelope-scaled — the engine sums voices directly. + // envelope-scaled — the engine sums voices directly. This is the MONO path (channel + // 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged. AudioSample renderFrame(); + // STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances + // the read head + envelope by exactly one frame (the same single advance the mono path + // performs — the envelope ticks ONCE per frame, shared across both channels). For a mono + // sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered). + // Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions + // as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both. + void renderFrameStereo(AudioSample& l, AudioSample& r); + private: + // Shared read/advance for both render paths: computes the interpolated per-channel + // value(s) at the current read head, ticks the envelope once, advances the head, and + // latches idle on exhaustion. `stereo` selects whether the second channel is read (and + // returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value. + AudioSample advanceFrame(bool stereo, AudioSample& outR); + bool active_ = false; bool releasing_ = false; int note_ = 0; @@ -245,6 +278,15 @@ public: // at least `frameCount` writable samples; a null `out` or zero count is a no-op. void render(AudioSample* out, std::size_t frameCount); + // REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two + // buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there + // (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no + // resize, no lock. A mono sample plays dual-mono (same value to both channels, centered); + // a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono + // and stereo render paths are independent output shapes over the SAME voice pool; the active + // channel mode (mono vs stereo bus) picks which one the process callback drives per block. + void render(AudioSample* left, AudioSample* right, 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. diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index da645ef..21aa607 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -678,6 +678,149 @@ static void testComponentStateGarbage() { CHECK(deserializeComponentState(t).map.zones.empty()); } +// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ---------- + +static void testExtractChannelStereo() { + // Interleaved stereo [L0,R0,L1,R1,...]; extract channel 0 -> L's, channel 1 -> R's. + const std::vector in{0.1f, 0.9f, 0.2f, 0.8f, 0.3f, 0.7f}; + const std::vector l = extractChannel(in, 2, 0); + const std::vector r = extractChannel(in, 2, 1); + CHECK(l.size() == 3 && approx(l[0], 0.1) && approx(l[1], 0.2) && approx(l[2], 0.3)); + CHECK(r.size() == 3 && approx(r[0], 0.9) && approx(r[1], 0.8) && approx(r[2], 0.7)); +} + +static void testExtractChannelClampsToLast() { + // A mono source asked for channel 1 yields channel 0 (clamp to last) — the dual-mono block. + const std::vector mono{0.1f, 0.2f, 0.3f}; + const std::vector ch1 = extractChannel(mono, 1, 1); + CHECK(ch1.size() == 3 && approx(ch1[0], 0.1) && approx(ch1[2], 0.3)); // == channel 0 + CHECK(extractChannel({}, 2, 0).empty()); // empty in + CHECK(extractChannel({0.1f}, 0, 0).empty()); // zero stride +} + +static void testDecodeChannelsMonoModeDownmixes() { + // MONO mode: a stereo source averages to one channel (the existing policy), framesR empty. + const std::vector stereo{1.0f, 0.0f, 0.4f, 0.6f}; // frames (1,0) and (0.4,0.6) + const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Mono, 48000); + CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.5) && approx(d.monoFrames[1], 0.5)); + CHECK(d.framesR.empty()); // mono mode -> single channel + CHECK(d.sampleRate == 48000); +} + +static void testDecodeChannelsStereoModeStereoSource() { + // STEREO mode + stereo source: channels taken as-is (L/R), both present + distinct. + const std::vector stereo{0.1f, 0.9f, 0.2f, 0.8f}; + const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Stereo, 44100); + CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.1) && approx(d.monoFrames[1], 0.2)); + CHECK(d.framesR.size() == 2 && approx(d.framesR[0], 0.9) && approx(d.framesR[1], 0.8)); +} + +static void testDecodeChannelsStereoModeMonoSourceDualMono() { + // STEREO mode + mono source: dual-mono — framesR duplicates channel 0 (centered, not silent). + const std::vector mono{0.3f, 0.6f, 0.9f}; + const DecodedZonePcm d = decodeChannels(mono, 1, ChannelMode::Stereo, 44100); + CHECK(d.monoFrames.size() == 3); + CHECK(d.framesR.size() == 3); + for (std::size_t i = 0; i < 3; ++i) CHECK(approx(d.monoFrames[i], d.framesR[i])); // R == L +} + +// --- S7: buildTier0Keymap stereo threading ------------------------------------ + +static void testBuildKeymapStereoCarriesSecondChannel() { + const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}, {0.9f, 0.8f}); + CHECK(km.samples.size() == 1); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2); + CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 && + approx(km.samples[0].framesR[0], 0.9) && approx(km.samples[0].framesR[1], 0.8)); +} + +static void testBuildKeymapMonoWhenNoSecondChannel() { + // No framesR passed -> mono SampleData (byte-identical to the pre-S7 build). + const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1); + CHECK(km.samples.size() == 1 && km.samples[0].framesR.empty()); +} + +static void testBuildKeymapDropsMismatchedSecondChannel() { + // A framesR whose length mismatches frames is dropped -> mono (a bad pair never half-plays). + const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 60, SampleLoop{}, {0.9f}); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1); +} + +static void testBuildZonedKeymapCarriesSecondChannel() { + // The zoned build threads each zone's framesR when it length-matches channel 0. + std::vector zones; + ResolvedZone z; z.lowNote = 0; z.highNote = 127; z.rootNote = 60; zones.push_back(z); + std::vector decoded; + DecodedZonePcm d; d.monoFrames = {0.1f, 0.2f}; d.sampleRate = 44100; d.framesR = {0.9f, 0.8f}; + decoded.push_back(d); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2); + CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 && + approx(km.samples[0].framesR[1], 0.8)); +} + +// --- S7: component state v4 (channel mode) ------------------------------------ + +static void testComponentStateV4RoundTripStereo() { + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "pick"); + CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); +} + +static void testComponentStateV4RoundTripMono() { + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Mono; + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "pick"); + CHECK(back.channelMode == ChannelMode::Mono); +} + +static void testComponentStateV4DefaultIsMono() { + // A default-constructed state serializes with mono and restores mono (preserves behavior). + const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{})); + CHECK(back.channelMode == ChannelMode::Mono); + CHECK(back.selectionId.empty() && back.map.zones.empty()); +} + +static void testComponentStateV3LiftsToMono() { + // A pre-S7 v3 blob (selection + zones, no mode byte) lifts to channelMode = mono, with the + // selection and zones intact. Build a v3 blob by hand: tag 3, id length + id, zones payload. + std::vector v3; + v3.push_back(3); v3.push_back(0); v3.push_back(0); v3.push_back(0); // version 3 + const std::string id = "legacy"; + v3.push_back(static_cast(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0); + v3.insert(v3.end(), id.begin(), id.end()); + v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0 + const ComponentState back = deserializeComponentState(v3); + CHECK(back.selectionId == "legacy"); + CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default + CHECK(back.map.zones.empty()); +} + +static void testComponentStateV1V2LiftToMono() { + // The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read. + const ComponentState v1 = deserializeComponentState(serializeSelection("old")); + CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old"); + PerformanceMap m; m.zones.push_back(zone("s", 12, 24)); + const ComponentState v2 = deserializeComponentState(serializePerformance(m)); + CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1); +} + +static void testComponentStateV4TruncatedModeByte() { + // A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds. + std::vector t{4, 0, 0, 0}; // version 4, nothing after + const ComponentState back = deserializeComponentState(t); + CHECK(back.channelMode == ChannelMode::Mono); + CHECK(back.selectionId.empty() && back.map.zones.empty()); +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -726,6 +869,21 @@ int main() { testComponentStateV1BackCompat(); testComponentStateV2BackCompat(); testComponentStateGarbage(); + testExtractChannelStereo(); + testExtractChannelClampsToLast(); + testDecodeChannelsMonoModeDownmixes(); + testDecodeChannelsStereoModeStereoSource(); + testDecodeChannelsStereoModeMonoSourceDualMono(); + testBuildKeymapStereoCarriesSecondChannel(); + testBuildKeymapMonoWhenNoSecondChannel(); + testBuildKeymapDropsMismatchedSecondChannel(); + testBuildZonedKeymapCarriesSecondChannel(); + testComponentStateV4RoundTripStereo(); + testComponentStateV4RoundTripMono(); + testComponentStateV4DefaultIsMono(); + testComponentStateV3LiftsToMono(); + testComponentStateV1V2LiftToMono(); + testComponentStateV4TruncatedModeByte(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 4a3a5a7..89ac901 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -562,6 +562,118 @@ static void testPolyphonyMixesAdditively() { CHECK(approx(out[0], 2.0, 1e-4)); // both voices sum } +// --------------------------------------------------------------------------- +// 7. Stereo channel dimension (S7). +// --------------------------------------------------------------------------- + +// A distinct-per-channel stereo DC sample: L = `l`, R = `r` everywhere. A stereo render +// must keep them distinct; a mono render (channel 0 only) sees L. +static SampleData stereoDcSample(std::size_t frames, float l, float r, int rootNote = 60) { + SampleData s; + s.frames.assign(frames, l); + s.framesR.assign(frames, r); + s.rootNote = rootNote; + return s; +} + +static void testChannelCount() { + // Mono: framesR empty -> 1 channel. Stereo: matching-length framesR -> 2. + CHECK(dcSample(10, 60).channelCount() == 1); + CHECK(stereoDcSample(10, 1.0f, -1.0f).channelCount() == 2); + // A mismatched framesR length is treated as mono (a bad pair never half-plays). + SampleData bad = dcSample(10, 60); + bad.framesR.assign(5, 0.5f); // wrong length + CHECK(bad.channelCount() == 1); +} + +static void testStereoRenderKeepsChannelsDistinct() { + // A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each + // scaled by velocity (full here). If the engine copied L to both channels the R check fails. + Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + + std::vector left(8, 0.f), right(8, 0.f); + eng.render(left.data(), right.data(), 8); + for (std::size_t i = 0; i < 8; ++i) { + CHECK(approx(left[i], 1.0, 1e-4)); // channel 0 + CHECK(approx(right[i], -1.0, 1e-4)); // channel 1 — distinct, NOT a copy of L + } +} + +static void testMonoSamplePlaysDualMonoInStereo() { + // A MONO sample rendered through the stereo path plays dual-mono: both channels equal + // (centered), not silent on the right. The cross-mode "mono source in stereo mode" case. + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0 + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector left(8, 0.f), right(8, 0.f); + eng.render(left.data(), right.data(), 8); + for (std::size_t i = 0; i < 8; ++i) { + CHECK(approx(left[i], 1.0, 1e-4)); + CHECK(approx(right[i], 1.0, 1e-4)); // R == L (dual-mono), not 0 + } +} + +static void testMonoRenderUnchangedByStereoData() { + // Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical + // whether or not a second channel is present. A stereo sample rendered mono == its L channel. + Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60)); + VoiceEngine engS(1, kmS, flatAdsr()); + engS.noteOn(60, 127); + std::vector mono; + engS.render(mono, 8); // the mono overload + for (std::size_t i = 0; i < 8; ++i) CHECK(approx(mono[i], 0.75, 1e-4)); // == L, ignores R +} + +static void testStereoRenderAdvancesLikeMonoRepitch() { + // The stereo path must advance the read head by the SAME per-frame ratio as the mono path, + // so repitch is identical. Play a stereo sine (both channels the same signal) an octave up + // and confirm the observed period halves — the mono repitch assertion, on the stereo path. + const std::size_t frames = 8000; + const double cycles = 20.0; + const double nativePeriod = static_cast(frames) / cycles; // 400 + SampleData s; + s.frames.resize(frames); + s.framesR.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + const float v = static_cast(std::sin(2.0 * kPi * cycles * + static_cast(i) / static_cast(frames))); + s.frames[i] = v; + s.framesR[i] = v; + } + s.rootNote = 60; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(72, 127); // +1 octave + std::vector left(frames / 2, 0.f), right(frames / 2, 0.f); + eng.render(left.data(), right.data(), frames / 2); + CHECK(approx(observedPeriodFrames(left), nativePeriod / 2.0, 2.0)); + CHECK(approx(observedPeriodFrames(right), nativePeriod / 2.0, 2.0)); // R repitches identically +} + +static void testStereoRenderSumsVoicesPerChannel() { + // Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo). + Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(60, 127); + eng.noteOn(60, 127); // second voice, same note + std::vector left(1, 0.f), right(1, 0.f); + eng.render(left.data(), right.data(), 1); + CHECK(approx(left[0], 1.0, 1e-4)); // 0.5 + 0.5 + CHECK(approx(right[0], -1.0, 1e-4)); // -0.5 + -0.5 +} + +static void testStereoRenderNullBufferIsNoOp() { + Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector buf(4, 0.f); + eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash + eng.render(buf.data(), nullptr, 4); // null right -> no-op + for (float v : buf) CHECK(approx(v, 0.0, 1e-9)); // untouched +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -582,6 +694,13 @@ int main() { testAbsentLoopGoesSilent(); testVelocityToVolume(); testPolyphonyMixesAdditively(); + testChannelCount(); + testStereoRenderKeepsChannelsDistinct(); + testMonoSamplePlaysDualMonoInStereo(); + testMonoRenderUnchangedByStereoData(); + testStereoRenderAdvancesLikeMonoRepitch(); + testStereoRenderSumsVoicesPerChannel(); + testStereoRenderNullBufferIsNoOp(); if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); From 38238c66ace2812b1bc6b3c6f99e32f6cb4c3ff7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 21:56:06 -0400 Subject: [PATCH 27/49] fix S11 review: extract upsertPickedOverride, restore map_ on drag-cancel, clamp start to frames-1, pin startFrame>loopEnd wrap test --- src/vst/reasampler_editor.cpp | 57 +++++++++++++++-------------------- src/vst/reasampler_editor.h | 6 ++++ tests/test_sampler_core.cpp | 38 +++++++++++++++++++++++ 3 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 18f311f..18a0f30 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -220,13 +220,10 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram return m; } -void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { - // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, - // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays - // identically to the un-zoned single capture (one chromatic zone) and round-trips through - // the component state; the zone becomes visible if the user opens the Zones panel. The bank - // intrinsic is NEVER written (read-only bank consumer, D-B). - if (selectedId_.empty()) return; +void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { + // Find-or-append the zone for selectedId_ and write the loop/start override fields. + // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must + // be non-empty; callers are responsible for that guard. SampleLoop loop; loop.hasLoop = m.hasLoop; loop.start = m.loopStart; @@ -249,6 +246,16 @@ void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { z.startPoint = m.start; map_.zones.push_back(z); } +} + +void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { + // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, + // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays + // identically to the un-zoned single capture (one chromatic zone) and round-trips through + // the component state; the zone becomes visible if the user opens the Zones panel. The bank + // intrinsic is NEVER written (read-only bank consumer, D-B). + if (selectedId_.empty()) return; + upsertPickedOverride(m); commitAndReload(); } @@ -758,6 +765,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { dragStartX_ = x; dragStartMarkers_ = m; dragSampleFrames_ = frames; + dragStartMap_ = map_; return; // no immediate set — the marker only moves once the cursor drags } } @@ -770,6 +778,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { drag_ = DragKind::kRootMarker; dragStartX_ = x; dragStartRoot_ = note; + dragStartMap_ = map_; // A click sets the root immediately (drag then refines); the override lives on // a one-zone map entry for the picked capture (D-B, never written to the bank). onMouseMove(x, y); // apply the click position as the first delta==0 set @@ -836,6 +845,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { dragStartX_ = x; dragStartLow_ = z.lowNote; dragStartHigh_ = z.highNote; + dragStartMap_ = map_; switch (hit.grab) { case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break; case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break; @@ -918,7 +928,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { } // Build the edited marker set from the snapshot, moving only the grabbed marker, then - // clamp: loopStart <= loopEnd, start in [0, frames]. Dragging a loop marker MAKES a loop. + // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop. SetupMarkers m = dragStartMarkers_; if (waveMarker_ == WaveMarker::kStart) { m.start = newFrame; @@ -930,32 +940,11 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { m.hasLoop = true; } if (m.start < 0) m.start = 0; - if (m.start > frames) m.start = frames; + if (m.start > frames - 1) m.start = frames - 1; // Upsert the override on the picked id (mirror of the root-marker path); commit lands on // release, this is live feedback. - SampleLoop loop; - loop.hasLoop = m.hasLoop; - loop.start = m.loopStart; - loop.end = m.loopEnd; - bool found = false; - for (PerformanceZone& z : map_.zones) { - if (z.sampleId == selectedId_) { - z.loopOverride = loop; - z.startPoint = m.start; - found = true; - break; - } - } - if (!found) { - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - z.loopOverride = loop; - z.startPoint = m.start; - map_.zones.push_back(z); - } + upsertPickedOverride(m); invalidate(); return; } @@ -1017,10 +1006,12 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, } return 0; case WM_CAPTURECHANGED: - // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — reset the drag - // state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. + // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its + // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset + // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. if (self && self->drag_ != DragKind::kNone) { + self->map_ = self->dragStartMap_; self->drag_ = DragKind::kNone; self->invalidate(); } diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index a34f704..1aada1f 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -135,6 +135,11 @@ private: // (upsert on the picked id — mirror of the root-marker path), then reload off-thread. void commitPickedMarkers(const SetupMarkers& m); + // Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append). + // Does NOT call commitAndReload — callers decide whether this is a live-drag update or a + // final commit. selectedId_ must be non-empty before calling. + void upsertPickedOverride(const SetupMarkers& m); + ReaSamplerProcessor* processor_ = nullptr; // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- @@ -155,6 +160,7 @@ private: int dragStartLow_ = 0; // the grabbed field's note at grab time int dragStartHigh_ = 0; int dragStartRoot_ = 60; + PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss // S11 waveform-marker drag: which marker + the marker set snapshotted at grab time (so the // pixel-delta resolver shifts the grabbed frame from its grab-time value, and inter-marker diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 700e9a0..4c1e949 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -592,6 +592,43 @@ static void testStartFrameWithLoop() { for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.5, 1e-4)); } +static void testStartAfterLoopEndWrapsIntoLoop() { + // Regression (S11 reviewer finding): if startFrame > loop.end (but still < frameCount), + // the voice's initial read head is past the loop end. The wrap-while in renderFrame must + // pull it back into [loopStart, loopEnd) on the very first frame, so the note sounds from + // somewhere inside the loop rather than running off the sample end silently. + // + // Setup: 100-frame sample; loop is [20, 40); startFrame = 60 (past loop.end = 40). + // loop body is a constant 0.5 so every frame inside it reads 0.5. + // After wrap: readPos starts inside [20, 40), first output frame == 0.5. + // Voice must stay active (loop sustains it) and emit the loop value, NOT go silent. + SampleData s; + s.frames.resize(100, 0.0f); + for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body + s.rootNote = 60; + s.startFrame = 60; // > loop.end (40), < frameCount (100) + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 40; + + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio, full velocity + + std::vector out; + eng.render(out, 50); + + // Voice must still be active — the usable loop keeps it alive indefinitely. + CHECK(eng.activeVoiceCount() == 1); + // Every frame after the first wrap must read 0.5 (the loop body). We skip the very + // first frame because the fractional-position wrap lands somewhere in [20,40) and the + // exact offset depends on how many loop lengths fit into 60; what matters is that the + // voice is alive and emitting the loop value, not 0.0 (pre-loop region). + for (std::size_t i = 5; i < out.size(); ++i) { + CHECK(approx(out[i], 0.5, 1e-4)); + } +} + // --------------------------------------------------------------------------- // velocity -> volume. // --------------------------------------------------------------------------- @@ -656,6 +693,7 @@ int main() { testStartFrameZeroIsUnchanged(); testStartFrameOutOfRangeClampsToZero(); testStartFrameWithLoop(); + testStartAfterLoopEndWrapsIntoLoop(); testVelocityToVolume(); testPolyphonyMixesAdditively(); From 8074e21057bb06b987ab38830d04ce0a7b5fe2c2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 21:29:13 -0400 Subject: [PATCH 28/49] =?UTF-8?q?feat(S8):=20ingest=20through=20the=20bank?= =?UTF-8?q?=20=E2=80=94=20capture/import/drop=20into=20bank=20+=20assign?= =?UTF-8?q?=20to=20active=20instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 20 +- src/assignment_request.cpp | 120 ++++++++++ src/assignment_request.h | 80 +++++++ src/bank_panel.cpp | 40 ++++ src/ext_keys.h | 11 + src/ingest.cpp | 384 ++++++++++++++++++++++++++++++ src/ingest.h | 77 ++++++ src/main.cpp | 106 ++++++++- src/persist.cpp | 17 ++ src/persist.h | 15 ++ tests/test_assignment_request.cpp | 145 +++++++++++ 11 files changed, 1009 insertions(+), 6 deletions(-) create mode 100644 src/assignment_request.cpp create mode 100644 src/assignment_request.h create mode 100644 src/ingest.cpp create mode 100644 src/ingest.h create mode 100644 tests/test_assignment_request.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0cd069d..40471e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -301,6 +301,19 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge add_library(provenance STATIC src/provenance.cpp) target_include_directories(provenance PUBLIC src) +# --------------------------------------------------------------------------- +# 2j') Pure assignment_request library — NO REAPER, NO SWELL, NO VST3. The S8 ingest +# assignment-request wire format: the (bankId, sampleId, generation) value the +# EXTENSION writes to "reasampler" ext-state after an ingest-with-assign, decoded by +# the VST3 instrument in a later dispatch. Only the wire (build/parse round-trip) +# lives here — writing it is the persist shell's job, reading it the instrument's. +# Split out (mirror of provenance / owned_manifest) so the format both artifacts +# depend on is unit-tested outside the DAW; the reader lands in a separate artifact, +# so the round-trip test is the contract guard. No dependency — plain strings + int64. +# --------------------------------------------------------------------------- +add_library(assignment_request STATIC src/assignment_request.cpp) +target_include_directories(assignment_request PUBLIC src) + # --------------------------------------------------------------------------- # 2k) Pure action_buttons library — NO REAPER, NO SWELL. The Milestone 11 # action-trigger button strip: strip rect + N buttons at a minimum width -> @@ -600,6 +613,10 @@ add_executable(card_drag_tests tests/test_card_drag.cpp) target_link_libraries(card_drag_tests PRIVATE card_drag) add_test(NAME card_drag_tests COMMAND card_drag_tests) +add_executable(assignment_request_tests tests/test_assignment_request.cpp) +target_link_libraries(assignment_request_tests PRIVATE assignment_request) +add_test(NAME assignment_request_tests COMMAND assignment_request_tests) + # sampler_core: the S3 heart. Links ONLY sampler_core (+ its peaks dep) — NEITHER the # VST3 SDK nor the REAPER SDK — which is the structural proof of the plain-data # boundary (a VST3/REAPER type in the core would fail to compile/link here). @@ -701,6 +718,7 @@ add_library(reaper_reasampler MODULE src/lane_keys.cpp src/item_read.cpp src/actions.cpp + src/ingest.cpp src/bank_book.cpp src/owned_manifest.cpp src/drag_out_win.cpp @@ -712,7 +730,7 @@ add_library(reaper_reasampler MODULE src/card_meta.cpp src/card_drag.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/src/assignment_request.cpp b/src/assignment_request.cpp new file mode 100644 index 0000000..a880e0c --- /dev/null +++ b/src/assignment_request.cpp @@ -0,0 +1,120 @@ +// assignment_request.cpp — see assignment_request.h. Pure: standard library only. + +#include "assignment_request.h" + +#include + +namespace reasampler { + +namespace { + +constexpr const char* kMagic = "rsassign1"; + +// Append one length-prefixed field: ':' . Mirror of +// provenance's putField so the two seams share one wire idiom. +void putField(std::string& out, const std::string& field) { + out += std::to_string(field.size()); + out += ':'; + out += field; +} + +// Cursor over the encoded string. All reads are bounds-checked; a short read fails +// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the +// three field kinds this record needs. +class Cursor { +public: + explicit Cursor(const std::string& s) : s_(s) {} + + bool ok() const { return ok_; } + bool atEnd() const { return pos_ >= s_.size(); } + + // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or + // non-numeric length, or a length that runs past the end. + bool field(std::string& out) { + if (!ok_) return false; + const std::size_t colon = s_.find(':', pos_); + if (colon == std::string::npos) return fail(); + if (colon == pos_) return fail(); // empty length token + std::size_t len = 0; + for (std::size_t i = pos_; i < colon; ++i) { + const char c = s_[i]; + if (c < '0' || c > '9') return fail(); + len = len * 10 + static_cast(c - '0'); + } + const std::size_t start = colon + 1; + if (start + len > s_.size()) return fail(); + out.assign(s_, start, len); + pos_ = start + len; + return true; + } + + // Reads a length-prefixed field and parses it as a signed 64-bit decimal (an + // optional leading '-'). Fails on empty, non-digit, or trailing bytes. + bool fieldInt64(std::int64_t& out) { + std::string f; + if (!field(f)) return false; + if (f.empty()) return fail(); + std::size_t i = 0; + bool neg = false; + if (f[0] == '-') { + neg = true; + i = 1; + if (f.size() == 1) return fail(); // bare "-" + } + std::int64_t v = 0; + for (; i < f.size(); ++i) { + const char c = f[i]; + if (c < '0' || c > '9') return fail(); + v = v * 10 + static_cast(c - '0'); + } + out = neg ? -v : v; + return true; + } + + // Consumes an exact literal at the cursor (the magic tag). Fails if absent. + bool literal(const char* lit) { + if (!ok_) return false; + std::size_t i = 0; + for (; lit[i] != '\0'; ++i) { + if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); + } + pos_ += i; + return true; + } + +private: + bool fail() { + ok_ = false; + return false; + } + + const std::string& s_; + std::size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace + +std::string encodeAssignmentRequest(const AssignmentRequest& req) { + std::string out = kMagic; + putField(out, req.bankId); + putField(out, req.sampleId); + putField(out, std::to_string(req.generation)); + return out; +} + +std::optional decodeAssignmentRequest(const std::string& wire) { + Cursor cur(wire); + if (!cur.literal(kMagic)) return std::nullopt; + + AssignmentRequest req; + if (!cur.field(req.bankId)) return std::nullopt; + if (!cur.field(req.sampleId)) return std::nullopt; + if (!cur.fieldInt64(req.generation)) return std::nullopt; + + // Reject trailing garbage: a well-formed value ends exactly at the last field. + if (!cur.ok() || !cur.atEnd()) return std::nullopt; + return req; +} + +} // namespace reasampler diff --git a/src/assignment_request.h b/src/assignment_request.h new file mode 100644 index 0000000..4992242 --- /dev/null +++ b/src/assignment_request.h @@ -0,0 +1,80 @@ +#pragma once +// assignment_request — the pure core of the S8 ingest assignment-request seam. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3, +// NO vendor/ includes. Standard library only. Unit-tested outside the DAW — the same +// "small pure type + length-prefixed round-trip" pattern as provenance / owned_manifest. +// +// -- What it is -------------------------------------------------------------- +// +// When the EXTENSION ingests a sample (S8: arrange capture / Media-Explorer import / +// drop-onto-panel) it writes an ASSIGNMENT REQUEST to its own "reasampler" ext-state +// namespace: "the active sampler instance should now play THIS sample." The value +// names the ingested sample by (bankId, sampleId) plus a monotonic `generation` the +// reader compares to decide the request is NEW (a fresh ingest, even of the same id). +// +// This module owns ONLY the value's WIRE FORMAT — build/parse round-trip. Writing it +// to ext-state is the persist shell's job; READING it is the instrument's job in a +// LATER dispatch (S8 instrument-side follow-up, after S10 merges). This is why the +// format is documented here in the header, not just in code: the reader lands elsewhere +// and must decode exactly what this writer produced. +// +// -- The data-ownership boundary (load-bearing) ------------------------------ +// +// The EXTENSION writes this; the instrument only READS it. That does not violate the +// instrument's read-only-over-the-bank rule: the assignment request is the extension +// writing its OWN namespace (a request FROM the extension TO the instrument), never the +// instrument writing back into the bank. The instrument, on reading a new generation, +// updates its OWN component-state selection (the same selection S4 persists) and reloads. +// +// -- Why `generation` ------------------------------------------------------- +// +// Instances reference sample IDs, so re-assigning the SAME id (e.g. a recapture, or a +// re-drop of the same file) would be indistinguishable from a stale value without a +// changing field. `generation` is a monotonic disambiguator (the ingest writer supplies +// a wall-clock unix-epoch stamp today — see the writer shell) so the reader can tell +// "assigned again just now" from "already saw this." It is DELIBERATELY the same shape +// the S9 bank-generation counter will use, but it is NOT that counter — S9 is a separate +// point; this field is self-contained to the request and does not depend on S9 landing. + +#include +#include +#include + +namespace reasampler { + +// One assignment request: the ingested sample's identity + a monotonic disambiguator. +// bankId — the bank the sample was ingested into (the active/target bank). +// sampleId — the ingested Sample's stable id (BankIndex key). +// generation — a monotonic value the reader compares to detect a NEW request. The +// writer supplies a unix-epoch-seconds stamp; the reader treats it as an +// opaque "did this change?" token, not a wall-clock it interprets. +struct AssignmentRequest { + std::string bankId; + std::string sampleId; + std::int64_t generation = 0; + + bool operator==(const AssignmentRequest& o) const { + return bankId == o.bankId && sampleId == o.sampleId && + generation == o.generation; + } + bool operator!=(const AssignmentRequest& o) const { return !(*this == o); } +}; + +// Encode an assignment request to the wire string. Length-prefixed fields behind a +// magic+version tag ("rsassign1"), so arbitrary bytes in an id (a GUID, a display- +// derived id) round-trip whole with no escaping ambiguity — the same idiom provenance +// uses. Deterministic: the same request always yields the same string. +// +// FORMAT (documented for the LATER instrument-side reader): +// "rsassign1" ':' ':' ':' +// where each is the decimal byte length of the field that follows the ':'. +std::string encodeAssignmentRequest(const AssignmentRequest& req); + +// Parse a wire string produced by encodeAssignmentRequest. std::nullopt on any +// malformed / truncated / trailing-garbage input (never UB, never a partial value) — +// the reader shell treats absence/malformed as "no pending request." Round-trips: +// decodeAssignmentRequest(encodeAssignmentRequest(x)) == x. +std::optional decodeAssignmentRequest(const std::string& wire); + +} // namespace reasampler diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 6037411..7eef9ba 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -61,6 +61,7 @@ #include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1) #include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4) #include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) +#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest #include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) #include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) #include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5) @@ -82,6 +83,7 @@ #ifdef _WIN32 #include #include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) +#include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest #else #include #endif @@ -3075,8 +3077,35 @@ void handleRightClick(int x, int y) { // --- Dialog proc + docking ---------------------------------------------------- +// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands +// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all and assigns +// the first. Always DragFinish's the HDROP (frees the shell-allocated drop buffer) on every +// path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; then each path is +// queried by index. Both Win32 and SWELL expose DragQueryFile/DragFinish with this contract. +void handleDropFiles(HDROP hDrop) { + std::vector paths; + const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0); + paths.reserve(count); + for (UINT i = 0; i < count; ++i) { + // Query the required length first (excludes the NUL), then read into a sized buffer. + const UINT len = DragQueryFile(hDrop, i, nullptr, 0); + if (len == 0) continue; + std::vector buf(static_cast(len) + 1, '\0'); + DragQueryFile(hDrop, i, buf.data(), static_cast(buf.size())); + std::string p(buf.data()); + if (!p.empty()) paths.push_back(std::move(p)); + } + DragFinish(hDrop); + if (!paths.empty()) ingestDroppedFiles(paths); +} + WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { + case WM_DROPFILES: + // S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import + // into the active bank + assign the first. wParam is the HDROP. + handleDropFiles(reinterpret_cast(wParam)); + return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); @@ -3172,6 +3201,17 @@ void openPanel() { DockWindowActivate(g_panel.hwnd); g_panel.open = true; + // S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles). + // DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it, + // so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3 + // instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration + // surface is out of scope for this dispatch. WM_DROPFILES handling itself uses + // DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means + // would still ingest — only the accept opt-in is gated. +#ifdef _WIN32 + DragAcceptFiles(g_panel.hwnd, TRUE); +#endif + registerAccel(); reconcileShownBank(); diff --git a/src/ext_keys.h b/src/ext_keys.h index 9461094..3db31ef 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -45,4 +45,15 @@ inline constexpr const char* kProjExtTailKey = "tail_setting"; // The per-project minted-GUID identity key. inline constexpr const char* kProjExtGuidKey = "project_guid"; +// The S8 ingest ASSIGNMENT-REQUEST key. The EXTENSION writes an assignment request here +// after an ingest-with-assign (arrange capture / Media-Explorer import / drop-onto-panel): +// "the active sampler instance should now play THIS sample." The value is the pure +// assignment_request wire format ("rsassign1" + bankId + sampleId + generation) — see +// assignment_request.h for the exact grammar. WIRE-SHARED because the VST3 instrument +// READS it (in a later dispatch, S8 instrument-side follow-up) to update its own selection +// and reload; the instrument never WRITES it (the extension writing its own namespace does +// not violate the instrument's read-only-over-the-bank rule). FOREVER-STABLE once shipped: +// changing this spelling strands any pending request an already-shipped instrument watches. +inline constexpr const char* kProjExtAssignKey = "assign_request"; + } // namespace reasampler diff --git a/src/ingest.cpp b/src/ingest.cpp new file mode 100644 index 0000000..f411bc6 --- /dev/null +++ b/src/ingest.cpp @@ -0,0 +1,384 @@ +// ingest.cpp — the S8 "ingest through the bank" shell (extension side). See ingest.h. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are extern +// (CLAUDE.md §contract). REAPER-facing, DAW-verified; the pure serialization it drives +// (assignment_request) is CTest-tested. + +#include "ingest.h" + +#include +#include +#include +#include +#include +#include + +#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path) +#include "app_version.h" // channelCommandId / channelActionName +#include "assignment_request.h" // pure (bankId, sampleId, generation) encode +#include "bank_book.h" // BankBook, Bank, activeBankId / activeIndex +#include "bank_model.h" // Sample, AddResult, findByHash +#include "bank_panel.h" // bankPanelRefresh +#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent +#include "persist.h" // ReaSamplerSession + +#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_MediaExplorerGetLastPlayedFileInfo +#define REAPERAPI_WANT_PCM_Source_CreateFromFile +#define REAPERAPI_WANT_PCM_Source_Destroy +#define REAPERAPI_WANT_GetMediaSourceNumChannels +#define REAPERAPI_WANT_GetMediaSourceSampleRate +#define REAPERAPI_WANT_GetMediaSourceLength +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// The live session the ingest paths mutate. Set once by ingestRegisterActions and read by +// every ingest body. Not owned here (main.cpp owns g_session). +ReaSamplerSession* g_session = nullptr; + +// FOREVER-STABLE ingest action-id SUFFIX (Phase V, V4). The channel prefix is prepended at +// register via channelCommandId; NEVER change a shipped suffix. Only the Media-Explorer +// import registers here — the arrange capture+assign action lives in the capture family in +// main.cpp (it reuses the capture render machinery there), and the drop path is a panel +// callback (ingestDroppedFiles), not a bindable action. +constexpr const char* kIdImportMediaExplorer = "INGEST_IMPORT_MEDIA_EXPLORER"; + +int g_cmdImportMediaExplorer = 0; +gaccel_register_t g_accelImportMediaExplorer{}; + +// Durable store of the composed, channel-qualified command-id + label strings. A vector of +// two entries suffices here (one action); pointers into it are handed to REAPER at register +// and re-presented at unregister, so it must not be resized after registration. Reserved up +// front so the register-time c_str() stays valid for the unload path. +std::string g_idImportStr; +std::string g_labelImportStr; + +// --- Project directory -------------------------------------------------------- + +// The current project's directory (parent of its .rpp), forward-slashed, no trailing +// slash — the M4 convention (projectDirOfRpp). Empty for an unsaved/no-active project, +// which makes the import refuse to place a file (no default-location fallback — the +// relative-paths invariant). Read-only. +std::string currentProjectDir() { + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + return projectDirOfRpp(std::string(buf.data())); +} + +// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror +// of capture.cpp's readFileBytes — used to hash an import source for dedup + a copied file +// for the Sample's content hash. +std::vector readFileBytes(const std::string& path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return {}; + const std::streamsize n = f.tellg(); + if (n <= 0) return {}; + std::vector bytes(static_cast(n)); + f.seekg(0); + f.read(reinterpret_cast(bytes.data()), n); + if (!f) return {}; + return bytes; +} + +// The result of an import-into-bank: the sample id to assign (the existing id on a +// hash-dedup collapse, the new id otherwise) and whether anything was added to the index +// (so the caller opens an undo point only for a real mutation). +struct ImportResult { + std::string sampleId; // "" on failure (nothing to assign) + bool added = false; // true iff a NEW index entry was created (not a collapse) + std::string message; // human-readable outcome for the console +}; + +// Imports one OS-native source file into the ACTIVE bank: copy into the project-relative +// bank folder + index add, hash-dedup applied. Mirrors capture's landing semantics +// (relative-paths-only, content-hash dedup) for a file the user brought in rather than +// captured. Populates the Sample's metadata by probing the file via PCM_Source. +// +// DEDUP-BEFORE-COPY: hash the SOURCE first and consult the active bank; if the content is +// already held, assign the existing sample's id and DO NOT copy a redundant file to disk. +// Only a genuinely new file is copied + added. This is stricter than capture (which renders +// then adds, leaving a collapsed render orphaned) because an import can cheaply check first. +// +// NON-DESTRUCTIVE: the source file is never modified or moved — only read + copied. +// Records the copied file in the owned-file manifest (Phase B B-cap) so Phase R prune can +// attribute it. Does NOT persist or open an undo point — the caller batches that (a +// multi-file drop is one undo point, one persist). +ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { + ImportResult out; + + if (absoluteSourcePath.empty()) { + out.message = "empty file path"; + return out; + } + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::exists(absoluteSourcePath, ec) || ec) { + out.message = "file not found: " + absoluteSourcePath; + return out; + } + + const std::string projectDir = currentProjectDir(); + if (projectDir.empty()) { + out.message = "no saved project, so the bank has no location -- save the " + "project first"; + return out; + } + + // Hash the SOURCE content up front (WAV-aware, same hasher captures use so an imported + // WAV dedups against a captured one byte-for-byte on audio content). An unreadable + // source yields an empty hash — treated as "not dedupable" (the safe direction: it + // will be copied + added rather than silently collapsed onto an unrelated entry). + const std::vector srcBytes = readFileBytes(absoluteSourcePath); + if (srcBytes.empty()) { + out.message = "file is empty or unreadable: " + absoluteSourcePath; + return out; + } + const std::string contentHash = hashWavContent(srcBytes); + + BankBook& book = g_session->book(); + const std::string activeBankId = book.activeBankId(); + + // Dedup-before-copy: if the active bank already holds this content, assign the existing + // id and skip the copy (no redundant on-disk duplicate). Empty hashes never match + // (findByHash treats "" as non-participating), so an unhashable file always imports fresh. + if (!contentHash.empty()) { + if (const Sample* existing = book.activeIndex().findByHash(contentHash)) { + out.sampleId = existing->id; + out.added = false; // already present — no index mutation, no undo point + out.message = "already in the active bank (assigned existing sample)"; + return out; + } + } + + // Derive the destination path (project-relative index value + absolute copy target). The + // stem comes from the source file name; a timestamp uniqueTag avoids collision with a + // prior import of a same-named file. (fs::path::stem strips the extension and any dir.) + const std::string sourceStem = fs::path(absoluteSourcePath).stem().string(); + const std::int64_t nowSec = static_cast(std::time(nullptr)); + const std::string uniqueTag = std::to_string(nowSec); + const BankPaths paths = deriveBankPaths(projectDir, sourceStem, uniqueTag); + + // Ensure the bank folder exists, then copy the source in (never move — the source is the + // user's file and must be left intact; non-destructive). copy_file with overwrite off: + // the uniqueTag makes a collision astronomically unlikely, and if one occurs we fail + // loudly rather than clobber. + fs::create_directories(paths.absoluteDir, ec); // idempotent; ec ignored (copy reports) + const std::string destPath = paths.absoluteDir + "/" + paths.fileName; + fs::copy_file(absoluteSourcePath, destPath, fs::copy_options::none, ec); + if (ec) { + out.message = "could not copy into the bank folder (" + ec.message() + ")"; + return out; + } + + // Probe the copied file's audio geometry via PCM_Source (channels / rate / length) so + // the Sample carries real metadata the panel + instrument read. A file REAPER cannot + // open leaves these at their zero-values — the sample still imports (the file is on + // disk, the hash is set); the geometry is simply unknown, the honest default. + int channelCount = 0; + int sampleRate = 0; + double lengthSeconds = 0.0; + if (PCM_source* src = PCM_Source_CreateFromFile(destPath.c_str())) { + channelCount = GetMediaSourceNumChannels(src); + sampleRate = GetMediaSourceSampleRate(src); + bool isQN = false; + lengthSeconds = GetMediaSourceLength(src, &isQN); + if (isQN) lengthSeconds = 0.0; // a QN-length source has no seconds length to store + PCM_Source_Destroy(src); + } + + // Build the Sample. Import is NOT a capture — sourceMode/range/tail do not apply; we + // record what we know (path, hash, geometry, name) and leave capture-only fields at + // their defaults. rootNote/loop stay empty (D-B): an imported file is not a single + // played note, so we do not guess a root note. + Sample s; + s.id = "imp-" + uniqueTag + "-" + paths.fileName; + s.displayName = sourceStem.empty() ? std::string("import") : sourceStem; + s.relativePath = paths.relativePath; // project-relative (invariant) + s.channelCount = channelCount; + s.sampleRate = sampleRate; + s.lengthSeconds = lengthSeconds; + s.tier = Tier::Scratch; // imports land in scratch, like captures + s.contentHash = contentHash; + s.createdTimestamp = nowSec; + + const AddResult r = book.activeIndex().add(s); + // Record the copied file as owned regardless of the add outcome — the tool WROTE it, so + // Phase R prune must attribute it. (A Collapsed result here would mean another sample in + // the active bank matched the hash; we pre-checked findByHash above, so in practice + // Added is the outcome. But record + handle both honestly.) + g_session->owned().add(paths.relativePath); + + switch (r) { + case AddResult::Added: + out.sampleId = s.id; + out.added = true; + out.message = "imported -> " + paths.relativePath; + break; + case AddResult::Collapsed: { + // The hash matched an existing entry (a race against our pre-check, or an empty- + // hash edge). Assign the existing entry's id, not the rejected new one. + const Sample* existing = + contentHash.empty() ? nullptr : book.activeIndex().findByHash(contentHash); + out.sampleId = existing ? existing->id : std::string{}; + out.added = false; + out.message = "collapsed onto an existing bank sample"; + break; + } + case AddResult::RejectedAbsolutePath: + case AddResult::RejectedEmptyId: + // deriveBankPaths always yields a relative path and a non-empty id above, so + // these are unreachable in practice — reported honestly rather than silently. + out.message = "index rejected the import (internal path/id error)"; + break; + } + return out; +} + +// --- Media-Explorer import action -------------------------------------------- + +// Import the Media Explorer's current last-played/selected file into the active bank and +// assign it to the active instance (S8 surface 2). Single-file, pull-on-action: +// MediaExplorerGetLastPlayedFileInfo returns the ONE last-played file (the whole ME +// contract — no enumerate-selected API). The selection RANGE it reports is deliberately +// IGNORED here: an import brings the whole file into the bank (the range is a preview +// hint, and the fields are [0,1] fractions, not seconds — see the DAW-verify note); a +// user wanting a sub-range captures it via the arrange path instead. Undo-wrapped. +void doImportFromMediaExplorer() { + // filemode/sel/pitch/vol/rate/bpm/extrainfo are read but only the filename is used for + // the import. selstart/selend are [0,1] fractions (SDK header) — a preview hint, not a + // bank-relevant range; left unused. extrainfo is documented "currently unused". + std::vector nameBuf(4096, '\0'); + int filemode = 0; + double selStart = 0.0, selEnd = 0.0; + double pitch = 0.0, vol = 0.0, rate = 0.0, srcbpm = 0.0; + std::vector extra(256, '\0'); // documented unused; sized generously to be safe + const bool ok = MediaExplorerGetLastPlayedFileInfo( + nameBuf.data(), static_cast(nameBuf.size()), &filemode, &selStart, &selEnd, + &pitch, &vol, &rate, &srcbpm, extra.data(), static_cast(extra.size())); + + const std::string path(nameBuf.data()); + if (!ok || path.empty()) { + ShowConsoleMsg("ReaSampler ingest: no Media Explorer file to import -- open the " + "Media Explorer and select (or preview) a file first.\n"); + return; + } + + const ImportResult r = importFileIntoActiveBank(path); + if (r.sampleId.empty()) { + ShowConsoleMsg(("ReaSampler ingest: Media Explorer import failed -- " + r.message + + ".\n").c_str()); + return; + } + + // Persist the bank add as one undo point ONLY when the index actually mutated (a + // dedup collapse changed nothing on the index). Then assign + refresh the panel. + if (r.added) persistBankOp("ReaSampler: import from Media Explorer"); + ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId); + bankPanelRefresh(); + ShowConsoleMsg(("ReaSampler ingest: " + r.message + " (assigned to the active " + "instance).\n").c_str()); +} + +} // namespace + +// --- Assignment-request write ------------------------------------------------ + +void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId) { + if (!g_session || sampleId.empty()) return; // nothing to assign + + AssignmentRequest req; + req.bankId = bankId; + req.sampleId = sampleId; + // Monotonic disambiguator: a wall-clock unix-epoch stamp so the reader tells a fresh + // assign (even re-assigning the SAME id) from a stale value. NOT the S9 bank-generation + // counter (a separate point) — this field is self-contained to the request. + req.generation = static_cast(std::time(nullptr)); + + g_session->writeAssignmentRequest(encodeAssignmentRequest(req)); +} + +// --- Drop-onto-panel ingest -------------------------------------------------- + +void ingestDroppedFiles(const std::vector& absolutePaths) { + if (!g_session || absolutePaths.empty()) return; + + // Import ALL dropped files; assign the FIRST successfully-imported one (documented + // multi-file policy). Batch the persist + undo point: many imports are ONE undo entry. + std::string firstAssignId; + std::string firstAssignBank; + int importedNew = 0; + int importedTotal = 0; // includes dedup collapses that still yielded an id to assign + std::string lastFailure; + + for (const std::string& path : absolutePaths) { + const ImportResult r = importFileIntoActiveBank(path); + if (r.sampleId.empty()) { + lastFailure = r.message; + continue; + } + ++importedTotal; + if (r.added) ++importedNew; + if (firstAssignId.empty()) { + firstAssignId = r.sampleId; + firstAssignBank = g_session->book().activeBankId(); + } + } + + // One undo point for the whole drop, opened only if a NEW index entry was created (a + // drop that only re-hit existing content mutated nothing on the index). persistBankOp + // itself no-ops the block on an unsaved project. + if (importedNew > 0) persistBankOp("ReaSampler: import dropped file(s)"); + + if (!firstAssignId.empty()) { + ingestAssignActiveInstance(firstAssignBank, firstAssignId); + bankPanelRefresh(); + const std::string msg = + "ReaSampler ingest: imported " + std::to_string(importedTotal) + + (importedTotal == 1 ? " file" : " files") + + " and assigned the first to the active instance.\n"; + ShowConsoleMsg(msg.c_str()); + } else { + ShowConsoleMsg(("ReaSampler ingest: nothing imported from the drop -- " + + (lastFailure.empty() ? std::string("no usable files") : lastFailure) + + ".\n").c_str()); + } +} + +// --- Action registration ------------------------------------------------------ + +void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { + g_session = session; // shared with the capture / bank / Design-View families + + g_idImportStr = channelCommandId(kIdImportMediaExplorer); + g_cmdImportMediaExplorer = rec->Register("command_id", (void*)g_idImportStr.c_str()); + if (g_cmdImportMediaExplorer) { + g_labelImportStr = channelActionName("import Media Explorer file into bank + assign"); + g_accelImportMediaExplorer.accel.cmd = g_cmdImportMediaExplorer; + g_accelImportMediaExplorer.desc = g_labelImportStr.c_str(); + rec->Register("gaccel", (void*)&g_accelImportMediaExplorer); + } +} + +bool ingestHandleCommand(int command) { + if (command == 0 || !g_session) return false; + if (command == g_cmdImportMediaExplorer) { doImportFromMediaExplorer(); return true; } + return false; // not ours — caller's hookcommand keeps looking +} + +void ingestUnregisterActions(reaper_plugin_info_t* rec) { + // Mirror-unregister with '-'-prefixed strings; the '-command_id' re-presents the SAME + // interned channel-qualified id used at register (g_idImportStr). + rec->Register("-gaccel", (void*)&g_accelImportMediaExplorer); + rec->Register("-command_id", (void*)g_idImportStr.c_str()); + g_session = nullptr; +} + +} // namespace reasampler diff --git a/src/ingest.h b/src/ingest.h new file mode 100644 index 0000000..a6b2203 --- /dev/null +++ b/src/ingest.h @@ -0,0 +1,77 @@ +#pragma once +// ingest — the S8 "ingest through the bank" shell (EXTENSION side). +// +// Compiled into the reaper_reasampler MODULE. REAPER-facing (PCM_Source metadata reads, +// Media-Explorer query, ext-state assignment write, action registration), so it is +// DAW-verified, not unit-tested; the pure serialization it drives lives in +// assignment_request (tested in CTest). +// +// -- The one gesture (CONTEXT.md §Ingest through the bank) -------------------- +// +// Loading a sample into the sampler is ONE gesture: capture/import-into-bank AND +// auto-assign to the active sampler instance. The EXTENSION owns ingest (it has arrange +// access, Media-Explorer access, and the drop-target surface on its own panels); the +// instrument stays a READ-ONLY bank consumer. Three ingest surfaces: +// +// 1. Arrange capture -> bank -> assign (a bindable action; reuses the capture path). +// 2. Media-Explorer import -> bank -> assign (a bindable action; single-file, pull-on- +// action via MediaExplorerGetLastPlayedFileInfo). +// 3. Drop-onto-panel -> bank -> assign (an OS file drop on the docked bank_panel HWND; +// multi-file: import all, assign the first). +// +// -- The load-bearing principle (restated) ----------------------------------- +// +// Ingest NEVER inserts a timeline item. Capture writes a file + an index entry; import +// copies a file + adds an index entry; assignment is a bank-index + instance-selection +// act, not a placement. Any path here that calls InsertMedia would be a bug. +// +// -- Import semantics --------------------------------------------------------- +// +// A Media-Explorer/drop import is a FILE COPY into the project-relative bank folder + +// an index add, mirroring how a capture lands (relative-paths-only, hash-dedup). If the +// active bank already holds the imported content (by content hash), the import collapses +// onto the existing sample and assigns THAT sample's id — no redundant on-disk copy. + +#include +#include + +// Forward declarations keep this header REAPER-free at its own boundary (the .cpp pulls +// the SDK). reaper_plugin_info_t is REAPER's dispatch struct; ReaSamplerSession owns the +// book + persist bridge the ingest paths mutate. +struct reaper_plugin_info_t; + +namespace reasampler { + +class ReaSamplerSession; + +// Registers the S8 ingest action family (command_id/gaccel per the house contract), +// mirror of bankRegisterActions. `session` is the live session the ingest paths mutate +// (shared with the capture / bank / Design-View families). The single hookcommand in +// main.cpp routes fired ids here via ingestHandleCommand. +void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); + +// Routes a fired command id to its ingest action. Returns true iff it was one of ours +// (claim-only, per the hookcommand contract); false otherwise so the hook keeps looking. +bool ingestHandleCommand(int command); + +// Mirror-unregisters the ingest action family on unload (the '-'-prefixed strings). +void ingestUnregisterActions(reaper_plugin_info_t* rec); + +// Write the S8 assignment request for a just-ingested sample: "the active sampler +// instance should now play (bankId, sampleId)." Encodes the pure assignment_request value +// (with a fresh monotonic generation stamp) and routes it to ext state via the session. +// Called by EVERY ingest surface after the sample lands in the bank — the arrange +// capture+assign action (main.cpp, alongside the capture machinery it reuses), the ME +// import action, and the drop path. A no-op-safe write: if there is no saved/active +// project the request is silently dropped (nothing to signal into), matching the +// book/manifest quiet-persist idiom. `sampleId` empty -> no write (nothing to assign). +void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId); + +// Ingest OS-dropped files onto a ReaSampler surface (S8 drop path). Called by the +// bank_panel's WM_DROPFILES handler with the dropped file paths (absolute, OS-native). +// Imports EVERY file into the active bank (copy + index add, hash-dedup) and assigns the +// FIRST successfully-imported sample to the active instance. A no-op on an empty list or +// an unsaved/no-active project (nothing to import into). Reports outcomes to the console. +void ingestDroppedFiles(const std::vector& absolutePaths); + +} // namespace reasampler diff --git a/src/main.cpp b/src/main.cpp index a8e5b8c..07c9d26 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -32,6 +32,7 @@ #include "bank_panel.h" #include "batch_capture.h" #include "capture.h" +#include "ingest.h" #include "insert.h" #include "persist.h" #include "provenance.h" @@ -151,6 +152,16 @@ static int g_cmdCaptureTrackRealtime = 0; // explicit action, allowed by the console policy). static int g_cmdRecaptureFromSource = 0; +// Command id for the S8 "capture selected item / time-selection into bank + assign" +// action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline +// Item-scope capture path (RunCapture) verbatim — same razor-else-time range, same +// FX-scope neutralize, same bank/persist landing — then writes an S8 assignment request +// so the active sampler instance plays the just-captured sample on its next reload. NEVER +// inserts a timeline item (the capture/placement separation holds; assign is a bank-index +// + instance-selection act). Lives in the capture family (not the ingest family) because +// it leans on main.cpp's capture render machinery, which is not exposed cross-module. +static int g_cmdCaptureItemAssign = 0; + // Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string. // Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user // who started a long capture can bail without waiting for the range end or hunting for @@ -738,6 +749,11 @@ static reasampler::CaptureResult renderOffline( // a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the // out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard), // and the backend restores every RENDER_* setting. +// +// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id +// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8 +// capture+assign path can target the sample actually in the bank. Batch callers ignore +// it; the plain capture actions are unaffected. static reasampler::CaptureResult captureAndIndexOne( reasampler::CaptureScope scope, const ResolvedSource& src, @@ -780,13 +796,26 @@ static reasampler::CaptureResult captureAndIndexOne( // resample-from-sample; otherwise the optional stays empty, per M1's contract). res.sample.provenance = prov; - // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). - g_session.bank().add(res.sample); + // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). The + // AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can + // target the sample actually in the bank (the existing entry on a collapse). + const reasampler::AddResult addResult = g_session.bank().add(res.sample); // B-cap: record the created file in the owned-file manifest, at the same point the // Sample is added. Recorded regardless of the index AddResult — even a hash-collapse // still WROTE a file the tool owns, and the manifest dedups a repeat path itself // (Phase R prune reconciles manifest vs index later). g_session.owned().add(res.sample.relativePath); + + // Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new + // id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a + // Collapsed (the file we just rendered deduped onto an already-present sample — assign + // THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged. + if (addResult == reasampler::AddResult::Collapsed && !res.sample.contentHash.empty()) + { + if (const reasampler::Sample* existing = + g_session.bank().findByHash(res.sample.contentHash)) + res.sample.id = existing->id; + } return res; } @@ -794,14 +823,19 @@ static reasampler::CaptureResult captureAndIndexOne( // record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle // holds structurally — this path writes a file + a bank index entry ONLY; it never // calls InsertMedia or touches the arrange/timeline. -static void RunCapture(const reasampler::CaptureActionDef& def) +// Returns the bank-index id of the sample the capture landed on: the newly-added id on a +// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign +// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange +// capture+assign path reads this to write an assignment request; the plain capture actions +// ignore it (their behaviour is unchanged — capture still writes a file + index entry only). +static std::string RunCapture(const reasampler::CaptureActionDef& def) { ResolvedSource src; std::string why; if (!ResolveScopeSource(def.scope, src, why)) { ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str()); - return; + return {}; } reasampler::CaptureResult res = @@ -809,14 +843,42 @@ static void RunCapture(const reasampler::CaptureActionDef& def) if (res.status != reasampler::CaptureStatus::Ok) { ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str()); - return; + return {}; } + // captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE + // bank, and recorded the created file in the owned-file manifest (WITHOUT persisting). // Persist the updated book AND manifest into the active project's ext state (the // `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and // travels with the .rpp. saveToActiveProject also clears the retired legacy key and // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys. g_session.saveToActiveProject(); + + // Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne + // resolved res.sample.id to the fresh id on a new add or the existing entry's id on a + // hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above). + return res.sample.id; +} + +// S8 arrange ingest: capture the selected item / time-selection into the active bank +// (reusing the Item-scope capture path verbatim) and, on success, write an assignment +// request so the active sampler instance plays the new sample on its next reload. The +// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER +// inserts a timeline item (load-bearing principle); the only addition here is the +// bank-index-id -> assignment-request write after the sample lands. If the capture +// failed / no-op'd (empty id), no assignment is written (nothing to assign). +static void RunCaptureItemAssign() +{ + // Reuse the Item-scope def from the capture table (index 0) — same range logic, same + // FX-scope neutralize, same bank/persist landing as the plain "capture item" action. + const std::string sampleId = + RunCapture(reasampler::captureActionTable()[0]); + if (sampleId.empty()) return; // capture failed / no-op — RunCapture already reported + + reasampler::ingestAssignActiveInstance(g_session.book().activeBankId(), sampleId); + reasampler::bankPanelRefresh(); + ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " + "instance.\n"); } // --- M11: batch capture (per selected item / per razor area) ---------------- @@ -1422,6 +1484,7 @@ static bool OnHookCommand(int command, int /*flag*/) return true; } if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; } + if (command == g_cmdCaptureItemAssign) { RunCaptureItemAssign(); return true; } if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; } if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } if (command == g_cmdCaptureBatchItems) { RunBatchCaptureItems(); return true; } @@ -1440,6 +1503,8 @@ static bool OnHookCommand(int command, int /*flag*/) if (reasampler::designViewHandleCommand(command)) return true; // Multi-bank action family (B3). Same contract: claims only its own ids. if (reasampler::bankHandleCommand(command)) return true; + // S8 ingest action family (Media-Explorer import). Same contract. + if (reasampler::ingestHandleCommand(command)) return true; return false; } @@ -1455,6 +1520,7 @@ static int OnToggleAction(int command) // gaccel storage must outlive registration — REAPER holds the pointer. // (The capture family's accels live in g_captureAccels, sized to the table.) static gaccel_register_t g_accelToggleBankPanel{}; +static gaccel_register_t g_accelCaptureItemAssign{}; static gaccel_register_t g_accelInsertSelected{}; static gaccel_register_t g_accelInsertSelectedConform{}; static gaccel_register_t g_accelCaptureBatchItems{}; @@ -1468,6 +1534,7 @@ static gaccel_register_t g_accelShowVersion{}; // (channelActionName) so it cannot be a string literal; REAPER holds the gaccel's `desc` // pointer, so each label lives here for the module lifetime. Composed once at registration. static std::string g_descToggleBankPanel; +static std::string g_descCaptureItemAssign; static std::string g_descInsertSelected; static std::string g_descInsertSelectedConform; static std::string g_descCaptureBatchItems; @@ -1480,6 +1547,7 @@ static std::string g_descShowVersion; // Composed command-id strings (channel-qualified), interned so register and the mirroring // '-command_id' unregister pass the SAME pointer. Set during registration; read on unload. static const char* g_idToggleBankPanel = nullptr; +static const char* g_idCaptureItemAssign = nullptr; static const char* g_idInsertSelected = nullptr; static const char* g_idInsertSelectedConform = nullptr; static const char* g_idCaptureBatchItems = nullptr; @@ -1519,6 +1587,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( reasampler::designViewUnregisterActions(g_rec); // Tear down the multi-bank action family (B3) — same mirror-unregister. reasampler::bankUnregisterActions(g_rec); + // Tear down the S8 ingest action family — same mirror-unregister. + reasampler::ingestUnregisterActions(g_rec); // Each '-command_id' re-presents the SAME interned, channel-qualified pointer // used at register (g_id*), so the mirror-unregister matches exactly. g_rec->Register("-gaccel", (void*)&g_accelShowVersion); @@ -1537,6 +1607,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_rec->Register("-command_id", (void*)g_idInsertSelectedConform); g_rec->Register("-gaccel", (void*)&g_accelInsertSelected); g_rec->Register("-command_id", (void*)g_idInsertSelected); + g_rec->Register("-gaccel", (void*)&g_accelCaptureItemAssign); + g_rec->Register("-command_id", (void*)g_idCaptureItemAssign); g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel); g_rec->Register("-command_id", (void*)g_idToggleBankPanel); // Mirror-unregister the capture family: gaccel + command_id per row, with @@ -1627,6 +1699,24 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( rec->Register("toggleaction", (void*)&OnToggleAction); } + // Register the S8 "capture selected item / time-selection into bank + assign" action + // (command_id -> gaccel -> hookcommand). Reuses the Item-scope offline capture path and + // writes an assignment request so the active instance plays the new sample. Channel- + // qualified FOREVER-STABLE id (suffix CAPTURE_ITEM_ASSIGN). MIDI-bindable like every + // capture action. Registered in the capture family (main.cpp) because it leans on the + // capture render machinery here; the other two ingest surfaces live in the ingest family + // (Media-Explorer import) and the panel drop callback. + g_idCaptureItemAssign = internCmdId("CAPTURE_ITEM_ASSIGN"); + g_cmdCaptureItemAssign = rec->Register("command_id", (void*)g_idCaptureItemAssign); + if (g_cmdCaptureItemAssign) + { + g_descCaptureItemAssign = reasampler::channelActionName( + "capture selected item into bank + assign to active instance"); + g_accelCaptureItemAssign.accel.cmd = g_cmdCaptureItemAssign; + g_accelCaptureItemAssign.desc = g_descCaptureItemAssign.c_str(); + rec->Register("gaccel", (void*)&g_accelCaptureItemAssign); + } + // Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two // variants: native-length (default, no stretch) and the EXPLICIT conform-to- // tempo opt-in. Both read the bank panel selection and place at the edit cursor. @@ -1745,6 +1835,12 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // by the same hookcommand via bankHandleCommand. Registered before the hook. reasampler::bankRegisterActions(rec, &g_session); + // Register the S8 ingest action family: the Media-Explorer import-into-bank+assign + // action. Shares g_session with the other families; routed by the same hookcommand via + // ingestHandleCommand. (The arrange capture+assign action is registered in the capture + // family above; the drop path is a bank_panel callback, not a bindable action.) + reasampler::ingestRegisterActions(rec, &g_session); + // One hookcommand routes every ReaSampler action (spike + toggle + Design View). // Registered once, after all command ids are minted. rec->Register("hookcommand", (void*)&OnHookCommand); diff --git a/src/persist.cpp b/src/persist.cpp index 9c8bf3a..74fe848 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -256,6 +256,23 @@ bool ReaSamplerSession::saveToActiveProject() { return true; } +bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { + std::string rppPath; + void* proj = readActiveProject(rppPath); + if (!proj) return false; // no active project — nothing to signal + if (rppPath.empty()) return false; // unsaved project — no .rpp to store into + + // One-shot write of the ingest assignment request under its own key (S8). Independent + // of the book/view/tail blobs — this is a transient signal to the instrument, not + // session state that must ride every save. Uses the channel-derived namespace + // (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta + // instrument reads only a beta extension's assignment requests. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtAssignKey, wire.c_str()); + MarkProjectDirty(static_cast(proj)); + return true; +} + namespace { // The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always diff --git a/src/persist.h b/src/persist.h index 9acd066..723e483 100644 --- a/src/persist.h +++ b/src/persist.h @@ -207,6 +207,21 @@ public: // shown the confirm; this method does NOT prompt. PruneDeletionResult pruneReclaim(const std::vector& confirmed) const; + // Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the + // `assign_request` key, namespace "reasampler"): the extension telling the active + // sampler instance "play THIS sample now." `wire` is the pure assignment_request + // encoding (assignment_request.h); this method only routes the already-encoded value + // to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and + // the encode live in the ingest shell (the pure module) so persist stays a thin bridge. + // + // A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an + // assignment request is a transient "just assigned" signal the instrument reads and + // acts on, so it rides its own key and is written only at ingest time, never on every + // book save. Returns true iff written (an active, SAVED project existed); false on a + // no-active / unsaved project (nothing to write into — the assign is dropped, matching + // the book/manifest quiet-persist idiom the ingest add-path already tolerates). + bool writeAssignmentRequest(const std::string& wire); + // Poll the active project. Detects a project load (active project changed) // and a Save-As (active project's .rpp path changed) and reacts accordingly. // Intended to be driven by REAPER's "timer" register. Idempotent per tick. diff --git a/tests/test_assignment_request.cpp b/tests/test_assignment_request.cpp new file mode 100644 index 0000000..33b2982 --- /dev/null +++ b/tests/test_assignment_request.cpp @@ -0,0 +1,145 @@ +// Standalone tests for reasampler::AssignmentRequest — no REAPER, no framework. +// The S8 ingest assignment-request seam: the (bankId, sampleId, generation) value the +// extension writes to ext-state after an ingest-with-assign, decoded by the instrument +// in a later dispatch. Only the wire format lives in this module; test it hard because +// the reader (a different artifact) must decode exactly what this writer produces. +// +// Covers: encode/decode round-trip, ids carrying arbitrary bytes (GUIDs, separators), +// the generation field including zero and negative-guard, and malformed/truncated/ +// trailing-garbage input -> nullopt (the reader's "no pending request" fallback hinges +// on it). + +#include "../src/assignment_request.h" + +#include +#include + +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) + +// --- round-trip -------------------------------------------------------------- + +static void testRoundTrip() { + AssignmentRequest req; + req.bankId = "{12345678-1234-1234-1234-1234567890AB}"; + req.sampleId = "cap-1700000000-kick.wav"; + req.generation = 1700000123; + + const std::string wire = encodeAssignmentRequest(req); + auto back = decodeAssignmentRequest(wire); + CHECK(back.has_value()); + CHECK(*back == req); + // Re-encoding the decoded value is byte-stable (deterministic encoder). + CHECK(encodeAssignmentRequest(*back) == wire); +} + +// The pool bank id and an empty-ish generation must round-trip too (generation 0 is the +// documented pre-S9 default; an assign still carries a real stamp, but 0 must be legal). +static void testRoundTripPoolAndZeroGeneration() { + AssignmentRequest req; + req.bankId = "pool"; + req.sampleId = "s1"; + req.generation = 0; + + auto back = decodeAssignmentRequest(encodeAssignmentRequest(req)); + CHECK(back.has_value()); + CHECK(back->bankId == "pool"); + CHECK(back->sampleId == "s1"); + CHECK(back->generation == 0); +} + +// Ids carrying the wire's own metacharacters (':' the length delimiter, digits that +// could be misread as a length, the magic-tag bytes) must survive whole — the whole +// reason for length-prefixing over a delimiter-split format. +static void testRoundTripAdversarialIds() { + AssignmentRequest req; + req.bankId = "12:34:has-colons"; // ':' is the length delimiter + req.sampleId = "rsassign1-lookalike-99"; // embeds the magic tag + req.generation = -42; // negative is representable + + auto back = decodeAssignmentRequest(encodeAssignmentRequest(req)); + CHECK(back.has_value()); + CHECK(*back == req); + CHECK(back->bankId == "12:34:has-colons"); + CHECK(back->sampleId == "rsassign1-lookalike-99"); + CHECK(back->generation == -42); +} + +// Empty ids are structurally valid on the wire (length 0) and must round-trip — the +// decoder must not conflate an empty field with a parse failure. +static void testRoundTripEmptyFields() { + AssignmentRequest req; + req.bankId = ""; + req.sampleId = ""; + req.generation = 7; + + auto back = decodeAssignmentRequest(encodeAssignmentRequest(req)); + CHECK(back.has_value()); + CHECK(*back == req); +} + +// A large generation (past 32-bit) must not truncate — the field is int64. +static void testLargeGeneration() { + AssignmentRequest req; + req.bankId = "b"; + req.sampleId = "s"; + req.generation = 9007199254740993LL; // > 2^53, > INT32_MAX + + auto back = decodeAssignmentRequest(encodeAssignmentRequest(req)); + CHECK(back.has_value()); + CHECK(back->generation == 9007199254740993LL); +} + +// --- malformed / tolerant parse ---------------------------------------------- + +static void testMalformedParse() { + // Absence / total garbage — the reader maps these to "no pending request". + CHECK(!decodeAssignmentRequest("").has_value()); + CHECK(!decodeAssignmentRequest("not a request").has_value()); + // Wrong magic tag. + CHECK(!decodeAssignmentRequest("rsprov1" "1:b1:s1:7").has_value()); + // Magic only, no fields. + CHECK(!decodeAssignmentRequest("rsassign1").has_value()); + // Truncated mid-record (missing the generation field). + CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s1").has_value()); + // A length that runs past the end. + CHECK(!decodeAssignmentRequest("rsassign1" "99:short").has_value()); + // Non-numeric length token. + CHECK(!decodeAssignmentRequest("rsassign1" "x:pool2:s11:7").has_value()); + // A non-numeric generation field. + CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s13:abc").has_value()); + // A bare "-" generation. + CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s11:-").has_value()); +} + +// Trailing garbage after a well-formed record must be rejected — a partial/padded blob +// is not a valid request, and the reader must never accept the prefix and ignore the rest. +static void testTrailingGarbageRejected() { + AssignmentRequest req; + req.bankId = "pool"; + req.sampleId = "s1"; + req.generation = 7; + const std::string wire = encodeAssignmentRequest(req); + + // The clean value parses. + CHECK(decodeAssignmentRequest(wire).has_value()); + // The same value with any trailing byte does not. + CHECK(!decodeAssignmentRequest(wire + "X").has_value()); + CHECK(!decodeAssignmentRequest(wire + "0:").has_value()); +} + +int main() { + testRoundTrip(); + testRoundTripPoolAndZeroGeneration(); + testRoundTripAdversarialIds(); + testRoundTripEmptyFields(); + testLargeGeneration(); + testMalformedParse(); + testTrailingGarbageRejected(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} From 373948c18ad71d1efe157adac1039af2ad79624c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 21:56:09 -0400 Subject: [PATCH 29/49] fix(ingest): convert-on-import to 32f WAV, undo-group bank+assign, overflow guards Non-WAV sources decode via PCM_source::GetSamples and land as canonical 32f RIFF/WAVE; hash taken post-conversion so re-imports dedup. Undo block covers bank mutation + assign_request atomically. Overflow guards + adversarial tests. --- src/assignment_request.cpp | 37 +++- src/assignment_request.h | 9 + src/ingest.cpp | 349 ++++++++++++++++++++++++------ src/main.cpp | 21 +- tests/test_assignment_request.cpp | 40 ++++ 5 files changed, 378 insertions(+), 78 deletions(-) diff --git a/src/assignment_request.cpp b/src/assignment_request.cpp index a880e0c..077aeee 100644 --- a/src/assignment_request.cpp +++ b/src/assignment_request.cpp @@ -3,6 +3,7 @@ #include "assignment_request.h" #include +#include namespace reasampler { @@ -29,27 +30,44 @@ public: bool atEnd() const { return pos_ >= s_.size(); } // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or - // non-numeric length, or a length that runs past the end. + // non-numeric length, a length that overflows SIZE_MAX, or a length that runs past + // the end. The digit count is capped at 20 (the decimal width of SIZE_MAX on a + // 64-bit host) so a crafted 200-digit length cannot accumulate past SIZE_MAX via + // repeated multiply. "never UB" promise from the header is upheld here. bool field(std::string& out) { if (!ok_) return false; const std::size_t colon = s_.find(':', pos_); if (colon == std::string::npos) return fail(); if (colon == pos_) return fail(); // empty length token + // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. + if (colon - pos_ > 20u) return fail(); std::size_t len = 0; for (std::size_t i = pos_; i < colon; ++i) { const char c = s_[i]; if (c < '0' || c > '9') return fail(); - len = len * 10 + static_cast(c - '0'); + const std::size_t digit = static_cast(c - '0'); + // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. + if (len > (std::numeric_limits::max() - digit) / 10u) + return fail(); + len = len * 10u + digit; } const std::size_t start = colon + 1; - if (start + len > s_.size()) return fail(); + // Guard: start may equal s_.size() (empty remainder), in which case only len==0 + // is valid; start > s_.size() cannot happen (colon < s_.size() by find()). + // Use subtraction-first form to avoid start+len wrapping on a huge len. + if (start > s_.size() || len > s_.size() - start) return fail(); out.assign(s_, start, len); pos_ = start + len; return true; } // Reads a length-prefixed field and parses it as a signed 64-bit decimal (an - // optional leading '-'). Fails on empty, non-digit, or trailing bytes. + // optional leading '-'). Fails on empty, non-digit, trailing bytes, or a value + // that would overflow INT64_MAX / underflow INT64_MIN. The digit count is capped + // at 19 (the decimal width of INT64_MAX, plus 1 for the optional sign = 20 + // characters maximum) so a crafted 21-digit field cannot accumulate UB. "never UB" + // promise from the header is upheld: all arithmetic is done on positive digits + // and capped before applying the sign. bool fieldInt64(std::int64_t& out) { std::string f; if (!field(f)) return false; @@ -61,11 +79,20 @@ public: i = 1; if (f.size() == 1) return fail(); // bare "-" } + // Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit + // positive value would overflow INT64_MAX; a 20-digit negative might be valid + // (INT64_MIN = -9223372036854775808) but we conservatively reject it too: the + // generation field is a unix timestamp, never near INT64 limits in practice. + if (f.size() - i > 19u) return fail(); std::int64_t v = 0; for (; i < f.size(); ++i) { const char c = f[i]; if (c < '0' || c > '9') return fail(); - v = v * 10 + static_cast(c - '0'); + const std::int64_t digit = static_cast(c - '0'); + // Overflow guard: v * 10 + digit must not exceed INT64_MAX. + if (v > (std::numeric_limits::max() - digit) / 10) + return fail(); + v = v * 10 + digit; } out = neg ? -v : v; return true; diff --git a/src/assignment_request.h b/src/assignment_request.h index 4992242..46c1e03 100644 --- a/src/assignment_request.h +++ b/src/assignment_request.h @@ -75,6 +75,15 @@ std::string encodeAssignmentRequest(const AssignmentRequest& req); // malformed / truncated / trailing-garbage input (never UB, never a partial value) — // the reader shell treats absence/malformed as "no pending request." Round-trips: // decodeAssignmentRequest(encodeAssignmentRequest(x)) == x. +// +// READER REQUIREMENT (instrument-side, S8 follow-up dispatch): after successfully +// decoding a request, the reader MUST verify that (bankId, sampleId) resolves to an +// existing sample before acting on it. An undo on the extension side rolls back the +// `banks` ext-state key (removing the sample) but cannot atomically clear the +// `assign_request` key if the write happened outside the undo block. Even with the +// undo-grouping fix (Major 2), the reader must guard against this: treat an +// unresolvable (bankId, sampleId) pair as a stale/no-op request and discard it +// silently, never crashing or selecting a nonexistent entry. std::optional decodeAssignmentRequest(const std::string& wire); } // namespace reasampler diff --git a/src/ingest.cpp b/src/ingest.cpp index f411bc6..6211bb3 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -8,6 +8,7 @@ #include "ingest.h" #include +#include #include #include #include @@ -23,6 +24,8 @@ #include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent #include "persist.h" // ReaSamplerSession +#include "wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path + #include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) #define REAPERAPI_MINIMAL @@ -34,6 +37,8 @@ #define REAPERAPI_WANT_GetMediaSourceNumChannels #define REAPERAPI_WANT_GetMediaSourceSampleRate #define REAPERAPI_WANT_GetMediaSourceLength +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler { @@ -54,10 +59,10 @@ constexpr const char* kIdImportMediaExplorer = "INGEST_IMPORT_MEDIA_EXPLORER"; int g_cmdImportMediaExplorer = 0; gaccel_register_t g_accelImportMediaExplorer{}; -// Durable store of the composed, channel-qualified command-id + label strings. A vector of -// two entries suffices here (one action); pointers into it are handed to REAPER at register -// and re-presented at unregister, so it must not be resized after registration. Reserved up -// front so the register-time c_str() stays valid for the unload path. +// Durable store of the composed, channel-qualified command-id + label strings. Two scalar +// std::string globals (one action); their c_str() pointers are handed to REAPER at register +// and re-presented at unregister, so these strings must not be mutated after registration. +// Populated once by ingestRegisterActions; stable for the extension lifetime. std::string g_idImportStr; std::string g_labelImportStr; @@ -74,8 +79,7 @@ std::string currentProjectDir() { } // Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror -// of capture.cpp's readFileBytes — used to hash an import source for dedup + a copied file -// for the Sample's content hash. +// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy. std::vector readFileBytes(const std::string& path) { std::ifstream f(path, std::ios::binary | std::ios::ate); if (!f) return {}; @@ -88,27 +92,155 @@ std::vector readFileBytes(const std::string& path) { return bytes; } +// Writes a byte buffer to a file. Returns true on success. The caller is responsible for +// ensuring the directory exists before calling. +bool writeFileBytes(const std::string& path, const std::vector& bytes) { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + return f.good(); +} + +// Builds a minimal 32-bit-float RIFF/WAVE byte buffer from interleaved double samples. +// The output is a canonical WAV the bank and wav_trim can read: +// RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, 16-byte body), +// data chunk (interleaved little-endian float32, one float per sample per channel). +// `nch` channels, `rate` Hz sample rate, `frameCount` frames (total samples = frameCount*nch). +// Each ReaSample (double) is narrowed to float by assignment — the instrument expects +// 32-bit float; the reduction is intentional and matches how the bank contract is defined +// (capture.cpp kRenderFormatWavFloat32; wav_trim.h FORMAT ASSUMPTION). +std::vector buildFloat32Wav(int nch, std::uint32_t rate, + std::size_t frameCount, + const std::vector& interleaved) { + const std::size_t sampleCount = frameCount * static_cast(nch); + const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32 + + // The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload. + // Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount. + const std::uint32_t riffSize = + static_cast(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data + + std::vector out; + out.reserve(44u + dataBytesCount); + + auto putU16 = [&](std::uint16_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + }; + auto putU32 = [&](std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); + }; + auto putTag = [&](const char* t) { + for (int i = 0; i < 4; ++i) + out.push_back(static_cast(t[i])); + }; + auto putF32 = [&](float f) { + std::uint8_t tmp[4]; + std::memcpy(tmp, &f, 4); + for (int i = 0; i < 4; ++i) out.push_back(tmp[i]); + }; + + // RIFF header + putTag("RIFF"); + putU32(riffSize); + putTag("WAVE"); + + // fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003) + putTag("fmt "); + putU32(16u); // chunk body size + putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT + putU16(static_cast(nch)); + putU32(rate); + putU32(rate * static_cast(nch) * 4u); // avgBytesPerSec + putU16(static_cast(nch * 4)); // blockAlign + putU16(32u); // bitsPerSample + + // data chunk + putTag("data"); + putU32(static_cast(dataBytesCount)); + for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i) + putF32(static_cast(interleaved[i])); + + return out; +} + +// Decodes ALL samples from `src` into interleaved double-precision frames. +// Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0). +// Uses GetSamples in blocks; advances time_s monotonically. The caller has already +// queried channelCount and sampleRate from the same source; those values are passed in +// to avoid re-querying after GetSamples mutates decoder state. +std::vector decodePcmSource(PCM_source* src, int nch, double sampleRate, + double lengthSeconds) { + if (!src || nch <= 0 || sampleRate < 1.0 || lengthSeconds <= 0.0) return {}; + + const std::size_t totalFrames = + static_cast(lengthSeconds * sampleRate + 0.5); + if (totalFrames == 0) return {}; + + std::vector out; + out.reserve(totalFrames * static_cast(nch)); + + // Pull samples in blocks of ~4096 frames; loop until source is exhausted. + constexpr int kBlockFrames = 4096; + std::vector block(static_cast(kBlockFrames * nch)); + + PCM_source_transfer_t t{}; + t.samplerate = sampleRate; + t.nch = nch; + t.time_s = 0.0; + t.midi_events = nullptr; + + while (true) { + t.samples = block.data(); + t.length = kBlockFrames; + t.samples_out = 0; + src->GetSamples(&t); + if (t.samples_out <= 0) break; + const std::size_t got = static_cast(t.samples_out) * + static_cast(nch); + out.insert(out.end(), block.data(), block.data() + got); + t.time_s += static_cast(t.samples_out) / sampleRate; + } + return out; +} + // The result of an import-into-bank: the sample id to assign (the existing id on a // hash-dedup collapse, the new id otherwise) and whether anything was added to the index // (so the caller opens an undo point only for a real mutation). struct ImportResult { - std::string sampleId; // "" on failure (nothing to assign) - bool added = false; // true iff a NEW index entry was created (not a collapse) - std::string message; // human-readable outcome for the console + std::string sampleId; // "" on failure (nothing to assign) + bool added = false; // true iff a NEW index entry was created (not a collapse) + std::string message; // human-readable outcome for the console }; -// Imports one OS-native source file into the ACTIVE bank: copy into the project-relative -// bank folder + index add, hash-dedup applied. Mirrors capture's landing semantics -// (relative-paths-only, content-hash dedup) for a file the user brought in rather than -// captured. Populates the Sample's metadata by probing the file via PCM_Source. +// Imports one OS-native source file into the ACTIVE bank: convert-if-needed to 32-bit- +// float WAV, write to the project-relative bank folder, index-add, hash-dedup applied. // -// DEDUP-BEFORE-COPY: hash the SOURCE first and consult the active bank; if the content is -// already held, assign the existing sample's id and DO NOT copy a redundant file to disk. -// Only a genuinely new file is copied + added. This is stricter than capture (which renders -// then adds, leaving a collapsed render orphaned) because an import can cheaply check first. +// BANK CONTRACT: the instrument (wav_trim) expects every bank file to be a canonical +// 32-bit-float WAV (WAVE_FORMAT_IEEE_FLOAT, 32 bits). A verbatim copy of a non-WAV (or +// an integer-PCM or double-float WAV) would be unplayable. This function therefore: +// 1. Checks whether the source IS already a valid 32f WAV (parseWavLayout fast path). +// 2. If yes: copies it verbatim — one I/O, content unchanged. +// 3. If no: decodes via PCM_source::GetSamples and writes a fresh 32f WAV, preserving +// the source's channel count and sample rate. // -// NON-DESTRUCTIVE: the source file is never modified or moved — only read + copied. -// Records the copied file in the owned-file manifest (Phase B B-cap) so Phase R prune can +// DEDUP ORDERING: the content hash is taken from the CONVERTED (bank-format) bytes AFTER +// building the file buffer but BEFORE writing to disk. This means: +// * Re-importing the same source file yields the same converted bytes → same hash → +// dedup fires → no redundant disk write (matching the DEDUP-BEFORE-DISK design). +// * An imported WAV whose audio-content hash matches a captured WAV also deduplicates +// correctly (hashWavContent is chunk-aware for both). +// * The pre-conversion hash shortcut (hash the raw source bytes) is not used: a non-WAV +// source's bytes would produce a different hash from the converted WAV bytes, so two +// imports of the same mp3 would NOT dedup — which is wrong. Hashing post-conversion +// is correct. +// +// NON-DESTRUCTIVE: the source file is never modified or moved — only read. +// Records the written file in the owned-file manifest (Phase B B-cap) so Phase R prune can // attribute it. Does NOT persist or open an undo point — the caller batches that (a // multi-file drop is one undo point, one persist). ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { @@ -132,23 +264,83 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { return out; } - // Hash the SOURCE content up front (WAV-aware, same hasher captures use so an imported - // WAV dedups against a captured one byte-for-byte on audio content). An unreadable - // source yields an empty hash — treated as "not dedupable" (the safe direction: it - // will be copied + added rather than silently collapsed onto an unrelated entry). + // Read source bytes; needed to check whether it is already a 32f WAV. const std::vector srcBytes = readFileBytes(absoluteSourcePath); if (srcBytes.empty()) { out.message = "file is empty or unreadable: " + absoluteSourcePath; return out; } - const std::string contentHash = hashWavContent(srcBytes); + + // Probe the source's audio geometry via PCM_source. Needed for conversion AND for + // populating the Sample's metadata. A file REAPER cannot open leaves geometry at + // zero — the sample still imports if the WAV-fast-path succeeds; the geometry + // is simply unknown, the honest default. + int channelCount = 0; + int sampleRate = 0; + double lengthSeconds = 0.0; + PCM_source* srcHandle = PCM_Source_CreateFromFile(absoluteSourcePath.c_str()); + if (srcHandle) { + channelCount = GetMediaSourceNumChannels(srcHandle); + sampleRate = GetMediaSourceSampleRate(srcHandle); + bool isQN = false; + lengthSeconds = GetMediaSourceLength(srcHandle, &isQN); + if (isQN) lengthSeconds = 0.0; // QN-length source has no seconds length to store + } + + // Determine whether a verbatim copy suffices (fast path) or a conversion is needed. + // parseWavLayout validates that the source is a canonical 32-bit-float RIFF/WAVE; any + // other format (mp3, aiff, integer PCM, 16-bit WAV, etc.) takes the decode+rewrite path. + const WavLayout layout = parseWavLayout(srcBytes); + const bool isFloat32Wav = layout.valid; + + // Build the bank-format bytes in memory (the "converted" bytes), which we hash for dedup + // BEFORE writing to disk so a re-import of the same source skips the disk write. + std::vector bankBytes; + if (isFloat32Wav) { + // Fast path: already canonical — bank bytes ARE the source bytes. + bankBytes = srcBytes; + if (srcHandle) PCM_Source_Destroy(srcHandle); + } else { + // Conversion path: decode all samples then write a fresh 32f WAV. + // PCM_source is opened on the source path (not a copy); we already have srcHandle. + std::vector decoded; + if (srcHandle && channelCount > 0 && sampleRate > 0 && lengthSeconds > 0.0) { + decoded = decodePcmSource(srcHandle, channelCount, + static_cast(sampleRate), lengthSeconds); + } + if (srcHandle) PCM_Source_Destroy(srcHandle); + + if (decoded.empty()) { + // No decodable audio. The source is on disk (valid path, REAPER could open it) + // but yielded no samples — e.g. a MIDI file, a zero-length audio file, or a + // format REAPER does not support. Fail loudly: we must not write a silent WAV + // and pretend the import succeeded. + out.message = "could not decode audio samples from: " + + fs::path(absoluteSourcePath).filename().string() + + " (unsupported format or no audio data)"; + return out; + } + + const std::size_t frameCount = + decoded.size() / static_cast(channelCount > 0 ? channelCount : 1); + bankBytes = buildFloat32Wav(channelCount, + static_cast(sampleRate), + frameCount, decoded); + } + // srcHandle is destroyed above in both branches. + + // Hash the converted (bank-format) bytes for dedup. WAV-aware hash (hashWavContent) + // so a re-import of the same source deduplicates against a previously-captured or + // previously-imported sample with identical audio content, even if non-audio RIFF + // chunks differ. Empty hash (unhashable) is treated as "not dedupable" (safe direction: + // copies + adds rather than silently collapsing onto an unrelated entry). + const std::string contentHash = hashWavContent(bankBytes); BankBook& book = g_session->book(); - const std::string activeBankId = book.activeBankId(); - // Dedup-before-copy: if the active bank already holds this content, assign the existing - // id and skip the copy (no redundant on-disk duplicate). Empty hashes never match - // (findByHash treats "" as non-participating), so an unhashable file always imports fresh. + // Dedup-before-disk: if the active bank already holds this audio content, assign the + // existing sample's id and skip the disk write (no redundant on-disk duplicate). + // Empty hashes never match (findByHash treats "" as non-participating). if (!contentHash.empty()) { if (const Sample* existing = book.activeIndex().findByHash(contentHash)) { out.sampleId = existing->id; @@ -158,46 +350,25 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { } } - // Derive the destination path (project-relative index value + absolute copy target). The - // stem comes from the source file name; a timestamp uniqueTag avoids collision with a - // prior import of a same-named file. (fs::path::stem strips the extension and any dir.) + // Derive the destination path. The stem comes from the source file name; a timestamp + // uniqueTag avoids collision with a prior import of a same-named file. const std::string sourceStem = fs::path(absoluteSourcePath).stem().string(); const std::int64_t nowSec = static_cast(std::time(nullptr)); const std::string uniqueTag = std::to_string(nowSec); const BankPaths paths = deriveBankPaths(projectDir, sourceStem, uniqueTag); - // Ensure the bank folder exists, then copy the source in (never move — the source is the - // user's file and must be left intact; non-destructive). copy_file with overwrite off: - // the uniqueTag makes a collision astronomically unlikely, and if one occurs we fail - // loudly rather than clobber. - fs::create_directories(paths.absoluteDir, ec); // idempotent; ec ignored (copy reports) + // Ensure the bank folder exists, then write the (converted) bank bytes. + fs::create_directories(paths.absoluteDir, ec); // idempotent; ec ignored (write reports) const std::string destPath = paths.absoluteDir + "/" + paths.fileName; - fs::copy_file(absoluteSourcePath, destPath, fs::copy_options::none, ec); - if (ec) { - out.message = "could not copy into the bank folder (" + ec.message() + ")"; + if (!writeFileBytes(destPath, bankBytes)) { + out.message = "could not write converted file to the bank folder"; return out; } - // Probe the copied file's audio geometry via PCM_Source (channels / rate / length) so - // the Sample carries real metadata the panel + instrument read. A file REAPER cannot - // open leaves these at their zero-values — the sample still imports (the file is on - // disk, the hash is set); the geometry is simply unknown, the honest default. - int channelCount = 0; - int sampleRate = 0; - double lengthSeconds = 0.0; - if (PCM_source* src = PCM_Source_CreateFromFile(destPath.c_str())) { - channelCount = GetMediaSourceNumChannels(src); - sampleRate = GetMediaSourceSampleRate(src); - bool isQN = false; - lengthSeconds = GetMediaSourceLength(src, &isQN); - if (isQN) lengthSeconds = 0.0; // a QN-length source has no seconds length to store - PCM_Source_Destroy(src); - } - // Build the Sample. Import is NOT a capture — sourceMode/range/tail do not apply; we // record what we know (path, hash, geometry, name) and leave capture-only fields at - // their defaults. rootNote/loop stay empty (D-B): an imported file is not a single - // played note, so we do not guess a root note. + // their defaults. rootNote/loop stay empty: an imported file is not a single played + // note, so we do not guess a root note. Sample s; s.id = "imp-" + uniqueTag + "-" + paths.fileName; s.displayName = sourceStem.empty() ? std::string("import") : sourceStem; @@ -210,21 +381,22 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { s.createdTimestamp = nowSec; const AddResult r = book.activeIndex().add(s); - // Record the copied file as owned regardless of the add outcome — the tool WROTE it, so + // Record the written file as owned regardless of the add outcome — the tool WROTE it, so // Phase R prune must attribute it. (A Collapsed result here would mean another sample in - // the active bank matched the hash; we pre-checked findByHash above, so in practice - // Added is the outcome. But record + handle both honestly.) + // the active bank matched the hash after we passed the pre-write dedup check — a narrow + // race window. Record + handle both honestly.) g_session->owned().add(paths.relativePath); switch (r) { case AddResult::Added: out.sampleId = s.id; out.added = true; - out.message = "imported -> " + paths.relativePath; + out.message = (isFloat32Wav ? "imported -> " : "converted + imported -> ") + + paths.relativePath; break; case AddResult::Collapsed: { - // The hash matched an existing entry (a race against our pre-check, or an empty- - // hash edge). Assign the existing entry's id, not the rejected new one. + // The hash matched an existing entry (a race against our pre-write dedup check, + // or an empty-hash edge). Assign the existing entry's id. const Sample* existing = contentHash.empty() ? nullptr : book.activeIndex().findByHash(contentHash); out.sampleId = existing ? existing->id : std::string{}; @@ -278,10 +450,27 @@ void doImportFromMediaExplorer() { return; } - // Persist the bank add as one undo point ONLY when the index actually mutated (a - // dedup collapse changed nothing on the index). Then assign + refresh the panel. - if (r.added) persistBankOp("ReaSampler: import from Media Explorer"); - ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId); + // Persist the bank add AND the assign request inside ONE undo block so Ctrl-Z rolls + // back both keys atomically: undo restores `banks` (removing the new sample) AND + // clears the `assign_request` that named it, so no stale request can survive. + // The block is opened only when the index mutated (a dedup collapse changed nothing). + // If saveToActiveProject() no-ops (unsaved project), we close with an empty label + + // zero flag so REAPER discards the undo entry (the house pattern from actions.cpp). + if (r.added) { + Undo_BeginBlock2(nullptr); + const bool persisted = g_session->saveToActiveProject(); + // Assign request inside the same block: undo rolls back both keys together. + ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId); + if (persisted) + Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer", + UNDO_STATE_MISCCFG); + else + Undo_EndBlock2(nullptr, "", 0); + } else { + // Dedup collapse: index unchanged, no undo point. Assign request still written + // (the user explicitly re-imported; they want the instance updated). + ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId); + } bankPanelRefresh(); ShowConsoleMsg(("ReaSampler ingest: " + r.message + " (assigned to the active " "instance).\n").c_str()); @@ -333,12 +522,28 @@ void ingestDroppedFiles(const std::vector& absolutePaths) { } // One undo point for the whole drop, opened only if a NEW index entry was created (a - // drop that only re-hit existing content mutated nothing on the index). persistBankOp - // itself no-ops the block on an unsaved project. - if (importedNew > 0) persistBankOp("ReaSampler: import dropped file(s)"); - + // drop that only re-hit existing content mutated nothing on the index). The assign + // request is written INSIDE the same block so Ctrl-Z rolls back both keys together: + // undo restores `banks` (removing the new samples) AND clears the `assign_request` that + // named one of them, so no stale request survives pointing to a removed sample. + // If saveToActiveProject() no-ops (unsaved project), we close with an empty label + zero + // flag so REAPER discards the undo entry (house pattern from actions.cpp). if (!firstAssignId.empty()) { - ingestAssignActiveInstance(firstAssignBank, firstAssignId); + if (importedNew > 0) { + Undo_BeginBlock2(nullptr); + const bool persisted = g_session->saveToActiveProject(); + // Assign inside the block: undo restores both keys atomically. + ingestAssignActiveInstance(firstAssignBank, firstAssignId); + if (persisted) + Undo_EndBlock2(nullptr, "ReaSampler: import dropped file(s)", + UNDO_STATE_MISCCFG); + else + Undo_EndBlock2(nullptr, "", 0); + } else { + // All dropped files deduplicated: index unchanged, no undo point needed. Still + // assign so the user sees the sample is already in the bank. + ingestAssignActiveInstance(firstAssignBank, firstAssignId); + } bankPanelRefresh(); const std::string msg = "ReaSampler ingest: imported " + std::to_string(importedTotal) + diff --git a/src/main.cpp b/src/main.cpp index 07c9d26..eeecdb3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -867,15 +867,34 @@ static std::string RunCapture(const reasampler::CaptureActionDef& def) // inserts a timeline item (load-bearing principle); the only addition here is the // bank-index-id -> assignment-request write after the sample lands. If the capture // failed / no-op'd (empty id), no assignment is written (nothing to assign). +// +// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the +// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are +// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically. +// An undo that removes the captured sample also clears the assign_request that named it, +// preventing a stale request from pointing at a removed sample. The block uses the house +// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero +// flag) matching the bank-op family in actions.cpp. static void RunCaptureItemAssign() { // Reuse the Item-scope def from the capture table (index 0) — same range logic, same // FX-scope neutralize, same bank/persist landing as the plain "capture item" action. + Undo_BeginBlock2(nullptr); + const std::string sampleId = RunCapture(reasampler::captureActionTable()[0]); - if (sampleId.empty()) return; // capture failed / no-op — RunCapture already reported + if (sampleId.empty()) + { + // Capture failed or no-op'd — RunCapture already reported. Discard the empty point. + Undo_EndBlock2(nullptr, "", 0); + return; + } + // Assign inside the same block so undo clears both keys together. reasampler::ingestAssignActiveInstance(g_session.book().activeBankId(), sampleId); + Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance", + UNDO_STATE_MISCCFG); + reasampler::bankPanelRefresh(); ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " "instance.\n"); diff --git a/tests/test_assignment_request.cpp b/tests/test_assignment_request.cpp index 33b2982..9dfe12c 100644 --- a/tests/test_assignment_request.cpp +++ b/tests/test_assignment_request.cpp @@ -131,6 +131,43 @@ static void testTrailingGarbageRejected() { CHECK(!decodeAssignmentRequest(wire + "0:").has_value()); } +// --- overflow / adversarial integer inputs ------------------------------------ + +// A 21-digit length field overflows SIZE_MAX and must be rejected safely (no UB, +// no wrap-around that could make a huge length appear small and pass the bounds check). +static void testOverflowFieldLength() { + // Craft a wire where the bankId length token is 21 digits that exceed SIZE_MAX. + // The decoder must fail cleanly, not access memory out of bounds. + // "rsassign1" + "999999999999999999999:" (21 nines) + junk: rejects before OOB. + const std::string wire = std::string("rsassign1") + "999999999999999999999:junk"; + CHECK(!decodeAssignmentRequest(wire).has_value()); +} + +// A 20-digit generation (exceeds the 19-digit cap) must be rejected safely. +static void testOverflowFieldInt64() { + // Encode a valid record then manually substitute the generation with a 20-digit value. + // We cannot use encode (it would produce a correct 19-digit generation), so we + // build the wire manually. Generation "99999999999999999999" (20 nines) exceeds cap. + // bankId = "pool" (4 bytes), sampleId = "s1" (2 bytes). + const std::string wire = std::string("rsassign1") + + "4:pool" + + "2:s1" + + "20:99999999999999999999"; + CHECK(!decodeAssignmentRequest(wire).has_value()); +} + +// SIZE_MAX as a length field (20 digits, within the digit-count cap) must not UB or +// wrap. The overflow-guard in field() caps the multiplication; even if the value itself +// does not trigger the multiply guard (SIZE_MAX accumulates cleanly digit by digit), +// the subsequent "len > s_.size() - start" bounds check catches it because the actual +// string is tiny — no OOB access, no wraparound, clean rejection. +static void testOverflowExactSizeMax() { + // 18446744073709551615 = SIZE_MAX on 64-bit. 20 digits: within the digit cap, but the + // trailing bounds check rejects it because the wire string is far smaller than SIZE_MAX. + const std::string wire = std::string("rsassign1") + "18446744073709551615:X"; + CHECK(!decodeAssignmentRequest(wire).has_value()); +} + int main() { testRoundTrip(); testRoundTripPoolAndZeroGeneration(); @@ -139,6 +176,9 @@ int main() { testLargeGeneration(); testMalformedParse(); testTrailingGarbageRejected(); + testOverflowFieldLength(); + testOverflowFieldInt64(); + testOverflowExactSizeMax(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; From 7a191bde61ce40b65c1f26e876b334532da8b718 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 22:09:45 -0400 Subject: [PATCH 30/49] =?UTF-8?q?test:=20S7xS11=20compose=20=E2=80=94=20st?= =?UTF-8?q?ereo+startFrame+loop=20share=20one=20read=20head;=20v4=20stereo?= =?UTF-8?q?=20envelope=20+=20v2=20zone=20overrides=20round-trip=20together?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_sample_map.cpp | 39 ++++++++++++++++++++++++++++++ tests/test_sampler_core.cpp | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 57a2970..7763915 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -941,6 +941,44 @@ static void testComponentStateV4TruncatedModeByte() { CHECK(back.selectionId.empty() && back.map.zones.empty()); } +static void testComponentStateV4StereoWithZoneOverridesRoundTrip() { + // The MERGE composition property (S7 v4 envelope x S11 v2 zones payload): a v4 blob carrying + // channelMode = STEREO AND zones with loopOverride + startPoint must round-trip ALL of it + // losslessly. The channel-mode byte lives on the envelope; the loop/start overrides live in + // the self-versioned zones payload — the two tracks are orthogonal, so both survive one + // serialize/deserialize. (V4RoundTripStereo covers mode with a bare zone; LoopStartRoundTrip + // covers overrides at the default mono mode; this asserts them TOGETHER.) + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + PerformanceZone z0 = zone("z0", 12, 48, /*override=*/36); + SampleLoop lp0; lp0.hasLoop = true; lp0.start = 500; lp0.end = 9000; + z0.loopOverride = lp0; + z0.startPoint = 128; + PerformanceZone z1 = zone("z1", 49, 127); // second zone: no overrides (mixed payload) + s.map.zones.push_back(z0); + s.map.zones.push_back(z1); + + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.channelMode == ChannelMode::Stereo); // envelope field survives + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 2); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && + back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 48); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].rootOverride.has_value() && + *back.map.zones[0].rootOverride == 36); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].loopOverride.has_value() && + back.map.zones[0].loopOverride->hasLoop && + back.map.zones[0].loopOverride->start == 500 && + back.map.zones[0].loopOverride->end == 9000); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].startPoint.has_value() && + *back.map.zones[0].startPoint == 128); + // The override-free second zone stays override-free (the payload framing per zone is intact). + CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" && + !back.map.zones[1].loopOverride.has_value() && + !back.map.zones[1].startPoint.has_value()); +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -1010,6 +1048,7 @@ int main() { testComponentStateV3LiftsToMono(); testComponentStateV1V2LiftToMono(); testComponentStateV4TruncatedModeByte(); + testComponentStateV4StereoWithZoneOverridesRoundTrip(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 9080bd8..8d25484 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -783,6 +783,52 @@ static void testStereoRenderNullBufferIsNoOp() { for (float v : buf) CHECK(approx(v, 0.0, 1e-9)); // untouched } +static void testStereoStartFrameLoopShareOneReadHead() { + // S7 x S11 compose: a STEREO sample with a startFrame AND a sustain loop must read BOTH + // channels from the SAME single read head — one offset, one loop wrap, applied to L and R + // identically (only the sampled value differs). A per-frame L/R ramp that is a fixed offset + // apart (R = L + 0.5) pins the read position on both channels: if the stereo path ever gave + // L and R independent heads, the constant L->R offset would break at the start jump or the + // loop seam. + SampleData s; + s.frames.resize(40); + s.framesR.resize(40); + for (int i = 0; i < 40; ++i) { + s.frames[i] = static_cast(i) * 0.01f; // L: 0.00 .. 0.39 + s.framesR[i] = static_cast(i) * 0.01f + 0.5f; // R: L + 0.5, everywhere + } + s.rootNote = 60; + s.startFrame = 10; // begin BOTH channels at frame 10 + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 30; // loop [20,30): frames 20..29 + CHECK(s.channelCount() == 2); + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio, full velocity, flat gain + + std::vector left(200, 0.f), right(200, 0.f); + eng.render(left.data(), right.data(), 200); + + // First frame: both channels start at frame 10 (L=0.10, R=0.60) — the shared start offset. + CHECK(approx(left[0], 0.10, 1e-4)); + CHECK(approx(right[0], 0.60, 1e-4)); + // The loop sustains the voice indefinitely. + CHECK(eng.activeVoiceCount() == 1); + // At every rendered frame R - L == 0.5 exactly: both channels read the SAME frame index + // (one read head) through the start jump and every loop wrap. A per-channel head drift would + // break this invariant at the seam. + for (std::size_t i = 0; i < left.size(); ++i) { + CHECK(approx(right[i] - left[i], 0.5, 1e-4)); + } + // Once fully inside the loop (start=10 -> reaches loop.start=20 within a handful of unity-ratio + // frames), every L value sits in the loop band [0.20, 0.30): the shared head is sustaining the + // loop region on both channels, never running off the sample end. + for (std::size_t i = 15; i < left.size(); ++i) { + CHECK(left[i] >= 0.20 - 1e-4 && left[i] < 0.30 + 1e-4); + } +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -815,6 +861,7 @@ int main() { testStereoRenderAdvancesLikeMonoRepitch(); testStereoRenderSumsVoicesPerChannel(); testStereoRenderNullBufferIsNoOp(); + testStereoStartFrameLoopShareOneReadHead(); if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); From 70d73ea798e6cc8f30461874cbb9763d1fc37f82 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 23:19:50 -0400 Subject: [PATCH 31/49] fix(vst): replace non-ASCII em-dash/ellipsis in editor string literals with ASCII equivalents to fix mojibake on ANSI DrawTextA path --- src/vst/reasampler_editor.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 7ae8fa9..24a4912 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -72,6 +72,7 @@ const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255); const COLORREF kRgbText = RGB(210, 230, 220); const COLORREF kRgbDim = RGB(140, 150, 146); +// ANSI path -- string literals must stay ASCII until the Phase L type kit lands. void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col, UINT fmt = DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX) { HDC dc = bmp->getDC(); @@ -513,7 +514,7 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from // a bank filter that hides everything. Either way it is the "pick a capture" empty state. const char* msg = samples_.empty() - ? "No captures in this project yet — capture audio into the bank to play it here." + ? "No captures in this project yet - capture audio into the bank to play it here." : "No captures in this bank filter. Choose another bank tab above."; drawTextCentered(bmp, area, msg, kRgbDim); } @@ -572,7 +573,7 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { std::string badge; if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); else if (s.key) badge = *s.key; - else badge = "root —"; + else badge = "root -"; drawText(bmp, badgeR, badge.c_str(), kRgbDim); } @@ -659,7 +660,7 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { alpha, 0); } } else { - drawTextCentered(bmp, waveArea, "(decoding…)", kRgbDim); + drawTextCentered(bmp, waveArea, "(decoding...)", kRgbDim); } // Keyboard strip with the root marker. @@ -726,7 +727,7 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) + - " – " + noteLabel(z.highNote) + " root " + + " - " + noteLabel(z.highNote) + " root " + (z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)")); drawText(bmp, infoR, info.c_str(), kRgbText); } else if (map_.zones.empty()) { From 37cb290cbce5ae3d627dffe8b06e3bf989b3ecb4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 23:09:28 -0400 Subject: [PATCH 32/49] =?UTF-8?q?docs(product):=20S17=20load-capture=20sea?= =?UTF-8?q?m=20=E2=80=94=20settle=20on=20(B)=20component-state=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the decision into the existing phase-s S17 spec: (B) via TrackFX_SetNamedConfigParm vst_chunk; (A) ext-state handshake rejected. Flag the shared component-state blob format as an extension<->instrument coordination dependency. --- CONTEXT.md | 75 +++++++++++++++++++++++++++++++----------------------- PLAN.md | 44 +++++++++++++++++++++----------- 2 files changed, 72 insertions(+), 47 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 890fe52..d8c23f4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1913,27 +1913,40 @@ reads bank index + mapping from `"reasampler"` ext-state via the bridge — §Th but **no entry point for an external actor to say "this fresh instance should play *this specific* capture."** Reading the bank is not the same as being *pointed at one sample*. This wave is the reason to add that seam, and the seam lands **inside the instrument** -(the `phase-s` artifact), not the extension. Two candidate mechanisms — **the choice is a -Phase S open question:** -- **(A) Fresh-instance ext-state handshake.** The extension writes a small "pending load" - hint into `"reasampler"` ext-state keyed to the target track/FX (e.g. the capture id + - a target GUID); a freshly-instantiated ReaSampler 9000 reads it on init via the same - bridge it already uses, claims + clears the hint, and self-selects that capture. **Pro:** - reuses the existing bridge; no new VST3 surface. **Con:** a cross-process handshake with - a claim/clear race to get right; "which instance claims which hint" needs a stable key. -- **(B) VST3 `setState` / preset injection.** The extension builds the instance's - component state (the same blob `getState`/`setState` round-trips) with the capture - pre-selected and injects it right after `TrackFX_AddByName`. **Pro:** deterministic, no - shared-state race, uses the instrument's own persistence format. **Con:** the extension - must know and construct the instrument's state blob format — a tighter cross-artifact - coupling to a format that is itself still being built in Phase S; verify whether the - extension can set an added FX's state through the REAPER API (candidate: - `TrackFX_SetNamedConfigParm` / a state-set path — **not yet confirmed against - `reaper_plugin_functions.h`**). +(the `phase-s` artifact), not the extension. - Lean: **(A)** keeps the artifacts loosely coupled through the one ext-state seam they - already share and avoids the extension hard-coding the instrument's state format — but - it inherits the claim/clear race. Daniel to decide (open question below). +**Mechanism (SETTLED — (B) VST3 component-state injection).** Right after +`TrackFX_AddByName` returns the new FX index, the extension writes the instance's component +state directly — the same blob the instrument's `getChunk`/`setChunk` round-trips — with +the target capture pre-selected. Deterministic, no shared-state race, no cross-process +handshake; it uses the instrument's own persistence format. The state-set path is +`TrackFX_SetNamedConfigParm` — **verified present** in `reaper_plugin_functions.h`: `bool +TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char* +value)`, and the header documents the write-parms `vst_chunk` / `vst_chunk_program` as the +base64-encoded VST-specific chunk. So the injection call is +`TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", )`. + +**Load-bearing caveat — `vst_chunk` is the plugin's own serialized chunk.** `vst_chunk` is +ReaSampler 9000's **own** base64-encoded serialized state (its `getChunk`/`setChunk` +FXP/FXB-style blob), **not** a raw VST3 `IComponent::setState` stream that REAPER +re-marshals into the plugin. The extension therefore has to construct **exactly the +instrument's own state-blob bytes** with the capture pre-selected — REAPER does not +translate a neutral state representation on its behalf. This makes the **component-state +blob format a shared cross-artifact contract** — one that is **still being defined in Phase +S** — and a **coordination dependency between the extension and the instrument:** both must +agree on the exact byte layout that ReaSampler 9000's `setChunk` accepts before either half +is final. The load-capture seam and the component-state persistence work (§Where the toggle +lives / component-state version bumps) share this one blob format. + +**Rejected alternative — (A) fresh-instance ext-state handshake.** The extension writes a +small "pending load" hint into `"reasampler"` ext-state keyed to the target track/FX (a +capture id + a target GUID); a freshly-instantiated ReaSampler 9000 reads it on init via +the bridge it already uses, claims + clears the hint, and self-selects that capture. It +would keep the artifacts loosely coupled through the one ext-state seam they already share +and avoid the extension hard-coding the instrument's state format — but it **loses on the +claim/clear race:** "which instance claims which hint" needs a stable key and a +cross-process handshake to get right, and (B) sidesteps that entirely by writing the state +directly and deterministically. **Coexistence with the OS drag-out (disambiguation contract).** The two OS-vs-internal modes are disambiguated **by pointer location, not a mode toggle** — the user never picks @@ -1962,20 +1975,12 @@ a multi-capture drag over the FX button is either rejected or loads the first). predicate; the M11 `drag_out` unit tests must not regress. **Open questions (Daniel / Phase S team to decide).** -- **Load-capture seam mechanism (A vs. B above).** The single load-bearing Phase S design - choice this wave forces. Lean is (A) for loose coupling; needs Daniel's call. - **Multi-capture drag over an FX button** — reject (only single-capture drags arm `InstrumentDrop`), or load the first / a keymap of all? Tier-0 leans reject-or-first; a multi-capture keymap load is a Tier-1 stretch. - **FX-button hotspot vs. whole TCP.** Does the drop zone have to be the FX button specifically, or is dropping anywhere on the target track's TCP enough (simpler hit resolution, arguably clearer target)? Depends on what the SDK exposes (see must-verify). -- **Numbering vs. the `phase-s` worktree (reconciled on merge to dev).** Authored on dev - as a provisional S7; renumbered to **S17** on merge, since the worktree's authoritative - Phase S set (S7 stereo, S8 ingest, S9 change-detection, S10–S16) took the lower labels. - Drop-and-load stays a distinct integration gesture (drop onto a track's FX button) — - a sibling of but not the same as S8 (ingest into the bank) and S13 (drop-to-load inside - the editor). See PLAN.md §S17. **Must-verify before build (drop-and-load).** - `TrackFX_AddByName` — **verified present** (`reaper_plugin_functions.h`): signature and @@ -1985,10 +1990,16 @@ a multi-capture drag over the FX button is either rejected or loads the first). `reaper_plugin_functions.h`); whether the FX button specifically is addressable vs. the TCP as a whole is an open verification that also decides the "hotspot vs. whole TCP" question. -- **Instance state injection (only if seam mechanism (B) is chosen)** — whether the - extension can set a just-added FX's state via the REAPER API - (`TrackFX_SetNamedConfigParm` or similar) — **not yet confirmed against - `reaper_plugin_functions.h`.** Moot if (A) is chosen. +- **Instance state injection (seam mechanism (B) — SETTLED, load-bearing prerequisite)** — + **verified present** in `reaper_plugin_functions.h`: `bool + TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char* + value)`, with the header documenting `vst_chunk` / `vst_chunk_program` as the + base64-encoded VST-specific chunk write-parms. The injection call is + `TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", )`. The remaining + prerequisite is **not** the API but the **shared component-state blob format**: `vst_chunk` + carries the instrument's *own* serialized chunk (its `setChunk` input), so the extension + must construct exactly ReaSampler 9000's state bytes — the cross-artifact contract still + being defined in Phase S. Blocks the drop half until the blob format is agreed. ## Non-goals / guardrails diff --git a/PLAN.md b/PLAN.md index eff680a..1deb3f1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -956,9 +956,25 @@ The current Phase S spec gives the instrument a *live-state read* seam (it reads index + mapping from `"reasampler"` ext-state via the bridge) but **no entry point for an external actor to say "instantiate playing *this specific* capture."** This wave is the reason to add that seam. It is a Phase S dependency, not extension-side, and must land in -the instrument before drop-and-load's drop half can work end-to-end. See CONTEXT.md -§Phase S for the two candidate seam mechanisms (fresh-instance ext-state handshake vs. -VST3 `setState` preset injection) and the open question on which is chosen. +the instrument before drop-and-load's drop half can work end-to-end. **Mechanism (SETTLED +— (B) VST3 component-state injection):** after `TrackFX_AddByName`, the extension writes the +new instance's component state directly via `TrackFX_SetNamedConfigParm(track, fx, +"vst_chunk", )` — **verified present** in `reaper_plugin_functions.h` (`bool +TrackFX_SetNamedConfigParm(MediaTrack*, int fx, const char* parmname, const char* value)`; +the header documents the `vst_chunk`/`vst_chunk_program` parms as the base64-encoded +VST-specific chunk write-path). Deterministic, no shared-state race, no cross-process +handshake. **Load-bearing caveat:** `vst_chunk` is the plugin's **own** base64-encoded +serialized chunk (its `getChunk`/`setChunk` FXP/FXB-style blob), **not** a raw VST3 +`IComponent::setState` stream REAPER re-marshals — so the extension must construct exactly +ReaSampler 9000's own state-blob bytes with the target capture pre-selected. That makes the +**component-state blob format a shared cross-artifact contract** — still being defined in +Phase S — and a coordination dependency between the extension and the instrument: both must +agree on the exact byte layout the instrument's `setChunk` accepts. Rejected alternative — +**(A) fresh-instance ext-state handshake** (extension writes a "pending load" hint into +`"reasampler"` ext-state, the fresh instance claims + clears it on init): loosely coupled +through the existing bridge, but loses on the claim/clear race — "which instance claims +which hint" needs a stable key and a cross-process handshake to get right. See CONTEXT.md +§Phase S (drop-and-load) for the full seam decision. **Coexistence with the M11 OS drag-out (disambiguation, load-bearing):** the two drag modes are disambiguated by **where the pointer goes**, not by a mode toggle. Inside the panel client rect → `Internal` (unchanged). Left the panel but still over REAPER's own @@ -976,7 +992,9 @@ still works unchanged; no media item is ever inserted into the arrange; the inst holds no private copy (it reads the one authoritative bank). **Depends on:** M11 (`drag_out` gesture machinery — the mode it extends); **Phase S S4** (a loadable, playing ReaSampler 9000 instance must exist) **AND the new load-capture seam -added inside ReaSampler 9000**. Composes with — but is distinct from — **S8** (ingest +added inside ReaSampler 9000 via (B) component-state injection — the shared component-state +blob contract must be defined so the extension can construct it and the instrument's +`setChunk` accept it**. Composes with — but is distinct from — **S8** (ingest through the bank: capture/import/drop *into* the bank) and **S13** (drop-to-load *inside* the editor). S17 is the third integration gesture: drop *onto a track's FX button* to instantiate a player. Gated on the rest of Phase S; the instrument seam is a Phase S @@ -998,21 +1016,17 @@ artifact, not extension-only. capture. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the whole gesture is one Ctrl-Z (mirrors the bank-verb undo discipline). - [ ] **ReaSampler 9000 (Phase S artifact):** add the **load-capture seam** — the entry - point that lets the just-added instance be told which capture to play (mechanism chosen - per the CONTEXT.md open question). This is the cross-artifact half; it lands in the - instrument, not the extension. + point that lets the just-added instance be told which capture to play via **(B) + component-state injection**: the instrument's `getChunk`/`setChunk` must round-trip a + component-state blob whose byte layout is the **shared cross-artifact contract** (defined + in Phase S) that the extension constructs with the target capture pre-selected and writes + via `TrackFX_SetNamedConfigParm(..., "vst_chunk", ...)`. This is the cross-artifact half; + it lands in the instrument, not the extension. **Coordination dependency:** the blob + format must be agreed between extension and instrument before either half is final. - [ ] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across single- and multi-capture payloads; FX-button hit resolution (pure geometry where it can be factored out); M11 OS drag-out and internal bank-to-bank drag both unchanged. -> **Numbering reconciliation (resolved on merge to dev).** This wave was authored on -> **dev** as a provisional **S7** while dev's Phase S ran only S1–S6. On merge with the -> `phase-s` worktree, the worktree's authoritative numbering (S7 stereo, S8 ingest, S9 -> change-detection, S10–S16) took the lower labels, so drop-and-load was renumbered to -> **S17** — the next free label past the worktree's Phase S set. It stays a distinct -> integration gesture (drop onto a track's FX button), a sibling of but not the same as -> S8 (ingest into the bank) and S13 (drop-to-load inside the editor). - ## Phase S — held and optional-forever (noted, not specified) - **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun), full ADSR, per-sample tuning/gain trim, sustain loops. The next depth increment once From 66d47fb4109c679192a58db61c74068af778612b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 23:46:06 -0400 Subject: [PATCH 33/49] feat(S9/S8): bank-generation change-detection + instrument-side assignment reader Extension stamps a monotonic bank_generation counter, bumped at content mutations; the VST3 instrument polls it off-thread on an editor timer and consumes assignment requests, refreshing playback hands-free. --- CMakeLists.txt | 24 ++++- src/actions.cpp | 23 +++- src/actions.h | 10 +- src/bank_panel.cpp | 11 +- src/ext_keys.h | 13 +++ src/ingest.cpp | 7 ++ src/main.cpp | 26 ++++- src/persist.cpp | 21 ++++ src/persist.h | 27 +++++ src/vst/bank_sync.cpp | 70 ++++++++++++ src/vst/bank_sync.h | 105 ++++++++++++++++++ src/vst/reasampler_editor.cpp | 50 +++++++++ src/vst/reasampler_editor.h | 9 ++ src/vst/reasampler_embed.cpp | 39 ++++++- src/vst/reasampler_embed.h | 13 +++ src/vst/reasampler_processor.cpp | 87 ++++++++++++++- src/vst/reasampler_processor.h | 37 +++++++ src/vst/sample_map.cpp | 26 ++++- src/vst/sample_map.h | 41 ++++--- tests/test_bank_sync.cpp | 179 +++++++++++++++++++++++++++++++ tests/test_sample_map.cpp | 56 ++++++++++ 21 files changed, 838 insertions(+), 36 deletions(-) create mode 100644 src/vst/bank_sync.cpp create mode 100644 src/vst/bank_sync.h create mode 100644 tests/test_bank_sync.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a0328a4..580bf2e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -685,6 +685,17 @@ add_library(waveform_view STATIC src/vst/waveform_view.cpp) target_include_directories(waveform_view PUBLIC src/vst src) target_link_libraries(waveform_view PUBLIC editor_geometry peaks) +# bank_sync (Phase S9/S8 reader) — PURE decision logic for the instrument's off-audio-thread +# poll: parse/compare the S9 bank-generation stamp, and the S8 assignment-request CONSUME +# decision (new-and-resolvable-and-target -> apply; unresolvable -> drop-and-mark; non-target +# -> stay eligible). The shell owns the timer cadence + side effects (reloadFromBank, +# setSelectedSampleId, component-state marker); this owns only the yes/no maths, unit-tested +# outside the DAW. Links assignment_request for the decoded AssignmentRequest it consumes. +# NEITHER SDK. +add_library(bank_sync STATIC src/vst/bank_sync.cpp) +target_include_directories(bank_sync PUBLIC src/vst src) +target_link_libraries(bank_sync PUBLIC assignment_request) + add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) @@ -718,6 +729,12 @@ add_executable(waveform_view_tests tests/test_waveform_view.cpp) target_link_libraries(waveform_view_tests PRIVATE waveform_view) add_test(NAME waveform_view_tests COMMAND waveform_view_tests) +# bank_sync (S9/S8 reader): the pure generation-parse + assignment-consume decision. Links +# ONLY bank_sync (+ its assignment_request dep) — NEITHER SDK — the plain-data-boundary proof. +add_executable(bank_sync_tests tests/test_bank_sync.cpp) +target_link_libraries(bank_sync_tests PRIVATE bank_sync) +add_test(NAME bank_sync_tests COMMAND bank_sync_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -770,7 +787,7 @@ add_library(reaper_reasampler MODULE src/card_meta.cpp src/card_drag.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' @@ -901,9 +918,12 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # waveform_view (S11): the pure frame<->pixel marker geometry + zero-crossing snap the # editor's waveform surface draws + hit-tests against; links editor_geometry + peaks # transitively (shared Rect + AudioSample). + # bank_sync (S9/S8 reader): the pure generation-compare + assignment-consume decision the + # processor's off-thread poll runs; links assignment_request transitively (the decoded + # request it consumes) — the same key the extension writes, shared via the pure module. target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal sample_map capture_paths embed_strip app_version capture_browser keyboard_strip - waveform_view) + waveform_view bank_sync) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/actions.cpp b/src/actions.cpp index 1868561..be69618 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -645,7 +645,9 @@ void doBankDelete() { ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); return; } - persistBankOp("ReaSampler: delete bank"); + // S9: bump only when the deleted bank held samples — dropping them changes what a live + // instance referencing one could play. Deleting an EMPTY bank is purely organizational. + persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); } // Evacuate a named bank: move every member back to the pool (index-only, collapse by @@ -666,7 +668,8 @@ void doBankEvacuate() { "destination, not a source).\n"); return; } - persistBankOp("ReaSampler: evacuate bank"); + // S9: evacuate moves members between banks (bank membership changes) -> bump. + persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); } // Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), @@ -752,7 +755,9 @@ void doBankTransferSelected(bool copy) { if (mutated) { const std::string label = std::string("ReaSampler: ") + verb + " sample(s)"; - persistBankOp(label.c_str()); + // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an + // instance may reference) -> bump so assigned instances refresh hands-free. + persistBankOp(label.c_str(), /*bumpGeneration=*/true); } } @@ -794,7 +799,9 @@ void doBankRemoveSelected() { } // No-op guardrail (R-B): open an undo point only if the index actually mutated. - if (removed > 0) persistBankOp("ReaSampler: remove sample(s)"); + // S9: a remove drops a sample from a bank (an instance referencing it must refresh — it + // will resolve to silence, per the stale-id policy) -> bump. + if (removed > 0) persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); } // Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE @@ -884,8 +891,14 @@ void doBankPruneFolder() { // change stands and persists on the user's next save; it just earns no undo point until // there is a project to persist into (undo of an unsaved bank op has nothing to roll // back to anyway). The Begin/End must still be balanced, hence the close-either-way. -void persistBankOp(const char* label) { +void persistBankOp(const char* label, bool bumpGeneration) { Undo_BeginBlock2(nullptr); + // S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the + // fresh generation rides the same ext-state write the persist makes (persistBook() -> + // saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs + // (the caller decides); a pure-organizational verb passes false and leaves the counter be, + // so a rename/activate does not needlessly refresh live instances. + if (bumpGeneration) g_session->bumpBankGeneration(); const bool persisted = persistBook(); if (persisted) Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); diff --git a/src/actions.h b/src/actions.h index 4ca2a74..e9d9bfe 100644 --- a/src/actions.h +++ b/src/actions.h @@ -79,6 +79,14 @@ int bankPruneCommandId(); // must invoke this ONLY after a successful/effective mutation — rejected ops (duplicate // name, un-deletable pool, etc.) must return before reaching here so no empty undo // point is ever opened for a no-op. Defined in actions.cpp alongside persistBook(). -void persistBankOp(const char* label); +// +// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a live +// instance would PLAY — move / copy / remove / evacuate / delete-with-members (a sample left, +// arrived, or dropped out of a bank an instance may reference). Leave it false (the default) +// for a PURELY ORGANIZATIONAL verb — create / rename / activate / reorder — which changes no +// existing (bankId, sampleId) -> content mapping, so no instance need refresh. The bump (when +// requested) happens INSIDE the block, BEFORE persistBook(), so the stamped counter rides the +// same ext-state write and undo captures the pre/post generation with the rest of the blob. +void persistBankOp(const char* label, bool bumpGeneration = false); } // namespace reasampler diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 7eef9ba..146389f 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -2059,7 +2059,10 @@ void doDeleteBank(const std::string& bankId) { // r == 6 (Yes) falls through to a plain delete (drops members). } if (!book()->deleteBank(bankId)) return; - persistBankOp("ReaSampler: delete bank"); + // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then- + // delete path moved/dropped members) — both change what a live instance could play. An + // empty-bank delete is purely organizational, no bump. + persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, // nudge focus to the pool so the selection has a valid home. if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; @@ -2071,7 +2074,7 @@ void doEvacuateBank(const std::string& bankId) { const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; if (!book()->evacuate(bankId)) return; - persistBankOp("ReaSampler: evacuate bank"); + persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); // S9: membership changed invalidatePanel(); } @@ -2115,7 +2118,7 @@ void transferSamples(const std::vector& sampleIds, if (!mutated) return; // nothing changed — no persist, no undo point const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)"; - persistBankOp(label); + persistBankOp(label, /*bumpGeneration=*/true); // S9: bank membership changed // The selection indexed into the source; after a move those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; @@ -2140,7 +2143,7 @@ void removeSamples(const std::vector& sampleIds, ++removed; if (removed == 0) return; // nothing changed — no persist, no undo point - persistBankOp("ReaSampler: remove sample(s)"); + persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); // S9: sample dropped // The selection indexed into the source; after a remove those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; diff --git a/src/ext_keys.h b/src/ext_keys.h index 3db31ef..9f8595e 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -45,6 +45,19 @@ inline constexpr const char* kProjExtTailKey = "tail_setting"; // The per-project minted-GUID identity key. inline constexpr const char* kProjExtGuidKey = "project_guid"; +// The S9 BANK-GENERATION key. The EXTENSION stamps a monotonic decimal counter here that it +// bumps on every bank-content mutation that changes what a live instance would PLAY (capture +// add, re-capture-in-place, sample remove, move/copy affecting banks, ingest import). The VST3 +// instrument READS it off the audio thread on a UI-timer cadence and, when the value differs +// from what it last saw, calls reloadFromBank() so a recapture/ingest refreshes playing +// instances hands-free (the S9 change-detection trigger). WIRE-SHARED (instrument reads it); +// the instrument never WRITES it (the extension owns it, same read-only-over-bank rule as the +// assignment request). Additive to the persist blob — an absent stamp reads as generation 0 +// (a pre-S9 project), and the first bump (>= 1) then reads as a change. FOREVER-STABLE once +// shipped: changing this spelling resets every already-shipped instance's change-detection +// baseline (a one-time spurious reload), so it is fixed like every sibling key. +inline constexpr const char* kProjExtBankGenKey = "bank_generation"; + // The S8 ingest ASSIGNMENT-REQUEST key. The EXTENSION writes an assignment request here // after an ingest-with-assign (arrange capture / Media-Explorer import / drop-onto-panel): // "the active sampler instance should now play THIS sample." The value is the pure diff --git a/src/ingest.cpp b/src/ingest.cpp index 6211bb3..78d1955 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -458,6 +458,10 @@ void doImportFromMediaExplorer() { // zero flag so REAPER discards the undo entry (the house pattern from actions.cpp). if (r.added) { Undo_BeginBlock2(nullptr); + // S9: an ingest import adds a sample to the active bank -> bump inside the block so + // the stamped generation refreshes the assigned instance hands-free (and undo rolls + // the generation back with the banks/assign_request keys). + g_session->bumpBankGeneration(); const bool persisted = g_session->saveToActiveProject(); // Assign request inside the same block: undo rolls back both keys together. ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId); @@ -531,6 +535,9 @@ void ingestDroppedFiles(const std::vector& absolutePaths) { if (!firstAssignId.empty()) { if (importedNew > 0) { Undo_BeginBlock2(nullptr); + // S9: one coalesced bump for the whole drop (>=1 new sample landed) inside the + // block so the generation refreshes the assigned instance and undo rolls it back. + g_session->bumpBankGeneration(); const bool persisted = g_session->saveToActiveProject(); // Assign inside the block: undo restores both keys atomically. ingestAssignActiveInstance(firstAssignBank, firstAssignId); diff --git a/src/main.cpp b/src/main.cpp index eeecdb3..668eba4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -216,7 +216,10 @@ static void CommitRealtimeResult(const reasampler::CaptureResult& res) // index AddResult — even a hash-collapse still WROTE a file the tool owns, and the // manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index). g_session.owned().add(res.sample.relativePath); - g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp) + // S9: a capture add changes what a live instance could play (a new sample landed in the + // active bank) -> bump before the persist so the stamped generation refreshes instances. + g_session.bumpBankGeneration(); + g_session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp) } // Advance any in-flight realtime capture one tick. Cheap when none is running (a @@ -852,6 +855,9 @@ static std::string RunCapture(const reasampler::CaptureActionDef& def) // `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and // travels with the .rpp. saveToActiveProject also clears the retired legacy key and // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys. + // S9: a capture add is a bank-content change -> bump before the persist so an assigned + // live instance refreshes hands-free (the S8 capture+assign path builds on this). + g_session.bumpBankGeneration(); g_session.saveToActiveProject(); // Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne @@ -1042,8 +1048,12 @@ static void RunBatchCaptureItems() } // selGuard restores the original selection here, on every path // Persist ONCE for the whole batch (one ext-state write) — only if something landed. - if (anyAdded) + // S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample, + // so a single increment past the last-seen value is enough to trigger one instance reload. + if (anyAdded) { + g_session.bumpBankGeneration(); g_session.saveToActiveProject(); + } ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); } @@ -1159,8 +1169,11 @@ static void RunBatchCaptureRazor() } } // selGuard restores the original track selection here, on every path - if (anyAdded) + // S9: one coalesced bump for the whole razor batch (see the item-batch note above). + if (anyAdded) { + g_session.bumpBankGeneration(); g_session.saveToActiveProject(); + } ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str()); } @@ -1344,7 +1357,12 @@ static void RunRecaptureFromSource() // Record the regenerated file in the owned manifest (a new file the tool wrote); // the superseded old file becomes an orphan reclaimed by Phase R prune. g_session.owned().add(updated.relativePath); - const bool persisted = g_session.saveToActiveProject(); // book + manifest + MarkProjectDirty + // S9: re-capture-in-place regenerates the SAME id's audio — the exact case the + // hands-free refresh exists for (an instance referencing this id keeps playing the + // OLD audio until it reloads). Bump inside the undo block so undo rolls back the + // generation with the rest of the blob. + g_session.bumpBankGeneration(); + const bool persisted = g_session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", persisted ? UNDO_STATE_MISCCFG : 0); } diff --git a/src/persist.cpp b/src/persist.cpp index 74fe848..e1f6e09 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -96,6 +96,7 @@ #include "app_version.h" #include "capture_paths.h" #include "prune_reconcile.h" +#include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects @@ -252,6 +253,16 @@ bool ReaSamplerSession::saveToActiveProject() { SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtVersionKey, stampVersion().c_str()); + // S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME + // seam so the counter and MarkProjectDirty stay paired. The value is whatever + // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so + // every content mutation's own save carries the fresh generation the instrument reads. The + // format is the SHARED pure encoder (vst::formatBankGeneration) so writer and reader agree + // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtBankGenKey, + vst::formatBankGeneration(bankGeneration_).c_str()); + MarkProjectDirty(static_cast(proj)); return true; } @@ -580,6 +591,16 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi kProjExtVersionKey) : std::string{}); + // S9: recover the bank-generation counter on EVERY load path (peer-symmetry with + // writingVersion_/tail_/view_ above), so it continues monotonic from the stored value + // rather than resetting to 0 on reopen — a next bump then reads > the stored value. A + // project switch reads THAT project's counter, not the previous one's; an absent/malformed + // stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0. + bankGeneration_ = vst::parseBankGeneration( + proj ? getProjExtStateString(static_cast(proj), projExtNamespace(), + kProjExtBankGenKey) + : std::string{}); + if (!proj) { book_ = BankBook{}; return; diff --git a/src/persist.h b/src/persist.h index 723e483..6b67f56 100644 --- a/src/persist.h +++ b/src/persist.h @@ -17,6 +17,7 @@ // calls live in persist.cpp. It depends on bank_model (pure) for JSON round-trip // and capture_paths (pure) for the path arithmetic it drives. +#include #include #include "app_version.h" @@ -149,6 +150,24 @@ public: // reason about the origin build without re-reading ext state. const WritingVersion& writingVersion() const { return writingVersion_; } + // The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic + // per project: recovered on load (so it continues from the stored value rather than + // resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on + // every saveToActiveProject(). Exposed const for the writer sites to read/log. + std::int64_t bankGeneration() const { return bankGeneration_; } + + // Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes + // what a live instance would PLAY (capture add, re-capture-in-place, sample remove, + // move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create / + // rename / activate / reorder a bank), which change no existing (bankId, sampleId) -> + // content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call + // the same mutation already makes (the counter rides the persist blob, so there is no + // separate write). In-memory only here — cheap and REAPER-free; the persist is the write. + // Over-bumping is safe (a reload that finds unchanged content atomically re-installs the + // same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err + // toward bumping. Idempotent per logical op — call once per mutation, before the persist. + void bumpBankGeneration() { ++bankGeneration_; } + // Serialize the current book (under the `banks` key), view model, and tail setting // to the active project's ext state (namespace "reasampler"), and clear the retired // legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys. @@ -282,6 +301,14 @@ private: // the previous project's stamp. Read-only to consumers via writingVersion(). WritingVersion writingVersion_; + // The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path + // from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it + // continues monotonic from the persisted value across reopen and resets cleanly on a + // project switch (a different project's counter, not the previous project's). bumped by + // bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject(). + // Default 0 for an unsaved / never-loaded / pre-S9 session. + std::int64_t bankGeneration_ = 0; + // The project identity last observed by poll(), used to detect load/Save-As. // The GUID is the PRIMARY signal (a different stored GUID = a different project // of record = Load, immune to pointer recycling). The pointer disambiguates the diff --git a/src/vst/bank_sync.cpp b/src/vst/bank_sync.cpp new file mode 100644 index 0000000..8ca894b --- /dev/null +++ b/src/vst/bank_sync.cpp @@ -0,0 +1,70 @@ +// bank_sync.cpp — see bank_sync.h. Pure; standard library only. + +#include "bank_sync.h" + +#include +#include +#include + +namespace reasampler::vst { + +std::int64_t parseBankGeneration(const std::string& raw) { + if (raw.empty()) return kBankGenerationAbsent; + + // Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises. + // A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max + // all reject to the absent default (0). Manual accumulation with an overflow guard so a + // pathologically long digit run can never wrap into a bogus small value. + std::int64_t value = 0; + constexpr std::int64_t kMax = std::numeric_limits::max(); + for (const char c : raw) { + if (c < '0' || c > '9') return kBankGenerationAbsent; // any non-digit -> reject whole + const int digit = c - '0'; + // Guard value*10 + digit against overflow before performing it. + if (value > (kMax - digit) / 10) return kBankGenerationAbsent; // would overflow -> reject + value = value * 10 + digit; + } + return value; +} + +std::string formatBankGeneration(std::int64_t generation) { + // Non-negative decimal; a negative (should never be produced by the writer) formats as + // its std::to_string form and would parse back to 0, so the writer's monotonic counter + // stays in the >= 0 domain by construction. + return std::to_string(generation); +} + +bool bankGenerationChanged(std::int64_t seen, std::int64_t current) { + return current != seen; +} + +AssignConsumeDecision consumeDecision(const std::optional& request, + std::int64_t lastConsumed, bool resolves, + bool isFocusedTarget) { + AssignConsumeDecision d; + d.consumedGeneration = lastConsumed; // default: nothing changes + + // Rule 1: no request, or not newer than what we already consumed -> nothing new. + if (!request) return d; + if (request->generation <= lastConsumed) return d; + + // Rule 2: a new request, but this instance is not the target -> do not act, do NOT + // advance the marker (stay eligible if focus later lands here). No thundering herd. + if (!isFocusedTarget) return d; + + // The request is new AND we are the target: it will be consumed-as-seen either way, so + // advance the marker to its generation so it is never re-evaluated. + d.consumedGeneration = request->generation; + + // Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker + // advanced above, but no selection change. + if (!resolves) return d; + + // Rule 4: new, target, resolvable -> apply the selection. + d.apply = true; + d.bankId = request->bankId; + d.sampleId = request->sampleId; + return d; +} + +} // namespace reasampler::vst diff --git a/src/vst/bank_sync.h b/src/vst/bank_sync.h new file mode 100644 index 0000000..acf49ad --- /dev/null +++ b/src/vst/bank_sync.h @@ -0,0 +1,105 @@ +#pragma once +// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the +// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO +// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of +// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a +// host-facing shell. +// +// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state +// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and +// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string +// read crosses the bridge in the shell; every DECISION after — parse the generation +// stamp, decide whether it differs from what we last saw, decide whether a decoded +// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here. +// +// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side +// effects (reloadFromBank, setSelectedSampleId); this module owns only the yes/no maths so +// the reader's rules are provable without a host. assignment_request.h owns the WIRE format +// (encode/decode); this module owns the CONSUME decision layered over a decoded request. + +#include +#include +#include + +#include "assignment_request.h" // AssignmentRequest (the decoded request this consumes) + +namespace reasampler::vst { + +// The S9 bank-generation "generation 0 = never stamped" default. A project saved before +// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value +// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the +// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation). +inline constexpr std::int64_t kBankGenerationAbsent = 0; + +// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a +// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative +// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp +// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never +// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing +// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored +// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT +// poll once the value is clean). +std::int64_t parseBankGeneration(const std::string& raw); + +// Format a bank-generation counter for the ext-state stamp. The inverse of +// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so +// the stamp is byte-stable across writes of the same value. +std::string formatBankGeneration(std::int64_t generation); + +// Has the bank generation changed since the reader last saw `seen`? True when `current` +// differs from `seen` — the reader then triggers a reload. Any difference counts (not just +// an increase): the writer is monotonic, but a project switch or reload can legitimately +// lower the value, and the reader should re-read the bank in that case too. `seen` starts at +// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 / +// first-bump refresh the spec requires). +bool bankGenerationChanged(std::int64_t seen, std::int64_t current); + +// The verdict of the S8 assignment-request consume decision (below). A pure value the +// processor shell acts on: apply the selection (or not) and advance the consumed marker +// (or not). Distinct booleans because the two are NOT the same event — a request may be +// consumed-as-seen (marker advances) without being applied (it named an unresolvable +// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it +// every poll. +struct AssignConsumeDecision { + bool apply = false; // set this instance's selection to (bankId, sampleId) + reload + std::string bankId; // the request's bank (valid only when apply) + std::string sampleId; // the request's sample (valid only when apply) + std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new) +}; + +// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader). +// +// `request` — the decoded assignment request (nullopt when the assign_request key +// is absent / malformed — nothing pending). +// `lastConsumed` — the generation this instance last consumed (persisted in component +// state so a re-open does not re-apply a request the user already got, +// then manually changed away from). Defaults to 0 for a fresh instance. +// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank +// sample RIGHT NOW (the shell computed this against the live bank blob). +// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's +// thundering-herd policy (e.g. only the focused-editor instance applies). +// The shell passes true when this instance should act; false suppresses +// consumption entirely so a non-target instance neither applies nor +// advances its marker (it stays eligible if it later becomes the target). +// +// RULES (all pure, order matters): +// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not +// apply, marker unchanged. (Covers the re-open case: the persisted marker == the +// request's generation, so it is not re-applied.) +// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not +// apply and do NOT advance the marker — a non-target instance must stay able to consume +// the request if focus later lands on it. (No thundering herd: only the target acts.) +// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT +// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply, +// but DO advance the marker to the request's generation so a stale/unresolvable request +// is consumed-as-seen and never re-evaluated (no error state, no selection change). +// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and +// advance the marker to the request's generation. +// +// The shell then: if apply, setSelectedSampleId + reloadFromBank; always persist +// consumedGeneration into component state when it advanced. +AssignConsumeDecision consumeDecision(const std::optional& request, + std::int64_t lastConsumed, bool resolves, + bool isFocusedTarget); + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 7ae8fa9..526bb2d 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -38,6 +38,14 @@ namespace { #ifdef _WIN32 constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; +// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread +// timer: responsive enough that a recapture/ingest/assign refreshes "within a bounded cadence" +// (the S9 verify criterion) yet cheap — three small ext-state reads per tick, coalescing many +// bumps between ticks into one reload. 500 ms is a deliberate build-time residual: fast enough +// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero). +constexpr UINT_PTR kSyncTimerId = 1; +constexpr UINT kSyncTimerIntervalMs = 500; + // Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry // is in capture_browser / keyboard_strip). The title band names the plugin + a live // readout; the toggle band carries the Browser/Zones switch; the setup band (single- @@ -173,6 +181,35 @@ void ReaSamplerEditor::rebuildVisible() { } } +#ifdef _WIN32 +// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared +// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes +// Windows the only build target, but the TU must still compile elsewhere). +void ReaSamplerEditor::onSyncTimer() { + // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the + // processor (off the audio thread — the poll itself never touches process()). NEVER while a + // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the + // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. + if (!processor_) return; + if (drag_ != DragKind::kNone) return; // defer past the in-flight edit + + // An open editor marks THIS instance the focused assignment target (the thundering-herd + // policy — only an editor-open instance applies a pending assign; see the handoff). Pass + // true so this instance consumes the request; instances with no editor open do not poll at + // all (the timer is bound to the child window), so they never contend for the request. + const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); + + // Re-snapshot the editor's own view only when something changed (a reload from a bank + // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the + // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM + // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. + if (r.reloaded || r.applied) { + refreshFromBank(); + invalidate(); + } +} +#endif // _WIN32 + void ReaSamplerEditor::commitAndReload() { // UI thread only. Publish the edited selection + zones to the processor, then rebuild // the instrument off the audio thread (reloadFromBank bakes them into the live Keymap). @@ -372,11 +409,21 @@ void ReaSamplerEditor::attachedToParent() { r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); if (childHwnd_) { SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); + // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's + // lifetime — created here, killed in removedFromParent — so an instance whose editor + // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). + SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr); + // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor + // was closed) or a bank change applies the instant the editor opens, rather than waiting + // up to one timer interval. refreshFromBank above already primed the view; this folds in + // any pending assign/generation so the just-opened editor shows the assigned capture. + onSyncTimer(); } } void ReaSamplerEditor::removedFromParent() { if (childHwnd_) { + KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away DestroyWindow(childHwnd_); childHwnd_ = nullptr; } @@ -1063,6 +1110,9 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, self->invalidate(); } return 0; + case WM_TIMER: + if (self && wParam == kSyncTimerId) self->onSyncTimer(); + return 0; case WM_ERASEBKGND: return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase default: diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index bf84b71..6959bd3 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -88,6 +88,15 @@ private: void onMouseMove(int x, int y); void onMouseUp(int x, int y); + // The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the + // audio thread). Polls the processor's bank-sync (generation change -> hands-free reload; + // a new assignment request -> apply as this instance's selection) and, when anything + // changed, re-snapshots the editor's own view (refreshFromBank) + repaints so the browser / + // setup surface reflect the new bank. An open editor means THIS instance is the focused + // assignment target (the thundering-herd policy — see the handoff), so it passes true. + // Suppressed WHILE A DRAG IS IN FLIGHT so a mid-drag reload does not yank the edit surface. + void onSyncTimer(); + static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); void invalidate(); diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp index ca4b5b9..51e62f7 100644 --- a/src/vst/reasampler_embed.cpp +++ b/src/vst/reasampler_embed.cpp @@ -7,9 +7,10 @@ #include #include +#include "bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh) #include "editor_geometry.h" // Rect (shared with embed_strip) #include "embed_strip.h" // the pure strip layout + hit-test -#include "ext_keys.h" // kProjExtBanksKey +#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey #include "reaper_bridge.h" #include "reasampler_processor.h" @@ -91,6 +92,36 @@ void ReaSamplerEmbed::refresh() { if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; } +void ReaSamplerEmbed::maybeRefresh() { + if (!processor_) { refresh(); return; } // clears state; cheap + + // The performance map is a cheap in-process accessor (mutex + copy), and the editor may + // have edited zones with NO bank-content change — always re-snapshot it so a zone edit + // reflects immediately. + map_ = processor_->performanceMap(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; + + // The EXPENSIVE part is the bank-blob bridge read (samples_). Gate it on the S9 bank- + // generation stamp (a small ext-state read): only re-read the bank when the generation + // changed since the last paint (a recapture / ingest / remove), or on the first paint + // (lastSeenBankGeneration_ == -1). A pre-S9 project reads generation 0; the first paint + // folds it and subsequent idle paints skip the bank read entirely. + std::int64_t currentGen = lastSeenBankGeneration_; + if (auto rawGen = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBankGenKey)) { + currentGen = parseBankGeneration(*rawGen); + } else if (lastSeenBankGeneration_ < 0) { + currentGen = 0; // unprimed + no stamp (pre-S9): treat as generation 0 for the first read + } + + if (lastSeenBankGeneration_ < 0 || currentGen != lastSeenBankGeneration_) { + auto banks = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banks ? listSamples(*banks) : std::vector{}; + lastSeenBankGeneration_ = currentGen; + } +} + TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) { switch (msg) { case REAPER_FXEMBED_WM_IS_SUPPORTED: @@ -141,8 +172,10 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { if (w <= 0 || h <= 0) return false; // Re-read live state each paint (UI thread) so the strip reflects keymap edits + bank - // changes without its own timer — REAPER repaints the embed surface on its cadence. - refresh(); + // changes without its own timer — REAPER repaints the embed surface on its cadence. S9 + // dirty-guard: maybeRefresh does the EXPENSIVE bank-blob read only when the bank generation + // changed (the flagged S6 follow-up), always refreshing the cheap performance map. + maybeRefresh(); // REAPER hands us its own bitmap sized to the embed area; draw directly into it (unlike // the editor, which owns a LICE_SysBitmap and BitBlt's). Origin is the bitmap's (0,0). diff --git a/src/vst/reasampler_embed.h b/src/vst/reasampler_embed.h index 8db5d0b..33a017c 100644 --- a/src/vst/reasampler_embed.h +++ b/src/vst/reasampler_embed.h @@ -32,6 +32,7 @@ #pragma once +#include #include #include @@ -86,7 +87,19 @@ private: // as the editor's refreshSampleList does (bridge read + processor accessors, UI thread). void refresh(); + // The S9 dirty-guard over refresh() (the S6 flagged follow-up): read the cheap bank- + // generation stamp; do the EXPENSIVE bank-blob bridge read (refresh()) only when the + // generation changed since the last paint (or on the first paint) — the strip re-read + // per paint was wasteful now that a generation counter exists. The performance map (a + // cheap in-process accessor, edited by the editor independently of bank content) is + // ALWAYS refreshed so a zone edit still reflects immediately. UI thread only. + void maybeRefresh(); + ReaSamplerProcessor* processor_ = nullptr; + // The bank generation last folded into samples_ (S9 dirty-guard). -1 forces the first + // maybeRefresh() to do a full read (no generation can be negative — parseBankGeneration + // yields >= 0 — so -1 is an "unprimed" sentinel distinct from a real generation 0). + std::int64_t lastSeenBankGeneration_ = -1; // Snapshotted for the current paint (refreshed each paint off the audio thread). std::vector samples_; PerformanceMap map_; diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 7d65188..f9903f8 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -17,8 +17,10 @@ #include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr) +#include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) +#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "ext_keys.h" // kProjExtBanksKey (shared wire contract) +#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) #include "reasampler_editor.h" #include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) #include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser @@ -184,6 +186,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { const ComponentState cs = deserializeComponentState(bytes); setSelectedSampleId(cs.selectionId); setPerformanceMap(cs.map); + // S8: restore the last-consumed assignment generation so a re-open does not re-apply a + // stale assign_request (the user may have manually changed the selection after the assign). + { + std::lock_guard lock(assignMarkerMutex_); + lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; + } // Restore the S7 channel mode and point the output bus at its arrangement so a reopened // project comes back in the saved mode. setState runs before the host queries bus info, so // seeding the arrangement here (rather than re-negotiating) is enough — no restartComponent. @@ -208,6 +216,10 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { state_out.selectionId = selectedSampleId(); state_out.map = performanceMap(); state_out.channelMode = channelMode(); // S7: persist the per-instance mono/stereo mode + { + std::lock_guard lock(assignMarkerMutex_); + state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker + } const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), @@ -392,6 +404,79 @@ std::string ReaSamplerProcessor::reloadFromBank() { return resolvedId; } +ReaSamplerProcessor::BankSyncResult +ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { + // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call + // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER + // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. + BankSyncResult result; + + // --- S8: assignment-request consume FIRST ------------------------------------- + // Decode the pending assignment request (nullopt when absent/malformed). Resolve its + // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when + // the sampleId names an existing sample (the reader requirement — an unresolvable pair is + // dropped). Then run the pure consume decision against this instance's persisted marker. + std::optional request; + if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { + request = decodeAssignmentRequest(*raw); + } + + bool resolves = false; + if (request) { + // Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request + // whose sample was rolled back by an extension undo resolves to nullopt -> dropped). + if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { + resolves = selectSample(*banksJson, request->sampleId).has_value(); + } + } + + std::int64_t lastConsumed = 0; + { + std::lock_guard lock(assignMarkerMutex_); + lastConsumed = lastConsumedAssignGeneration_; + } + const AssignConsumeDecision decision = + consumeDecision(request, lastConsumed, resolves, isFocusedTarget); + + // Advance the persisted consumed marker whenever the decision consumed the request + // (applied OR dropped-as-seen). getState will persist it on the next project save so a + // re-open does not re-apply. A non-target instance leaves the marker (decision returns it + // unchanged) so it stays eligible if focus later lands here. + if (decision.consumedGeneration != lastConsumed) { + std::lock_guard lock(assignMarkerMutex_); + lastConsumedAssignGeneration_ = decision.consumedGeneration; + } + + if (decision.apply) { + // Apply the assignment as this instance's own selection (the same path a user card-pick + // takes) — the instrument updates its OWN state, never the bank. reloadFromBank below + // rebuilds against the new selection, so skip a redundant reload here. + setSelectedSampleId(decision.sampleId); + result.applied = true; + } + + // --- S9: bank-generation change-detection ------------------------------------- + // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll + // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — setState + // already loaded the current bank, so a redundant reload on open would only churn. A later + // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the + // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced). + std::int64_t currentGen = kBankGenerationAbsent; + if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { + currentGen = parseBankGeneration(*rawGen); + } + const bool firstPoll = (lastSeenBankGeneration_ < 0); + const bool genChanged = + !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); + lastSeenBankGeneration_ = currentGen; + + if (genChanged || result.applied) { + reloadFromBank(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) + result.reloaded = genChanged; // report S9 vs S8 distinctly for the editor's reaction + } + return result; +} + tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the // whole block (a single atomic acquire), then publish inst->installedAt so the off- diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index a16e283..0b1dde0 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -122,6 +122,27 @@ public: // resolved selection id ("" if nothing was loaded) for the editor to reflect. std::string reloadFromBank(); + // The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor + // can react (repaint / re-snapshot its own view) only when something actually changed. + struct BankSyncResult { + bool reloaded = false; // the bank generation changed -> reloadFromBank ran + bool applied = false; // a new assignment request was applied -> selection changed + }; + + // Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF + // THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). Semantics: + // * S9: if the bank generation differs from what we last saw, call reloadFromBank() so a + // recapture/ingest refreshes playback hands-free (atomic swap, glitch-free). + // * S8: if a NEW (generation > last consumed) assignment request names a resolvable + // sample AND this instance is the target (isFocusedTarget), apply it as the selection + // and reload; an unresolvable request is DROPPED silently (marker advanced, no change); + // a non-target instance neither applies nor advances its marker. + // The consumed marker advances in component state (marked dirty via the host handler) so a + // re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input + // (the editor passes true only for the instance whose editor is open — see the handoff). + // Idempotent on an idle tick (generation unchanged + no new request -> no work). + BankSyncResult pollBankSync(bool isFocusedTarget); + // The bridge, for the editor's live-state readout + sample list. Owned here; the // editor borrows it (outlives the editor). ReaperBridge& bridge() { return bridge_; } @@ -210,6 +231,22 @@ private: std::mutex channelModeMutex_; ChannelMode channelMode_ = ChannelMode::Mono; + // The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in + // component state (v5) so a re-open does not re-apply a request the user already got and + // then changed away from. Written by pollBankSync (UI/timer thread) and getState; read by + // pollBankSync + getState; seeded by setState. Guarded against a getState/poll race. NEVER + // read on the audio thread. Default 0 -> a genuinely new first assign (gen >= 1) applies. + std::mutex assignMarkerMutex_; + std::int64_t lastConsumedAssignGeneration_ = 0; + + // The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync + // is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a + // -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the + // FIRST poll after an editor open BASELINES the seen value without a redundant reload (setState + // already loaded the current bank); a subsequent generation CHANGE then drives the reload. + // NOT read on the audio thread. + std::int64_t lastSeenBankGeneration_ = -1; + // Latched from setupProcessing so setActive/reload can size against it. Read // off-thread only. double sampleRate_ = 44100.0; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 64c95b8..1f27924 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -406,6 +406,10 @@ std::vector serializeComponentState(const ComponentState& state) { putU32le(out, kComponentStateVersion); // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0); + // v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE + // two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that + // stops at the mode byte is a strict prefix (see the v4 lift below). + putU64le(out, asU64(state.lastConsumedAssignGeneration)); // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — // unlike the v1 selection blob where the id ran to end-of-stream). putU32le(out, static_cast(state.selectionId.size())); @@ -447,15 +451,31 @@ ComponentState deserializeComponentState(const std::vector& bytes) out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty readZonesPayload(r, out.map); - return out; // channelMode stays Mono (pre-S7) + return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9) + } + // BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker): + // mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration + // defaults to 0, so a first assign still applies for a pre-marker instance. + if (version == kSelectionZonesModeV4Version) { + const std::uint8_t modeByte = r.u8(); + if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) + out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; + const std::uint32_t idLen = r.u32(); + out.selectionId = r.str(idLen); + if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty + readZonesPayload(r, out.map); + return out; // marker stays 0 (pre-S8/S9 reader) } if (version != kComponentStateVersion) return out; // unknown -> empty - // v4: the channel-mode byte precedes the v3 body. A non-{0,1} byte is treated as mono - // (conservative default) rather than rejected — a corrupt mode never silences the instance. + // v5: the channel-mode byte, then the 8-byte consumed-assignment marker, precede the v3 + // body. A non-{0,1} mode byte is treated as mono (conservative default) rather than + // rejected — a corrupt mode never silences the instance. const std::uint8_t modeByte = r.u8(); if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; + out.lastConsumedAssignGeneration = r.i64(); + if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds) const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index f527135..e05b5f2 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -285,25 +285,40 @@ PerformanceMap deserializePerformance(const std::vector& bytes); // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // state), never auto-playing sample #1. // -// Format (v4): 4-byte LE version tag (== 4), then a 1-byte channel-mode field (0 = mono, -// 1 = stereo), then a 4-byte LE selection-id length + id bytes, then the v2 zones payload -// (4-byte LE zone count + per-zone records, identical to serializePerformance's body). The -// channel-mode field is the ONLY v4 addition over v3 — the envelope grew a field, the zones -// payload is untouched (a PARALLEL track owns zone-record extension under the map's own -// versioning). BACK-COMPAT on read (every older blob lifts to channelMode = MONO, preserving -// current behavior for already-saved instances): -// * v4 blob -> {channelMode, selectionId, zones} parsed directly. -// * v3 blob -> {mono, selectionId, zones}: pre-S7 had no channel mode. -// * v2 blob -> {mono, "", zones}: an S5 instance had zones but no separate selection. -// * v1 blob -> {mono, id, one full-keyboard zone}: the S4 single-selection lift. -// * empty/unknown -> {mono, "", no zones}: EMPTY (the S10 silent empty state). +// Format (v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono, +// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), +// then a 4-byte LE selection-id length + id bytes, then the v2 zones payload (4-byte LE zone +// count + per-zone records, identical to serializePerformance's body). The 8-byte marker is +// the ONLY v5 addition over v4 — the envelope grew a field, the zones payload is untouched +// (a PARALLEL track owns zone-record extension under the map's own versioning). BACK-COMPAT on +// read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0, +// preserving current behavior for already-saved instances): +// * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct. +// * v4 blob -> {channelMode, 0, selectionId, zones}: pre-S8/S9 reader (no marker). +// * v3 blob -> {mono, 0, selectionId, zones}: pre-S7 had no channel mode. +// * v2 blob -> {mono, 0, "", zones}: an S5 instance had zones but no separate selection. +// * v1 blob -> {mono, 0, id, one full-keyboard zone}: the S4 single-selection lift. +// * empty/unknown -> {mono, 0, "", no zones}: EMPTY (the S10 silent empty state). +// +// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is +// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user +// already got and then manually changed away from: on re-open the instance re-reads the pending +// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see +// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign +// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the +// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. struct ComponentState { std::string selectionId; // the single-capture pick; "" = no pick PerformanceMap map; // the opt-in zones; empty = no zones ChannelMode channelMode = ChannelMode::Mono; // S7 output mode; default mono (D-E) + std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed }; -inline constexpr std::uint32_t kComponentStateVersion = 4; +inline constexpr std::uint32_t kComponentStateVersion = 5; + +// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed +// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. +inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4; // The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named // constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. diff --git a/tests/test_bank_sync.cpp b/tests/test_bank_sync.cpp new file mode 100644 index 0000000..f18af93 --- /dev/null +++ b/tests/test_bank_sync.cpp @@ -0,0 +1,179 @@ +// Standalone tests for reasampler::vst::bank_sync — no REAPER, no VST3, no framework. +// The S9 bank-generation change-detection + the S8 assignment-request consume DECISION +// (the yes/no maths the instrument's off-audio-thread poll runs). The shell owns the +// cadence + side effects; this proves the decision rules without a host. +// +// Covers: parseBankGeneration (absent/malformed/overflow/negative/valid whole-string), +// formatBankGeneration round-trip, bankGenerationChanged, and every consumeDecision rule +// (no request / not-newer / non-target / unresolvable-drop / apply), asserting both the +// apply flag AND the advanced-marker value so a stale request is never re-evaluated. + +#include "../src/vst/bank_sync.h" + +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- parseBankGeneration ----------------------------------------------------- + +static void testParseAbsentAndMalformed() { + // Absent / empty -> generation 0 (the pre-S9 default; a project with no stamp). + CHECK(parseBankGeneration("") == 0); + CHECK(parseBankGeneration("") == kBankGenerationAbsent); + // Malformed -> 0, never a crash, never a partial value. + CHECK(parseBankGeneration("abc") == 0); + CHECK(parseBankGeneration("12x") == 0); // trailing garbage rejects whole + CHECK(parseBankGeneration("x12") == 0); // leading garbage + CHECK(parseBankGeneration("1 2") == 0); // embedded space + CHECK(parseBankGeneration("+5") == 0); // sign rejected + CHECK(parseBankGeneration("-5") == 0); // negative rejected + CHECK(parseBankGeneration(" 5") == 0); // leading space + CHECK(parseBankGeneration("5.0") == 0); // decimal point +} + +static void testParseValid() { + CHECK(parseBankGeneration("0") == 0); + CHECK(parseBankGeneration("1") == 1); + CHECK(parseBankGeneration("42") == 42); + CHECK(parseBankGeneration("00042") == 42); // leading zeros are still digits -> 42 + CHECK(parseBankGeneration("9007199254740993") == 9007199254740993LL); // > 2^53 +} + +static void testParseOverflow() { + // A 19-digit int64-max is fine; anything past it rejects to 0 (never wraps). + CHECK(parseBankGeneration("9223372036854775807") == 9223372036854775807LL); // INT64_MAX + CHECK(parseBankGeneration("9223372036854775808") == 0); // INT64_MAX + 1 -> reject + CHECK(parseBankGeneration("99999999999999999999") == 0); // 20 nines -> reject +} + +static void testFormatRoundTrip() { + CHECK(formatBankGeneration(0) == "0"); + CHECK(formatBankGeneration(1) == "1"); + CHECK(formatBankGeneration(123456789) == "123456789"); + // Round-trips: format then parse yields the original for the valid domain. + for (std::int64_t g : {std::int64_t{0}, std::int64_t{1}, std::int64_t{7}, + std::int64_t{9007199254740993LL}}) { + CHECK(parseBankGeneration(formatBankGeneration(g)) == g); + } +} + +// --- bankGenerationChanged --------------------------------------------------- + +static void testGenerationChanged() { + CHECK(!bankGenerationChanged(0, 0)); // pre-S9 idle: no stamp seen, no stamp now + CHECK(bankGenerationChanged(0, 1)); // first bump after a pre-S9 baseline -> change + CHECK(bankGenerationChanged(5, 6)); // normal increment + CHECK(!bankGenerationChanged(6, 6)); // idle poll (coalesced): no change + CHECK(bankGenerationChanged(6, 3)); // a project switch/reload can lower it -> change +} + +// --- consumeDecision --------------------------------------------------------- + +static AssignmentRequest makeReq(const std::string& bank, const std::string& sample, + std::int64_t gen) { + AssignmentRequest r; + r.bankId = bank; + r.sampleId = sample; + r.generation = gen; + return r; +} + +// Rule 1a: no pending request -> nothing to do, marker unchanged. +static void testNoRequest() { + const auto d = consumeDecision(std::nullopt, /*lastConsumed*/ 5, + /*resolves*/ true, /*isFocusedTarget*/ true); + CHECK(!d.apply); + CHECK(d.consumedGeneration == 5); // marker held +} + +// Rule 1b: a request no newer than what we already consumed (re-open case) -> no re-apply. +static void testNotNewerNotReapplied() { + // The persisted marker equals the request generation: the user already got this assign, + // possibly changed away from it. It MUST NOT re-apply on re-open. + const auto same = consumeDecision(makeReq("b", "s", 100), 100, true, true); + CHECK(!same.apply); + CHECK(same.consumedGeneration == 100); // unchanged + // An older request (a stale value lingering) is likewise ignored. + const auto older = consumeDecision(makeReq("b", "s", 90), 100, true, true); + CHECK(!older.apply); + CHECK(older.consumedGeneration == 100); +} + +// Rule 2: a NEW request but this instance is not the target -> do not apply AND do not +// advance the marker (must stay eligible if focus later lands here — no thundering herd). +static void testNonTargetStaysEligible() { + const auto d = consumeDecision(makeReq("b", "s", 200), /*lastConsumed*/ 100, + /*resolves*/ true, /*isFocusedTarget*/ false); + CHECK(!d.apply); + CHECK(d.consumedGeneration == 100); // marker NOT advanced -> still eligible later +} + +// Rule 3: a NEW request, target, but unresolvable -> DROP silently. Marker advances so it +// is never re-evaluated, but no selection change (assignment_request.h reader requirement). +static void testUnresolvableDroppedSilently() { + const auto d = consumeDecision(makeReq("b", "deleted-sample", 200), + /*lastConsumed*/ 100, /*resolves*/ false, + /*isFocusedTarget*/ true); + CHECK(!d.apply); // no selection change + CHECK(d.consumedGeneration == 200); // consumed-as-seen: never re-evaluated + CHECK(d.sampleId.empty()); // nothing to apply +} + +// Rule 4: a NEW request, target, resolvable -> APPLY selection + advance the marker. +static void testAppliedWhenNewTargetResolvable() { + const auto d = consumeDecision(makeReq("bank-7", "cap-42", 200), + /*lastConsumed*/ 100, /*resolves*/ true, + /*isFocusedTarget*/ true); + CHECK(d.apply); + CHECK(d.bankId == "bank-7"); + CHECK(d.sampleId == "cap-42"); + CHECK(d.consumedGeneration == 200); +} + +// Re-assigning the SAME sample id under a NEW generation must re-apply (the generation is +// the disambiguator; a recapture/re-drop of the same id is a fresh assign, not a no-op). +static void testSameIdNewGenerationReapplies() { + // First consume at gen 100. + const auto first = consumeDecision(makeReq("b", "s", 100), 50, true, true); + CHECK(first.apply); + CHECK(first.consumedGeneration == 100); + // Same id, higher generation, marker now at 100 -> applies again. + const auto second = consumeDecision(makeReq("b", "s", 150), 100, true, true); + CHECK(second.apply); + CHECK(second.sampleId == "s"); + CHECK(second.consumedGeneration == 150); +} + +// A fresh instance (lastConsumed == 0) applies a first assign — the default marker must not +// swallow the first request. +static void testFreshInstanceAppliesFirst() { + const auto d = consumeDecision(makeReq("b", "s", 1), 0, true, true); + CHECK(d.apply); + CHECK(d.consumedGeneration == 1); +} + +int main() { + testParseAbsentAndMalformed(); + testParseValid(); + testParseOverflow(); + testFormatRoundTrip(); + testGenerationChanged(); + testNoRequest(); + testNotNewerNotReapplied(); + testNonTargetStaysEligible(); + testUnresolvableDroppedSilently(); + testAppliedWhenNewTargetResolvable(); + testSameIdNewGenerationReapplies(); + testFreshInstanceAppliesFirst(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 7763915..6b74e13 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -979,6 +979,58 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() { !back.map.zones[1].startPoint.has_value()); } +// --- v5 component state: the S8/S9 last-consumed-assignment marker ------------- + +static void testComponentStateV5MarkerRoundTrip() { + // The consumed-assignment generation (S8 reader marker) round-trips through the v5 envelope + // alongside selection + mode + zones. A non-zero, > 32-bit value proves the 8-byte LE field. + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX + s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives + CHECK(back.channelMode == ChannelMode::Stereo); + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); +} + +static void testComponentStateDefaultMarkerIsZero() { + // A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first + // assign (generation >= 1) must not be swallowed by a non-zero default. + const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{})); + CHECK(back.lastConsumedAssignGeneration == 0); +} + +static void testComponentStateV4LiftsMarkerToZero() { + // A GENUINE v4 blob (version tag 4: mode byte, then id + zones — NO 8-byte marker) must lift + // with lastConsumedAssignGeneration = 0 and its mode/selection/zones intact. Build it by hand + // (serializeComponentState now emits v5, so we cannot use it to make a v4 blob). This proves + // an already-saved pre-S8/S9 instance restores cleanly and its first assign still applies. + std::vector v4; + v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // version 4 + v4.push_back(1); // channel mode = stereo + const std::string id = "saved"; + v4.push_back(static_cast(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0); + v4.insert(v4.end(), id.begin(), id.end()); + v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0 + const ComponentState back = deserializeComponentState(v4); + CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0 + CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored + CHECK(back.selectionId == "saved"); + CHECK(back.map.zones.empty()); +} + +static void testComponentStateV5TruncatedMarker() { + // A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty, + // mono + marker 0 default holds (bounded read, never throws across the host). + std::vector t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes + const ComponentState back = deserializeComponentState(t); + CHECK(back.lastConsumedAssignGeneration == 0); + CHECK(back.selectionId.empty() && back.map.zones.empty()); +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -1049,6 +1101,10 @@ int main() { testComponentStateV1V2LiftToMono(); testComponentStateV4TruncatedModeByte(); testComponentStateV4StereoWithZoneOverridesRoundTrip(); + testComponentStateV5MarkerRoundTrip(); + testComponentStateDefaultMarkerIsZero(); + testComponentStateV4LiftsMarkerToZero(); + testComponentStateV5TruncatedMarker(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; From 77e7c39171f9151a751a54116d3e856750a65c67 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 00:02:32 -0400 Subject: [PATCH 34/49] fix(review): embed asymmetry comment, single-lock assign-marker, dedup-collapse test --- src/vst/reasampler_embed.cpp | 4 ++++ src/vst/reasampler_processor.cpp | 28 +++++++++++++++------------- tests/test_bank_sync.cpp | 24 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp index 51e62f7..95c69ce 100644 --- a/src/vst/reasampler_embed.cpp +++ b/src/vst/reasampler_embed.cpp @@ -113,6 +113,10 @@ void ReaSamplerEmbed::maybeRefresh() { } else if (lastSeenBankGeneration_ < 0) { currentGen = 0; // unprimed + no stamp (pre-S9): treat as generation 0 for the first read } + // Intentional asymmetry: a TRANSIENT bridge failure (readReasamplerExtState returned + // nullopt after we were already primed) leaves currentGen == lastSeenBankGeneration_, + // so the bank-blob read is skipped and the editor keeps its last-known sample list. + // A stale-but-intact list is better than clearing samples_ on every transient hiccup. if (lastSeenBankGeneration_ < 0 || currentGen != lastSeenBankGeneration_) { auto banks = diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index f9903f8..4495c10 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -430,22 +430,24 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { } } + // Read lastConsumed and conditionally write it back under a single lock scope so there + // is no interleave window between the read and the write (a concurrent getState could + // otherwise observe a stale marker between the two separate lock acquisitions). std::int64_t lastConsumed = 0; - { + const AssignConsumeDecision decision = [&] { std::lock_guard lock(assignMarkerMutex_); lastConsumed = lastConsumedAssignGeneration_; - } - const AssignConsumeDecision decision = - consumeDecision(request, lastConsumed, resolves, isFocusedTarget); - - // Advance the persisted consumed marker whenever the decision consumed the request - // (applied OR dropped-as-seen). getState will persist it on the next project save so a - // re-open does not re-apply. A non-target instance leaves the marker (decision returns it - // unchanged) so it stays eligible if focus later lands here. - if (decision.consumedGeneration != lastConsumed) { - std::lock_guard lock(assignMarkerMutex_); - lastConsumedAssignGeneration_ = decision.consumedGeneration; - } + const AssignConsumeDecision d = + consumeDecision(request, lastConsumed, resolves, isFocusedTarget); + // Advance the persisted consumed marker whenever the decision consumed the request + // (applied OR dropped-as-seen). getState will persist it on the next project save so + // a re-open does not re-apply. A non-target instance leaves the marker (decision + // returns it unchanged) so it stays eligible if focus later lands here. + if (d.consumedGeneration != lastConsumed) { + lastConsumedAssignGeneration_ = d.consumedGeneration; + } + return d; + }(); if (decision.apply) { // Apply the assignment as this instance's own selection (the same path a user card-pick diff --git a/tests/test_bank_sync.cpp b/tests/test_bank_sync.cpp index f18af93..cdcad2a 100644 --- a/tests/test_bank_sync.cpp +++ b/tests/test_bank_sync.cpp @@ -160,6 +160,29 @@ static void testFreshInstanceAppliesFirst() { CHECK(d.consumedGeneration == 1); } +// Re-import / dedup-collapse path: the extension ingests a sample that already exists in +// the bank (dedup collapse: the bank_generation counter does NOT advance because no new +// sample was added), but a new assign_request is still written with a HIGHER assign +// generation (the ingest disambiguator, independent of bank_generation). +// +// The consumeDecision must apply the request — its own generation is the "is this new?" +// discriminator, and it is strictly greater than lastConsumed. bank_generation does not +// enter consumeDecision at all; this test proves the two counters are fully independent. +static void testDedupCollapseAssignAppliesWhenBankGenerationUnchanged() { + // Simulate: bank_generation is 5 both before and after the dedup ingest (unchanged). + // The assign_request generation is 300 (new; lastConsumed was 200 from the prior assign). + // The existing sample resolves (it is in the bank — dedup kept it there). + const auto d = consumeDecision(makeReq("pool", "existing-sample-id", 300), + /*lastConsumed*/ 200, /*resolves*/ true, + /*isFocusedTarget*/ true); + CHECK(d.apply); + CHECK(d.bankId == "pool"); + CHECK(d.sampleId == "existing-sample-id"); + CHECK(d.consumedGeneration == 300); // marker advanced to the new assign generation + // bank_generation (5) is not a parameter here — this test documents its absence from + // consumeDecision: only the assign_request's own generation drives the consume decision. +} + int main() { testParseAbsentAndMalformed(); testParseValid(); @@ -173,6 +196,7 @@ int main() { testAppliedWhenNewTargetResolvable(); testSameIdNewGenerationReapplies(); testFreshInstanceAppliesFirst(); + testDedupCollapseAssignAppliesWhenBankGenerationUnchanged(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; From 1e1d6bddbbc7d7eb575e6c4f8d3b11c226098fc6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 23:50:31 -0400 Subject: [PATCH 35/49] S15/S16: Gate(AHDSR)/Trigger play modes + Varispeed/Preserve pitch engines + AD pitch envelope Per-zone play params on SampleData; hand-rolled pure pitch_shift OLA for Preserve (WDL drags windows.h); zone-payload v3 tail; RT-safe pre-warmed shifters + Preserve voice cap. --- CLAUDE.md | 1 + CMakeLists.txt | 18 +- src/vst/pitch_shift.cpp | 126 +++++++++++ src/vst/pitch_shift.h | 88 ++++++++ src/vst/reasampler_processor.cpp | 16 +- src/vst/reasampler_processor.h | 7 +- src/vst/sample_map.cpp | 53 ++++- src/vst/sample_map.h | 50 ++++- src/vst/sampler_core.cpp | 327 +++++++++++++++++++++++---- src/vst/sampler_core.h | 256 ++++++++++++++++++++-- tests/test_pitch_shift.cpp | 187 ++++++++++++++++ tests/test_sample_map.cpp | 111 ++++++++++ tests/test_sampler_core.cpp | 365 +++++++++++++++++++++++++++++++ 13 files changed, 1528 insertions(+), 77 deletions(-) create mode 100644 src/vst/pitch_shift.cpp create mode 100644 src/vst/pitch_shift.h create mode 100644 tests/test_pitch_shift.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 12c14a6..a4c8887 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ Key targets (see CMakeLists.txt for the full list): | `tooltip_tests` | executable | Pure unit tests for `tooltip` — no REAPER, no DAW. | | `card_drag_tests` | executable | Pure unit tests for `card_drag` — no REAPER, no DAW. | | `card_meta_tests` | executable | Pure unit tests for `card_meta` — no REAPER, no DAW. | +| `pitch_shift_tests` | executable | Pure unit tests for `pitch_shift` (S16 Preserve engine) — no REAPER, no DAW. | | `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). | ### Beta channel build (Phase V, V4) diff --git a/CMakeLists.txt b/CMakeLists.txt index a0328a4..dc9782d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -471,9 +471,18 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid) # the one house precedent wav_trim also relies on). The VST3 shell (src/vst/ # reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified. # --------------------------------------------------------------------------- +# pitch_shift (S16) — the pure duration-preserving PitchShifter (Preserve-engine DSP core). +# NO VST3/REAPER/SWELL/vendor: a hand-rolled OLA shifter chosen over WDL_SimplePitchShifter +# because that header drags (via wdltypes.h) into any TU that includes it, which +# cannot enter the pure sampler_core. Links only peaks (the AudioSample alias). sampler_core +# depends on it (Voice owns two PitchShifters). +add_library(pitch_shift STATIC src/vst/pitch_shift.cpp) +target_include_directories(pitch_shift PUBLIC src src/vst) +target_link_libraries(pitch_shift PUBLIC peaks) + add_library(sampler_core STATIC src/vst/sampler_core.cpp) target_include_directories(sampler_core PUBLIC src src/vst) -target_link_libraries(sampler_core PUBLIC peaks) +target_link_libraries(sampler_core PUBLIC peaks pitch_shift) # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). @@ -620,6 +629,13 @@ add_test(NAME assignment_request_tests COMMAND assignment_request_tests) # sampler_core: the S3 heart. Links ONLY sampler_core (+ its peaks dep) — NEITHER the # VST3 SDK nor the REAPER SDK — which is the structural proof of the plain-data # boundary (a VST3/REAPER type in the core would fail to compile/link here). +# pitch_shift (S16): the pure Preserve-engine OLA shifter. Links ONLY pitch_shift (+ peaks) — +# NEITHER SDK — the same plain-data-boundary proof, and specifically the compile-time proof it +# does NOT drag in the WDL chain the built-in WDL shifter would. +add_executable(pitch_shift_tests tests/test_pitch_shift.cpp) +target_link_libraries(pitch_shift_tests PRIVATE pitch_shift) +add_test(NAME pitch_shift_tests COMMAND pitch_shift_tests) + add_executable(sampler_core_tests tests/test_sampler_core.cpp) target_link_libraries(sampler_core_tests PRIVATE sampler_core) add_test(NAME sampler_core_tests COMMAND sampler_core_tests) diff --git a/src/vst/pitch_shift.cpp b/src/vst/pitch_shift.cpp new file mode 100644 index 0000000..d957543 --- /dev/null +++ b/src/vst/pitch_shift.cpp @@ -0,0 +1,126 @@ +// pitch_shift — pure implementation. See pitch_shift.h for the contract and the S16-F2 +// route-(b) rationale (WDL drags , so the Preserve DSP is house-native here). +// NO VST3 / REAPER / SWELL / vendor includes; standard library only. +// +// Algorithm: a single delay ring of `window_` frames. The write head advances one frame per +// input sample (source rate → duration preserved). TWO read taps chase the write head, offset +// by half a window; each advances by the shift `ratio_` per frame. A tap that would cross the +// write head wraps by a full window (so it stays a bounded delay behind the writer). The two +// taps are crossfaded by an equal-power window keyed to each tap's distance from the write +// head, so the wrap discontinuity of one tap is masked by the other mid-window — the classic +// two-grain time-domain pitch shifter, no FFT. + +#include "pitch_shift.h" + +#include +#include + +namespace reasampler { + +namespace { + +// A Hann OLA window over a grain phase in [0,1): 0.5(1 - cos(2*pi*phase)). Zero at the grain +// ends (where a tap wraps — the discontinuity), unity mid-grain. Two grains offset by half a +// window PARTITION UNITY (w(p) + w(p+0.5) == 1 for all p), so the two crossfaded taps sum to a +// gain of exactly 1 everywhere — no amplitude ripple across the window, and each tap's wrap +// seam is masked because its window is 0 exactly there. +double hannWeight(double phase) { + while (phase < 0.0) phase += 1.0; + while (phase >= 1.0) phase -= 1.0; + return 0.5 * (1.0 - std::cos(2.0 * 3.14159265358979323846 * phase)); +} + +} // namespace + +void PitchShifter::configure(std::int64_t windowFrames) { + window_ = windowFrames; + if (window_ <= 1) { + // Pass-through: no ring, process() returns input unchanged. + ring_.clear(); + writePos_ = 0; + readPos_ = 0.0; + ratio_ = 1.0; + return; + } + ring_.assign(static_cast(window_), 0.0f); + reset(); +} + +void PitchShifter::reset() { + if (window_ > 1) { + // Zero the ring and seed the read head a half-window behind the writer so the two taps + // (readPos_ and readPos_ + window/2) straddle the writer from the first frame. + std::fill(ring_.begin(), ring_.end(), 0.0f); + writePos_ = 0; + readPos_ = static_cast(window_) / 2.0; + } else { + writePos_ = 0; + readPos_ = 0.0; + } + ratio_ = 1.0; +} + +void PitchShifter::warm() { + if (window_ <= 1) return; // pass-through needs no warm-up + // Push one full window of silence so the taps reach steady state before real audio. + for (std::int64_t i = 0; i < window_; ++i) process(0.0f); +} + +void PitchShifter::setShiftRatio(double ratio) { + if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run taps backward/stall) +} + +AudioSample PitchShifter::process(AudioSample in) { + if (window_ <= 1) return in; // pass-through (unconfigured / degenerate) + + // 1. Write the incoming sample at the write head (source rate). + ring_[static_cast(writePos_)] = in; + + const double w = static_cast(window_); + const double half = w / 2.0; + + // 2. Read the two taps, each a bounded delay behind the writer. tap0 is `readPos_`; tap1 is + // a half-window ahead of it (mod window). Distance-from-writer drives the crossfade so a + // tap near the writer (about to wrap) is faded out while its partner (mid-window) is up. + auto readTap = [&](double pos) -> double { + // Fractional linear interpolation with ring wrap. + double p = pos; + while (p < 0.0) p += w; + while (p >= w) p -= w; + const std::int64_t i0 = static_cast(p); + const double frac = p - static_cast(i0); + std::int64_t i1 = i0 + 1; + if (i1 >= window_) i1 = 0; + const double s0 = static_cast(ring_[static_cast(i0)]); + const double s1 = static_cast(ring_[static_cast(i1)]); + return s0 + (s1 - s0) * frac; + }; + + const double tap0 = readTap(readPos_); + const double tap1 = readTap(readPos_ + half); + + // Distance of tap0 behind the write head, in [0, window). Its crossfade phase is that + // distance over the window; tap1 (half a window offset) gets the complementary phase. + double dist0 = static_cast(writePos_) - readPos_; + while (dist0 < 0.0) dist0 += w; + while (dist0 >= w) dist0 -= w; + const double phase0 = dist0 / w; + + // Hann windows offset by half a grain partition unity, so the two taps sum to gain 1 with + // each tap's wrap seam masked by its window zero. phase0 drives tap0; tap1 (half-window + // offset) is at phase0 + 0.5. + const double g0 = hannWeight(phase0); + const double g1 = hannWeight(phase0 + 0.5); + const double out = tap0 * g0 + tap1 * g1; + + // 3. Advance heads: write head one frame (source rate), read head by the shift ratio. + ++writePos_; + if (writePos_ >= window_) writePos_ = 0; + readPos_ += ratio_; + while (readPos_ >= w) readPos_ -= w; + while (readPos_ < 0.0) readPos_ += w; + + return static_cast(out); +} + +} // namespace reasampler diff --git a/src/vst/pitch_shift.h b/src/vst/pitch_shift.h new file mode 100644 index 0000000..d96ed9c --- /dev/null +++ b/src/vst/pitch_shift.h @@ -0,0 +1,88 @@ +#pragma once +// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve" +// engine's DSP core. Time-domain overlap-add (OLA) with two half-window-offset read taps +// crossfaded to hide the ring-wrap seam. Source is consumed 1:1 and output produced 1:1 +// (duration held); only the PITCH changes — an octave up plays the same wall-clock length +// as the root note, unlike the Varispeed `readPos_ += ratio_` resample path. +// +// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was +// route (a) `WDL_SimplePitchShifter`. But its include chain +// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 -> +// #include ` unconditionally, which CANNOT enter the pure sampler_core module +// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither +// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native +// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same +// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at +// the SHELL, never in the pure core. +// +// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only. +// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core / +// wav_trim do the same). +// +// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio +// thread, at voice allocation). `warm()` pre-fills the ring with silence so steady-state +// latency is reached before the first real sample (no cold-start click). `process()` does +// NO allocation and NO locks — it reads/writes the pre-sized ring only. All state is plain +// value fields, so a voice owning one by value costs a fixed ring buffer per channel. + +#include +#include +#include + +#include "peaks.h" // AudioSample (float) + +namespace reasampler { + +// A per-channel time-domain OLA pitch shifter. One instance transposes ONE channel; a stereo +// voice owns two (or a stereo-aware wrapper) — the algorithm is per-sample and channel-count +// agnostic, matching the S7 "one read head, per-channel value" idiom of the core. +// +// The default-constructed shifter is INERT: with no configure() it passes input through +// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is +// byte-identical to the pre-S16 engine. +class PitchShifter { +public: + // Size the delay ring for `windowFrames` (the OLA grain length) and prepare the two + // read taps a half-window apart. `windowFrames` <= 1 degrades to pass-through (no ring), + // so a degenerate configure never divides by zero or wraps a zero span. Called OFF the + // audio thread (allocates). Resets all running state. A larger window = smoother on large + // transpositions but more latency; the shell picks it from the Preserve quality setting. + void configure(std::int64_t windowFrames); + + // Pre-fill the ring with silence (one full window of zero writes) so the read taps reach + // steady state before the first real sample. Removes the cold-start seam (the S16 "onset + // click absent" requirement) — call once at voice allocation after configure(). No-op when + // unconfigured (pass-through needs no warm-up). + void warm(); + + // The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias. + // 1.0 = no shift (pass-through-equivalent output). Set per frame is fine (cheap); the tap + // advance simply uses the current value. Values <= 0 are ignored (kept at the last valid + // ratio) so a bad input never runs the taps backward or stalls them. + void setShiftRatio(double ratio); + + // Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out). + // RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured + // (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write + // head, reads the two half-window-offset taps advancing at the shift ratio, crossfades + // them by the write-head-relative distance (equal-power), and advances both heads by one. + AudioSample process(AudioSample in); + + // Reset running state to a freshly-warmed-equivalent silence (ring zeroed, heads re-seeded) + // WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window. + void reset(); + + // True once configure() sized a real ring (window > 1). A pass-through shifter is false. + bool configured() const { return window_ > 1; } + + std::int64_t window() const { return window_; } + +private: + std::vector ring_; // delay line, length `window_` (channel-local) + std::int64_t window_ = 0; // OLA grain length in frames; <= 1 = pass-through + std::int64_t writePos_ = 0; // integer write head into the ring (source rate) + double readPos_ = 0.0; // fractional read head (advances at shift ratio) + double ratio_ = 1.0; // current shift ratio (>0) +}; + +} // namespace reasampler diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 7d65188..e6c49d2 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -41,6 +41,13 @@ constexpr double kSustainLevel = 1.0; constexpr double kReleaseSeconds = 0.060; constexpr std::size_t kMaxVoices = 16; +// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is +// materially heavier than a Varispeed voice. Below the Varispeed polyphony bound so a chord of +// Preserve notes stays within the RT budget; a Preserve note-on past the cap is dropped rather +// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice +// cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling. +constexpr std::size_t kPreserveVoiceCap = 8; + AdsrParams tier0Adsr(double sampleRate) { const double sr = sampleRate > 0.0 ? sampleRate : 44100.0; AdsrParams p; @@ -363,8 +370,15 @@ std::string ReaSamplerProcessor::reloadFromBank() { } if (haveKeymap) { + // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). + // Every voice's shifter is pre-sized to this off-thread here, so process()-time + // note-on never allocates. Floored at 2 so a valid window is always a real ring. + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * (sampleRate_ > 0.0 ? sampleRate_ : 44100.0) / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; built = std::make_unique( - std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen); + std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen, kPreserveVoiceCap, + preserveWindow); } } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index a16e283..32b9492 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -51,8 +51,11 @@ struct LoadedInstrument { std::uint64_t installedAt = 0; // reload generation at which this was installed LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr, - std::uint64_t gen) - : keymap(std::move(km)), engine(maxVoices, keymap, adsr), installedAt(gen) {} + std::uint64_t gen, std::size_t preserveVoiceCap = 0, + std::int64_t preserveWindowFrames = 0) + : keymap(std::move(km)), + engine(maxVoices, keymap, adsr, preserveVoiceCap, preserveWindowFrames), + installedAt(gen) {} LoadedInstrument(const LoadedInstrument&) = delete; LoadedInstrument& operator=(const LoadedInstrument&) = delete; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 64c95b8..7b2deff 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -135,7 +135,7 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, - std::vector framesR) { + std::vector framesR, const ZonePlayParams& play) { SampleData data; data.frames = std::move(frames); // A second channel only counts when it length-matches channel 0 (else the sample stays @@ -146,6 +146,7 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, data.sampleRate = sampleRate > 0 ? sampleRate : 44100; data.rootNote = rootNote; data.loop = loop; + data.play = play; // S15/S16 single-capture play params (product defaults unless overridden) return Keymap::singleSampleChromatic(std::move(data)); } @@ -186,6 +187,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // never mutated — this only shapes what the core plays for THIS instance (D-B). rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found); rz.startFrame = z.startPoint ? *z.startPoint : 0; + // S15/S16 per-zone play params carry through unchanged (they are instrument state, not + // resolved against the bank) so the keymap build can stamp them onto the SampleData. + rz.play = z.play; out.zones.push_back(std::move(rz)); } return out; @@ -210,6 +214,7 @@ Keymap buildZonedKeymap(const std::vector& zones, data.rootNote = zones[i].rootNote; data.loop = zones[i].loop; data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) + data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes const std::size_t sampleIndex = km.samples.size(); km.samples.push_back(std::move(data)); KeyZone zone; @@ -241,6 +246,19 @@ void putU64le(std::vector& out, std::uint64_t v) { std::uint64_t asU64(std::int64_t v) { return static_cast(v); } +// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++). +// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields. +std::uint64_t doubleToBits(double d) { + std::uint64_t bits; + std::memcpy(&bits, &d, sizeof(bits)); + return bits; +} +double bitsToDouble(std::uint64_t bits) { + double d; + std::memcpy(&d, &bits, sizeof(d)); + return d; +} + // A bounded little-endian reader over a byte blob. Every read is length-checked; once a // read runs past the end the reader latches `ok=false` and yields zeros, so a truncated // blob degrades to a partial/empty parse rather than reading out of bounds. @@ -324,6 +342,20 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) } out.push_back(z.startPoint ? 1 : 0); if (z.startPoint) putU64le(out, asU64(*z.startPoint)); + + // S15/S16 extension (PAYLOAD v3): the per-zone play params, always present (every zone + // has a play mode + engine — no flag gate). Order matches the header's v3 record spec. + const ZonePlayParams& pp = z.play; + out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); + putU64le(out, asU64(pp.adsr.holdFrames)); + putU64le(out, doubleToBits(pp.trigger.lengthFraction)); + putU64le(out, asU64(pp.trigger.fadeInFrames)); + putU64le(out, asU64(pp.trigger.fadeOutFrames)); + out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); + out.push_back(pp.pitchEnv.enabled ? 1 : 0); + putU64le(out, asU64(pp.pitchEnv.attackFrames)); + putU64le(out, asU64(pp.pitchEnv.decayFrames)); + putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); } } @@ -333,14 +365,18 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) // clean back-compat lift, the overrides simply default absent). A truncated mid-zone read // keeps the zones that parsed cleanly and drops the rest. void readZonesPayload(ByteReader& r, PerformanceMap& map) { - bool extended = false; + bool extended = false; // v2+: the S11 loop/start tail is present + bool hasPlay = false; // v3+: the S15/S16 play-params tail is present if (r.peekU32() == kZonesFormatMarker) { r.u32(); // consume the marker const std::uint32_t pv = r.u32(); // payload version extended = (pv >= 2); // v2+ carries the loop/start tail + hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail } const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { + // z.play defaults to the PRODUCT defaults (Gate + Preserve). A v1/v2 payload (no play + // tail) therefore lifts every zone to those defaults — the deliberate S16-F1 change. PerformanceZone z; const std::uint32_t idLen = r.u32(); z.sampleId = r.str(idLen); @@ -360,6 +396,19 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { const std::uint8_t hasStart = r.u8(); if (hasStart) z.startPoint = r.i64(); } + if (hasPlay) { + // S15/S16 play params, always present in a v3 record (read in the emit order). + z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; + z.play.adsr.holdFrames = r.i64(); + z.play.trigger.lengthFraction = bitsToDouble(r.u64()); + z.play.trigger.fadeInFrames = r.i64(); + z.play.trigger.fadeOutFrames = r.i64(); + z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; + z.play.pitchEnv.enabled = (r.u8() != 0); + z.play.pitchEnv.attackFrames = r.i64(); + z.play.pitchEnv.decayFrames = r.i64(); + z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); + } if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest map.zones.push_back(std::move(z)); } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index f527135..5b7bd92 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -114,9 +114,16 @@ std::vector extractChannel(const std::vector& interlea // which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length // mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad // pair never half-plays. `sampleRate` is the WAV's rate. +// `play` carries the S15/S16 per-zone play params for the single-capture path; it defaults to +// the PRODUCT defaults (Gate + Preserve engine, S16-F1) so a picked single capture plays under +// the same default engine as a zone would. The editor will surface per-capture overrides later +// (S15-F2 one-zone-map lean); until then this is the one place the single-capture default lives. Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, - std::vector framesR = {}); + std::vector framesR = {}, + const ZonePlayParams& play = ZonePlayParams{ + PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, + PitchEnvParams{}}); // --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // @@ -147,6 +154,17 @@ struct PerformanceZone { std::optional rootOverride; // instrument-owned override; absent -> bank intrinsic std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional startPoint; // instrument-owned initial read frame; absent -> 0 + + // S15/S16 per-zone play parameters (play mode + AHDSR hold + Trigger %-length/fades; pitch + // engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the + // loop/start overrides. Defaults to the PRODUCT defaults for a NEW zone: Gate play mode, + // hold 0, no fades, and the PRESERVE pitch engine (S16-F1 — Daniel's directive; the one + // flippable default is sampler_core::kDefaultPitchEngine), pitch envelope off. An older + // zone-payload blob (no S15/S16 tail) lifts to exactly these defaults on read (see the + // PAYLOAD v3 versioning in the (de)serialize section), so a pre-S15 instrument opens with + // Gate + Preserve — the deliberate, spec-flagged behavior change. + ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, + PitchEnvParams{}}; }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -170,6 +188,8 @@ struct ResolvedZone { int rootNote = 60; // effective: override, else bank intrinsic, else 60 SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) + ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, + PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is) }; // The result of resolving a performance map against the live bank blob. `zones` are the @@ -249,6 +269,20 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). // The reader detects the marker to know the record shape — a v1 payload (no marker) reads // the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. +// * PAYLOAD v3 (S15/S16): the same marker + payload version (== 3), THEN the v2 body PLUS, +// appended to each zone record after the S11 startPoint tail (the S15/S16 per-zone play +// params — always present, NOT flag-gated, since every zone has a play mode + engine): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage (A/D/S/R timing stays +// instrument-wide; only hold is per-zone); +// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); +// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64); +// 8-byte LE pitchEnv.decayFrames (int64); 8-byte LE pitchEnv.peakSemitones as a double. +// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + +// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved +// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. // BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is // lifted to a single full-keyboard zone playing that id (no override) — so an instance saved // under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes @@ -261,12 +295,14 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, inline constexpr std::uint32_t kPerformanceStateVersion = 2; -// The zones-payload format version and its detection marker (S11). serializePerformance and -// serializeComponentState both emit PAYLOAD v2 (marker + version + extended records) so the -// S11 loop/start overrides round-trip through EITHER envelope. Readers accept a v1 payload -// (no marker) for back-compat. The marker is a high sentinel that a legitimate zone count -// (bounded by 128 MIDI zones in practice, always tiny) can never collide with. -inline constexpr std::uint32_t kZonesPayloadVersion = 2; +// The zones-payload format version and its detection marker (S11/S15/S16). serializePerformance +// and serializeComponentState both emit the CURRENT payload version (v3 — marker + version + +// records with the S11 loop/start tail AND the S15/S16 play-params tail) so the overrides +// round-trip through EITHER envelope. Readers accept a v1 payload (no marker) and a v2 payload +// (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults. +// The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in +// practice, always tiny) can never collide with. +inline constexpr std::uint32_t kZonesPayloadVersion = 3; // S15/S16: per-zone play params tail inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; // The performance map serialized to bytes for IBStream (getState). diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 5bdd075..88de67e 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -85,6 +85,32 @@ double AdsrEnvelope::tick() { const double out = level_; ++framesInStage_; if (framesInStage_ >= params_.attackFrames) { + // S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight + // through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path. + stage_ = Stage::Hold; + framesInStage_ = 0; + level_ = 1.0; + } + return out; + } + + case Stage::Hold: { + // S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the + // stage on this same tick (no frame consumed at 1.0 beyond what Attack already + // emitted), so hold=0 is byte-identical to the pre-S15 envelope. + if (params_.holdFrames <= 0) { + stage_ = Stage::Decay; + framesInStage_ = 0; + // Fall through to Decay this frame so no extra unity sample is emitted for a + // zero-length hold (preserving the exact pre-S15 sample-for-sample shape). + level_ = 1.0; + // Re-dispatch by recursion-free goto-equivalent: evaluate Decay immediately. + return tick(); + } + level_ = 1.0; + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.holdFrames) { stage_ = Stage::Decay; framesInStage_ = 0; level_ = 1.0; @@ -136,46 +162,194 @@ double AdsrEnvelope::tick() { return 0.0; // unreachable; silences a warning. } +// --------------------------------------------------------------------------- +// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function. +// --------------------------------------------------------------------------- + +void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames, + std::int64_t fadeOutFrames, FadeCurve curve) { + playLength_ = playLengthFrames > 0 ? playLengthFrames : 0; + curve_ = curve; + finished_ = (playLength_ <= 0); + + // Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A + // negative fade is treated as 0. When both fades together exceed the play length, shrink + // the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp), + // then the fade-in — never letting either go negative or the sum exceed the span. + std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0; + std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0; + if (fi > playLength_) fi = playLength_; + if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_ + fadeIn_ = fi; + fadeOut_ = fo; +} + +double TriggerEnvelope::amplitudeAt(double sourceOffset) { + if (finished_ || sourceOffset < 0.0 || + sourceOffset >= static_cast(playLength_)) { + // At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd. + if (sourceOffset >= static_cast(playLength_)) finished_ = true; + return 0.0; + } + + // Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_). + // Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length). + // The offset is fractional (the read head is fractional under repitch), so the ramps are + // smooth rather than stepped. + double amp = 1.0; + const double foStart = static_cast(playLength_ - fadeOut_); + if (fadeIn_ > 0 && sourceOffset < static_cast(fadeIn_)) { + const double phase = sourceOffset / static_cast(fadeIn_); // 0..1 + amp = (curve_ == FadeCurve::EqualPower) + ? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): 0->1 constant power + : phase; + } else if (fadeOut_ > 0 && sourceOffset >= foStart) { + const double phase = (sourceOffset - foStart) / static_cast(fadeOut_); // 0..1 + amp = (curve_ == FadeCurve::EqualPower) + ? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power + : (1.0 - phase); + } + return amp; +} + +// --------------------------------------------------------------------------- +// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled. +// --------------------------------------------------------------------------- + +double PitchEnvelope::tick() { + if (!params_.enabled) return 0.0; + + const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0; + const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0; + const double peak = params_.peakSemitones; + + double offset; + if (pos_ < a) { + // Attack: 0 -> peak over attackFrames (rise into the peak). + offset = peak * (static_cast(pos_) / static_cast(a)); + } else if (pos_ < a + d) { + // Decay: peak -> 0 over decayFrames (settle to base pitch). + const double t = static_cast(pos_ - a) / static_cast(d); + offset = peak * (1.0 - t); + } else { + offset = 0.0; // past attack+decay: at base pitch forever. + } + ++pos_; + return offset; +} + // --------------------------------------------------------------------------- // Voice // --------------------------------------------------------------------------- +void Voice::presizePreserveShifters(std::int64_t windowFrames) { + // OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs + // no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. + shiftL_.configure(windowFrames); + shiftR_.configure(windowFrames); +} + void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, - const AdsrParams& adsr) { + const AdsrParams& gateAdsr) { active_ = true; releasing_ = false; + amplitudeDone_ = false; note_ = note; // MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively. int v = velocity; if (v < 0) v = 0; if (v > 127) v = 127; velocityGain_ = static_cast(v) / 127.0; - ratio_ = pitchRatio(note, rootNote); - // Initial read position honors the sample's start-point offset (S11). Clamp into - // [0, frames): a start at or past the end degrades to 0 (play from the top) rather - // than starting a voice already off the end. A negative start (shouldn't occur — - // the map clamps) is likewise pinned to 0. + baseRatio_ = pitchRatio(note, rootNote); + sample_ = &sample; + + const ZonePlayParams& p = sample.play; + playMode_ = p.playMode; + pitchEngine_ = p.pitchEngine; + + // Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp + // into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than + // starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0. const std::int64_t frameCount = static_cast(sample.frames.size()); std::int64_t start = sample.startFrame; if (start < 0 || start >= frameCount) start = 0; readPos_ = static_cast(start); - sample_ = &sample; - env_.configure(adsr); - env_.noteOn(); + startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset) + + // --- Amplitude envelope: Gate = AHDSR (instrument A/D/S/R + per-zone HOLD); Trigger = the + // time-boxed fade-in/out over the % play length. --- + if (playMode_ == PlayMode::Gate) { + AdsrParams a = gateAdsr; + a.holdFrames = p.adsr.holdFrames; // per-zone hold folds into the instrument-wide AHDSR + env_.configure(a); + env_.noteOn(); + playEnd_ = 0; // unused in Gate + } else { + // Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)). + double frac = p.trigger.lengthFraction; + if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately) + if (frac > 1.0) frac = 1.0; + const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount) + std::int64_t playLen = static_cast( + static_cast(span) * frac + 0.5); // round + if (playLen < 0) playLen = 0; + if (playLen > span) playLen = span; + playEnd_ = start + playLen; + trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames, + kDefaultFadeCurve); + } + + // --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). --- + pitchEnv_.configure(p.pitchEnv); + pitchEnv_.noteOn(); + + // --- Preserve engine (S16): reset + pre-warm the ALREADY-SIZED per-channel shifters. The + // rings were allocated off-thread by presizePreserveShifters (the engine calls it at + // construction), so this RT-safe path only zeroes state (reset) and runs a silence pass + // (warm) to settle the OLA taps before the first output frame — NO allocation here. + // Varispeed voices never touch the shifters (advanceFrame checks configured()), so a + // Varispeed instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. --- + if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { + shiftL_.reset(); + shiftL_.warm(); + if (sample.channelCount() == 2 && shiftR_.configured()) { + shiftR_.reset(); + shiftR_.warm(); + } + } + ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine. } void Voice::release() { if (!active_) return; + // TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length. + if (playMode_ == PlayMode::Trigger) return; releasing_ = true; env_.noteOff(); } +double Voice::tickAmplitude() { + double amp; + if (playMode_ == PlayMode::Gate) { + // AHDSR is wall-clock (one tick per output frame), independent of the read rate. + amp = env_.tick(); + if (env_.finished()) amplitudeDone_ = true; + } else { + // Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades + // land on the same source frames under either engine's read rate. The voice ALSO frees on + // readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders. + amp = trigEnv_.amplitudeAt(readPos_ - static_cast(startFrame_)); + if (trigEnv_.finished()) amplitudeDone_ = true; + } + return amp; +} + AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { // Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap, // bracketing indices, interpolation partner) is computed ONCE and applied identically to - // every channel — only the PCM value read differs. The envelope ticks ONCE per frame and - // scales all channels equally (a voice is one envelope). The head advances by exactly one - // ratio step per call, so mono and stereo consume the sample at the same rate. + // every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE + // per frame and scale all channels equally (a voice is one envelope). The head advances by + // exactly one source-frame step per call, so mono and stereo consume the sample at one rate. if (!active_ || sample_ == nullptr) { if (stereo) outR = 0.0f; return 0.0f; @@ -188,30 +362,32 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { const bool haveR = stereo && sample_->channelCount() == 2; const std::vector& pcmR = haveR ? sample_->framesR : pcm; - // Loop-aware sustain: if a valid, non-zero-length loop exists and the read head - // has advanced past the loop end, wrap it back into [start, end). A zero-length - // loop (start == end) is treated as "no loop" — the note is allowed to run off - // the sample end and go silent, rather than spinning on a zero span. + // Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a + // valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap + // it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the + // loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract). const SampleLoop& loop = sample_->loop; - const bool loopUsable = loop.hasLoop && loop.end > loop.start && - loop.start >= 0 && loop.end <= frameCount; + const bool loopUsable = playMode_ == PlayMode::Gate && loop.hasLoop && + loop.end > loop.start && loop.start >= 0 && loop.end <= frameCount; if (loopUsable) { - const std::int64_t loopStart = loop.start; - const std::int64_t loopEnd = loop.end; - const double loopLen = static_cast(loopEnd - loopStart); - while (readPos_ >= static_cast(loopEnd)) { + const double loopLen = static_cast(loop.end - loop.start); + while (readPos_ >= static_cast(loop.end)) { readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase. } } - // Ran off the end with no usable loop -> voice is done. - if (readPos_ >= static_cast(frameCount)) { + // TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The + // trigger envelope also finishes at the same frame count; either latches the voice idle. + const bool triggerRanOff = + playMode_ == PlayMode::Trigger && readPos_ >= static_cast(playEnd_); + // Ran off the sample end with no usable loop -> voice is done. + if (triggerRanOff || readPos_ >= static_cast(frameCount)) { active_ = false; if (stereo) outR = 0.0f; return 0.0f; } - // Linear interpolation between the two bracketing frames. For the loop case, the + // Linear interpolation between the two bracketing SOURCE frames. For the loop case, the // second point wraps to loopStart so the seam is continuous. const std::int64_t i0 = static_cast(readPos_); const double frac = readPos_ - static_cast(i0); @@ -219,29 +395,71 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { if (loopUsable && i1 >= loop.end) { i1 = loop.start; // seamless wrap for the interpolation partner. } - // i0 is always in [0, frameCount) after the early-out above; the guard is purely - // defensive. i1 (the interpolation partner) can exceed frameCount when no loop - // wraps it — only that partner actually needs the clamp. framesR is length-matched - // to frames (channelCount() enforces it), so the same indices are valid in both. const bool i0ok = (i0 >= 0 && i0 < frameCount); const bool i1ok = (i1 >= 0 && i1 < frameCount); - const double amp = env_.tick(); + // Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine. + const double amp = tickAmplitude(); const double gain = amp * velocityGain_; + const double pitchEnvSemis = pitchEnv_.tick(); - const double l0 = i0ok ? static_cast(pcm[i0]) : 0.0; - const double l1 = i1ok ? static_cast(pcm[i1]) : 0.0; - const double outL = (l0 + (l1 - l0) * frac) * gain; - + // Raw interpolated source values (pre-shift). These are the SOURCE stream both engines read; + // Varispeed applies pitch by the read RATE, Preserve applies it by the shifter. + const double srcL = (i0ok ? static_cast(pcm[i0]) : 0.0) + + ((i1ok ? static_cast(pcm[i1]) : 0.0) - + (i0ok ? static_cast(pcm[i0]) : 0.0)) * frac; + double srcR = 0.0; if (stereo) { - const double r0 = i0ok ? static_cast(pcmR[i0]) : 0.0; - const double r1 = i1ok ? static_cast(pcmR[i1]) : 0.0; - outR = static_cast((r0 + (r1 - r0) * frac) * gain); + srcR = (i0ok ? static_cast(pcmR[i0]) : 0.0) + + ((i1ok ? static_cast(pcmR[i1]) : 0.0) - + (i0ok ? static_cast(pcmR[i0]) : 0.0)) * frac; } + // The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0) + // this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read + // (no per-frame transcendental), byte-identical to pre-S16. + const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0); + + double outL, outRlocal = 0.0; + if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { + // PRESERVE: read the source at unity rate (duration held) and TRANSPOSE the output by + // 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to the shift amount, not the + // read rate — pitch bends, duration unchanged (S16 contract). + const double shift = baseRatio_ * envFactor; + shiftL_.setShiftRatio(shift); + const double shiftedL = static_cast(shiftL_.process(static_cast(srcL))); + outL = shiftedL * gain; + if (stereo) { + if (shiftR_.configured()) { + // Genuine stereo: an independent shifter transposes channel 1. Each shifter is + // process()'d EXACTLY ONCE per output frame (never twice — that would advance its + // heads twice and corrupt the OLA state). + shiftR_.setShiftRatio(shift); + outRlocal = + static_cast(shiftR_.process(static_cast(srcR))) * gain; + } else { + // Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted + // value from srcL (== srcR since pcmR aliases pcm); mirror it to R. Do NOT call + // shiftL_.process again this frame. + outRlocal = shiftedL * gain; + } + } + // Preserve advances the read head at the SOURCE rate (duration preserved). + ratio_ = 1.0; + } else { + // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch + // envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the + // envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical). + outL = srcL * gain; + if (stereo) outRlocal = srcR * gain; + ratio_ = baseRatio_ * envFactor; + } + + if (stereo) outR = static_cast(outRlocal); + readPos_ += ratio_; - if (env_.finished()) { + if (amplitudeDone_) { active_ = false; } return static_cast(outL); @@ -262,10 +480,27 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) { // --------------------------------------------------------------------------- VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, - const AdsrParams& adsr) - : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr) { + const AdsrParams& adsr, std::size_t preserveVoiceCap, + std::int64_t preserveWindowFrames) + : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr), + preserveVoiceCap_(preserveVoiceCap) { // maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on; // clamp to a single voice so the engine is always usable (documented degenerate). + // + // Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so + // note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one + // allocation point for the shifter rings across the engine's lifetime. + if (preserveWindowFrames > 1) { + for (Voice& v : voices_) v.presizePreserveShifters(preserveWindowFrames); + } +} + +std::size_t VoiceEngine::activePreserveVoices() const { + std::size_t n = 0; + for (const Voice& v : voices_) { + if (v.active() && v.pitchEngine() == PitchEngine::Preserve) ++n; + } + return n; } std::size_t VoiceEngine::allocateVoice() { @@ -305,6 +540,18 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) { } const SampleData& sample = keymap_.samples[zone.sampleIndex]; + // S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice + // OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on + // rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated). + // Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only + // NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch"). + if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve && + activePreserveVoices() >= preserveVoiceCap_) { + return kNoVoice; + } + + // The voice's Preserve shifters were pre-sized at engine construction (off-thread), so + // start() only reset()s + warm()s them — no allocation on this audio-thread path. const std::size_t v = allocateVoice(); voices_[v].start(note, velocity, sample, zone.rootNote, adsr_); voices_[v].setStartOrder(nextStartOrder_++); diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index ece219d..28d99d5 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -21,7 +21,8 @@ #include #include -#include "peaks.h" // AudioSample (float) +#include "peaks.h" // AudioSample (float) +#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) namespace reasampler { @@ -33,6 +34,92 @@ namespace reasampler { // itself never branches on it — the mode only picks which render overload the shell drives. enum class ChannelMode { Mono, Stereo }; +// --------------------------------------------------------------------------- +// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because +// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching +// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower +// with the rest of the engine machinery; only the value structs need to precede SampleData. +// --------------------------------------------------------------------------- + +// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack +// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below. +struct AdsrParams { + std::int64_t attackFrames = 0; + std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR + std::int64_t decayFrames = 0; + double sustainLevel = 1.0; // 0..1 + std::int64_t releaseFrames = 0; +}; + +// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's +// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, +// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone +// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. +enum class PlayMode { Gate, Trigger }; + +// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span +// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), +// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over +// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play +// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger. +struct TriggerParams { + double lengthFraction = 1.0; // (0,1] of the post-start span to play + std::int64_t fadeInFrames = 0; // 0->1 ramp at the head + std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd +}; + +// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default +// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) +// so a third curve can join without a signature change. +enum class FadeCurve { EqualPower, Linear }; + +// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted. +inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower; + +// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration +// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances +// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). +enum class PitchEngine { Varispeed, Preserve }; + +// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" +// directive). ONE constant to flip if Varispeed should be the default instead. This is the +// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's +// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core +// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16 +// engine" holds for the core's own regression tests (an octave up still halves duration in the +// bare engine); the Preserve product default is layered on above at (de)serialization. +inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; + +// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds +// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = +// smoother on big transpositions, more onset latency. One knob, resolved at voice allocation. +inline constexpr double kPreserveWindowMs = 50.0; + +// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always +// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to +// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack +// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-). +struct PitchEnvParams { + bool enabled = false; + std::int64_t attackFrames = 0; + std::int64_t decayFrames = 0; + double peakSemitones = 0.0; // signed depth at the peak +}; + +// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData +// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 +// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope +// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the +// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one +// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine. +struct ZonePlayParams { + PlayMode playMode = PlayMode::Gate; + AdsrParams adsr; // Gate: the AHDSR envelope + TriggerParams trigger; // Trigger: %-length + fades + PitchEngine pitchEngine = PitchEngine::Varispeed; + PitchEnvParams pitchEnv; // AD pitch modulation, off by default +}; + // --------------------------------------------------------------------------- // Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that // govern playback. The shell decodes the on-disk WAV and fills this; the core @@ -69,6 +156,11 @@ struct SampleData { // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. std::int64_t startFrame = 0; + // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch + // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is + // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData. + ZonePlayParams play; + // 2 iff a matching-length second channel exists; else 1. A framesR of a different // length than frames is treated as absent (mono) — a malformed pair never half-plays. int channelCount() const { @@ -134,30 +226,26 @@ struct Keymap { double pitchRatio(int note, int rootNote); // --------------------------------------------------------------------------- -// ADSR amplitude envelope. Sample-based (times in frames), linear segments. A gate: -// noteOn() enters Attack; noteOff() enters Release from wherever it is. The classic -// four-stage shape, asserted against a known signal in the tests (mirror of peaks). +// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based +// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters +// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks). // // Segment math (all linear ramps): // Attack: 0 -> 1 over attackFrames +// Hold: hold 1 over holdFrames (S15: NEW stage between A and D) // Decay: 1 -> sustainLevel over decayFrames // Sustain: hold sustainLevel until noteOff // Release: currentLevel -> 0 over releaseFrames -// A zero-length attack jumps straight to 1 on the first frame; zero decay jumps to -// sustain; a noteOff during attack/decay (release-before-sustain) releases from the -// current partial level, not from sustainLevel. +// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold +// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged); +// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain) +// releases from the current partial level, not from sustainLevel. AdsrParams is defined above +// (with the other per-zone value structs); this section holds only the per-frame evaluator. // --------------------------------------------------------------------------- -struct AdsrParams { - std::int64_t attackFrames = 0; - std::int64_t decayFrames = 0; - double sustainLevel = 1.0; // 0..1 - std::int64_t releaseFrames = 0; -}; - class AdsrEnvelope { public: - enum class Stage { Idle, Attack, Decay, Sustain, Release, Finished }; + enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished }; void configure(const AdsrParams& params) { params_ = params; } @@ -184,6 +272,64 @@ private: double releaseFrom_ = 0.0; // level at the moment noteOff() was called }; +// --------------------------------------------------------------------------- +// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams / +// FadeCurve value structs are defined above with the other per-zone params. +// --------------------------------------------------------------------------- + +// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated +// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output +// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so +// output and source frames coincide, but under Varispeed a transposed voice consumes source +// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME +// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The +// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by +// the play length and note-off-immune. Reports finished() once the offset reaches the play length. +class TriggerEnvelope { +public: + // Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the + // SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength + // (fadeOut anchored to the end). A zero/negative play length finishes immediately. + void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames, + std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve); + + // Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play + // span. Latches finished() once the offset reaches the play length (>= playLength). Pure over + // the offset (no internal advance) so it composes with either pitch engine's read rate. + double amplitudeAt(double sourceOffset); + + bool finished() const { return finished_; } + +private: + std::int64_t playLength_ = 0; + std::int64_t fadeIn_ = 0; + std::int64_t fadeOut_ = 0; + FadeCurve curve_ = kDefaultFadeCurve; + bool finished_ = false; +}; + +// --------------------------------------------------------------------------- +// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs +// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above. +// --------------------------------------------------------------------------- + +// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones +// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone +// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested +// for offset at t=0, peak at t=attack, and 0 at t=attack+decay. +class PitchEnvelope { +public: + void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; } + void noteOn() { pos_ = 0; } + + // Advance one frame, return this frame's pitch offset in semitones. + double tick(); + +private: + PitchEnvParams params_; + std::int64_t pos_ = 0; +}; + // --------------------------------------------------------------------------- // A single voice: one active note playing one repitched, enveloped sample. Reads // the sample by fractional frame position with linear interpolation, advancing by @@ -193,13 +339,21 @@ private: class Voice { public: // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference - // the caller must keep alive for the voice's lifetime — the Keymap owns it), - // repitched from `rootNote`, with `adsr` as the amplitude envelope. + // the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched + // from `rootNote`. `gateAdsr` is the effective Gate AHDSR (the engine supplies the + // instrument-wide attack/decay/sustain/release timing; the per-zone HOLD stage comes from + // sample.play.adsr.holdFrames, folded in here). The S15 play MODE + Trigger params and the + // S16 pitch ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST + // already be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s + // them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm + // silence pass settles the OLA taps before the first output frame (no cold-start click). + // Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no + // pitch env). void start(int note, int velocity, const SampleData& sample, int rootNote, - const AdsrParams& adsr); + const AdsrParams& gateAdsr); - // Gate off — begins the amplitude release. The voice keeps rendering (and looping, - // if it would) until the release finishes, then goes idle. + // Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in + // TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length). void release(); // True while this voice is producing (or about to produce) sound. @@ -211,6 +365,17 @@ public: std::uint64_t startOrder() const { return startOrder_; } void setStartOrder(std::uint64_t order) { startOrder_ = order; } bool releasing() const { return releasing_; } + // The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only + // meaningful while active(). + PitchEngine pitchEngine() const { return pitchEngine_; } + + // Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the + // audio thread (this allocates). The engine calls it once at construction so start() — which + // runs on the audio thread inside process() — never allocates: start() only reset()s + warm()s + // the already-sized rings. `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed + // instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap no-op + // in the underlying vector. + void presizePreserveShifters(std::int64_t windowFrames); // Renders one frame's contribution, advancing the read head and envelope by one // output frame. Returns 0.0 (and goes idle) once the envelope finishes or the @@ -229,19 +394,46 @@ public: private: // Shared read/advance for both render paths: computes the interpolated per-channel - // value(s) at the current read head, ticks the envelope once, advances the head, and + // value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies + // the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and // latches idle on exhaustion. `stereo` selects whether the second channel is read (and // returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value. AudioSample advanceFrame(bool stereo, AudioSample& outR); + // This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per + // output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the + // fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to + // source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope + // finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice. + double tickAmplitude(); + bool active_ = false; bool releasing_ = false; int note_ = 0; double velocityGain_ = 1.0; - double ratio_ = 1.0; // fractional frames advanced per output frame + double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio + double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame) double readPos_ = 0.0; // fractional frame index into the sample const SampleData* sample_ = nullptr; + + // S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only + // one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame + // stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle). + PlayMode playMode_ = PlayMode::Gate; AdsrEnvelope env_; + TriggerEnvelope trigEnv_; + std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin + std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused + bool amplitudeDone_ = false; // set when the active amplitude envelope finished + + // S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve + // (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel + // (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine. + PitchEngine pitchEngine_ = PitchEngine::Varispeed; + PitchEnvelope pitchEnv_; + PitchShifter shiftL_; + PitchShifter shiftR_; + std::uint64_t startOrder_ = 0; }; @@ -262,8 +454,19 @@ class VoiceEngine { public: // Builds an engine with `maxVoices` voices (the polyphony bound) playing from // `keymap`. The keymap must outlive the engine (the engine holds a reference — it - // reads zones and sample data through it, never copies PCM). - VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr); + // reads zones and sample data through it, never copies PCM). `adsr` is the instrument-wide + // Gate AHDSR timing (attack/decay/sustain/release); each zone's HOLD stage + play mode + + // pitch engine ride on its SampleData::play. `preserveVoiceCap` (S16) bounds how many + // Preserve-engine voices may sound at once (the shifter is materially heavier than + // Varispeed) — a Preserve note-on beyond the cap is dropped rather than glitching; 0 means + // "no separate Preserve cap" (bounded only by maxVoices). `preserveWindowFrames` is the OLA + // window (in OUTPUT frames) every voice's Preserve pitch shifters are PRE-SIZED to at + // construction (OFF the audio thread), so note-on (which runs in process()) never allocates; + // 0 leaves them pass-through (a Varispeed-only instrument pays no ring cost). The processor + // derives it from the host sample rate (kPreserveWindowMs). Defaulted so existing callers + // (and the pure-core tests) are unaffected. + VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr, + std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0); // MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of // zone) it is a defined no-op (no voice consumed). Otherwise allocates a free @@ -313,9 +516,14 @@ private: // one per the documented policy. Always returns a valid index (maxVoices >= 1). std::size_t allocateVoice(); + // Count of active Preserve-engine voices (for the S16 Preserve cap). A cheap running tally + // kept in sync at note-on/steal/free rather than rescanned per note. + std::size_t activePreserveVoices() const; + std::vector voices_; const Keymap& keymap_; AdsrParams adsr_; + std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap) std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started" }; diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp new file mode 100644 index 0000000..aca05d6 --- /dev/null +++ b/tests/test_pitch_shift.cpp @@ -0,0 +1,187 @@ +// Standalone tests for reasampler::PitchShifter — the S16 Preserve-engine DSP core. No VST3, +// no REAPER, no vendor, no test framework. The compile-time proof it does NOT drag the WDL +// chain is the CMake target linking only pitch_shift (+ peaks). +// +// Covers (PLAN.md S16 / CONTEXT.md §Pitch engine modes — Preserve): +// 1. duration invariance — N inputs yield N outputs at every shift ratio (the load-bearing +// Preserve property: a transposed render is the SAME frame length as the un-transposed one). +// 2. unity pass-through fidelity — ratio 1.0 reproduces the input closely (a shifter at unity +// must not mangle the signal). +// 3. transpose direction — an octave-up shift raises the observed pitch (period shortens), an +// octave-down lowers it (period lengthens), measured on a synthesized sine. +// 4. RT discipline surrogate — after configure()+warm() (the off-thread setup), a long +// process() run never resizes the ring (checked via window() constancy) and never returns +// NaN/inf; pass-through (unconfigured) returns input verbatim. + +#include "../src/vst/pitch_shift.h" + +#include +#include +#include + +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) + +static bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; } + +constexpr double kPi = 3.14159265358979323846; + +// A sine of `cycles` periods over `frames` frames. +static std::vector sine(std::size_t frames, double cycles) { + std::vector s(frames); + for (std::size_t i = 0; i < frames; ++i) { + s[i] = static_cast(std::sin(2.0 * kPi * cycles * + static_cast(i) / static_cast(frames))); + } + return s; +} + +// Average spacing between positive-going zero crossings (the observed period). +static double observedPeriod(const std::vector& out, std::size_t from) { + std::vector up; + for (std::size_t i = from + 1; i < out.size(); ++i) { + if (out[i - 1] <= 0.0f && out[i] > 0.0f) up.push_back(i); + } + if (up.size() < 2) return 0.0; + double sum = 0.0; + for (std::size_t i = 1; i < up.size(); ++i) sum += static_cast(up[i] - up[i - 1]); + return sum / static_cast(up.size() - 1); +} + +// --- 1. Duration invariance across shift ratios. --- +static void testDurationInvariance() { + // The core Preserve property: whatever the shift ratio, one input frame yields one output + // frame. So a shifter fed N frames produces exactly N frames — a transposed render is the + // same length as an un-transposed one (unlike Varispeed, where an octave up halves length). + const std::size_t n = 4000; + const std::vector in = sine(n, 40.0); + const double ratios[] = {0.5, 1.0, 2.0, std::pow(2.0, 7.0 / 12.0)}; + for (double r : ratios) { + PitchShifter ps; + ps.configure(2205); // ~50 ms @ 44.1k + ps.warm(); + ps.setShiftRatio(r); + std::size_t produced = 0; + for (std::size_t i = 0; i < n; ++i) { + const AudioSample o = ps.process(in[i]); + (void)o; + ++produced; // exactly one output per input, unconditionally. + } + CHECK(produced == n); // duration held at every ratio. + } +} + +// --- 2. Unity pass-through fidelity. --- +static void testUnityRoughlyReproduces() { + // At ratio 1.0 the shifter should reproduce the input's PITCH faithfully (the OLA taps run + // in lockstep with the writer). Amplitude/phase warble is allowed (basic OLA), but the + // observed period must match the source period within a small tolerance past the warm-up. + const std::size_t n = 8000; + const double cycles = 40.0; + const double nativePeriod = static_cast(n) / cycles; // 200 + const std::vector in = sine(n, cycles); + PitchShifter ps; + ps.configure(2205); + ps.warm(); + ps.setShiftRatio(1.0); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); + // Measure past the initial half-window latency region. + const double p = observedPeriod(out, 3000); + CHECK(p > 0.0); + CHECK(approx(p, nativePeriod, nativePeriod * 0.10)); // within 10% of source period +} + +// --- 3. Transpose direction: up shortens the period, down lengthens it. --- +static void testTransposeDirection() { + const std::size_t n = 12000; + const double cycles = 60.0; + const double nativePeriod = static_cast(n) / cycles; // 200 + const std::vector in = sine(n, cycles); + + // Octave up: output period ~ half the source period (higher pitch). + { + PitchShifter ps; + ps.configure(2205); + ps.warm(); + ps.setShiftRatio(2.0); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); + const double p = observedPeriod(out, 4000); + CHECK(p > 0.0); + CHECK(approx(p, nativePeriod / 2.0, nativePeriod * 0.15)); // period halves + } + // Octave down: output period ~ double the source period (lower pitch). + { + PitchShifter ps; + ps.configure(2205); + ps.warm(); + ps.setShiftRatio(0.5); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); + const double p = observedPeriod(out, 4000); + CHECK(p > 0.0); + CHECK(approx(p, nativePeriod * 2.0, nativePeriod * 0.30)); // period doubles + } +} + +// --- 4. RT discipline surrogate + pass-through. --- +static void testRtDisciplineAndPassthrough() { + // Unconfigured shifter passes input through verbatim (a Varispeed voice never allocates one). + { + PitchShifter ps; + CHECK(!ps.configured()); + CHECK(ps.process(0.37f) == 0.37f); // exact pass-through + CHECK(ps.process(-0.9f) == -0.9f); + } + // Configured: the window is fixed at configure() and never changes across a long run (no + // per-frame Resize), and no output is NaN/inf (numerically well-behaved OLA). + { + PitchShifter ps; + ps.configure(1024); + ps.warm(); + const std::int64_t w = ps.window(); + CHECK(w == 1024); + ps.setShiftRatio(std::pow(2.0, 5.0 / 12.0)); + const std::vector in = sine(20000, 100.0); + for (std::size_t i = 0; i < in.size(); ++i) { + const AudioSample o = ps.process(in[i]); + CHECK(std::isfinite(o)); + } + CHECK(ps.window() == w); // window unchanged -> ring never resized mid-run + } + // A non-positive shift ratio is ignored (keeps the last valid ratio) — never stalls/reverses. + { + PitchShifter ps; + ps.configure(512); + ps.warm(); + ps.setShiftRatio(1.0); + ps.setShiftRatio(-2.0); // ignored + ps.setShiftRatio(0.0); // ignored + for (int i = 0; i < 2000; ++i) CHECK(std::isfinite(ps.process(0.5f))); + } + // Degenerate window (<= 1) stays pass-through even after configure. + { + PitchShifter ps; + ps.configure(1); + CHECK(!ps.configured()); + CHECK(ps.process(0.25f) == 0.25f); + } +} + +int main() { + testDurationInvariance(); + testUnityRoughlyReproduces(); + testTransposeDirection(); + testRtDisciplineAndPassthrough(); + + if (g_fail == 0) { + std::printf("all pitch_shift tests passed\n"); + return 0; + } + std::printf("%d pitch_shift check(s) failed\n", g_fail); + return 1; +} diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 7763915..a238713 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -979,6 +979,113 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() { !back.map.zones[1].startPoint.has_value()); } +// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift ------------- + +static void testPlayParamsRoundTrip() { + // A zone carrying explicit S15/S16 play params (Trigger mode, hold, fades, Varispeed engine, + // pitch env on) must round-trip ALL fields losslessly through the payload-v3 tail. + PerformanceMap m; + PerformanceZone z = zone("lead", 20, 100, /*override=*/55); + z.play.playMode = PlayMode::Trigger; + z.play.adsr.holdFrames = 1234; + z.play.trigger.lengthFraction = 0.375; + z.play.trigger.fadeInFrames = 64; + z.play.trigger.fadeOutFrames = 128; + z.play.pitchEngine = PitchEngine::Varispeed; + z.play.pitchEnv.enabled = true; + z.play.pitchEnv.attackFrames = 10; + z.play.pitchEnv.decayFrames = 500; + z.play.pitchEnv.peakSemitones = -7.5; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const ZonePlayParams& p = back.zones[0].play; + CHECK(p.playMode == PlayMode::Trigger); + CHECK(p.adsr.holdFrames == 1234); + CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip + CHECK(p.trigger.fadeInFrames == 64); + CHECK(p.trigger.fadeOutFrames == 128); + CHECK(p.pitchEngine == PitchEngine::Varispeed); + CHECK(p.pitchEnv.enabled == true); + CHECK(p.pitchEnv.attackFrames == 10); + CHECK(p.pitchEnv.decayFrames == 500); + CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip +} + +static void testPlayParamsComposeWithLoopStart() { + // S11 (loop/start) x S15/S16 (play params) tails co-exist per zone: both round-trip together. + PerformanceMap m; + PerformanceZone z = zone("pad", 0, 60); + SampleLoop lp; lp.hasLoop = true; lp.start = 111; lp.end = 222; + z.loopOverride = lp; + z.startPoint = 333; + z.play.playMode = PlayMode::Gate; + z.play.adsr.holdFrames = 999; + z.play.pitchEngine = PitchEngine::Preserve; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + CHECK(back.zones[0].loopOverride.has_value() && + back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222); + CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333); + CHECK(back.zones[0].play.adsr.holdFrames == 999); + CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); +} + +static void testPlayParamsV2BackCompatLiftsToDefaults() { + // A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail) + // lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the + // deliberate behavior change for already-saved instruments. Hand-build a v2 record exactly. + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + u32(kPerformanceStateVersion); // envelope version (2) + u32(kZonesFormatMarker); // marker -> a versioned payload + u32(2); // PAYLOAD VERSION 2 (S11, no play tail) + u32(1); // zone count 1 + const std::string id = "old"; + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(5); // lowNote + u32(80); // highNote + b.push_back(0); // hasRootOverride = 0 + b.push_back(0); // hasLoopOverride = 0 + b.push_back(0); // hasStartPoint = 0 (record ends here in v2) + const PerformanceMap back = deserializePerformance(b); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + CHECK(back.zones[0].sampleId == "old"); + // Lifted to product defaults: Gate play mode, PRESERVE engine (the S16-F1 default), env off. + CHECK(back.zones[0].play.playMode == PlayMode::Gate); + CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve + CHECK(back.zones[0].play.pitchEnv.enabled == false); + CHECK(back.zones[0].play.adsr.holdFrames == 0); +} + +static void testPlayParamsThroughComponentEnvelope() { + // The play params round-trip through the v4 COMPONENT envelope too (the composition property: + // the zones payload is envelope-independent, so v4 {channelMode, selection, zones} carries them). + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + PerformanceZone z = zone("z", 0, 127); + z.play.playMode = PlayMode::Trigger; + z.play.trigger.lengthFraction = 0.9; + z.play.pitchEngine = PitchEngine::Varispeed; + s.map.zones.push_back(z); + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.channelMode == ChannelMode::Stereo); + CHECK(back.map.zones.size() == 1); + if (back.map.zones.size() != 1) return; + CHECK(back.map.zones[0].play.playMode == PlayMode::Trigger); + CHECK(back.map.zones[0].play.trigger.lengthFraction == 0.9); + CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed); +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -1026,6 +1133,10 @@ int main() { testPerformanceStateV1BackCompat(); testPerformanceStateGarbage(); testPerformanceStateNegativeNotesRoundTrip(); + testPlayParamsRoundTrip(); + testPlayParamsComposeWithLoopStart(); + testPlayParamsV2BackCompatLiftsToDefaults(); + testPlayParamsThroughComponentEnvelope(); testComponentStateRoundTrip(); testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 8d25484..b7282a4 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -829,6 +829,354 @@ static void testStereoStartFrameLoopShareOneReadHead() { } } +// =========================================================================== +// S15 — sampling modes (Gate AHDSR hold stage, Trigger %-length + fades, note-off immunity). +// =========================================================================== + +// --- AHDSR hold stage vs a known signal. --- +static void testAhdsrHoldStageShape() { + // Gate grows a HOLD stage between Attack and Decay: attack 0->1 (5f), HOLD at 1.0 (8f), + // decay 1->0.5 (5f), sustain 0.5. Assert the hold plateau is exactly 1.0 for holdFrames. + AdsrParams p; + p.attackFrames = 5; + p.holdFrames = 8; + p.decayFrames = 5; + p.sustainLevel = 0.5; + p.releaseFrames = 5; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + + for (int i = 0; i < 5; ++i) env.tick(); // consume Attack (ends at 1.0) + // The next holdFrames ticks must all be exactly 1.0 (the plateau), stage == Hold. + for (int i = 0; i < 8; ++i) { + CHECK(env.stage() == AdsrEnvelope::Stage::Hold); + CHECK(approx(env.tick(), 1.0, 1e-9)); + } + // Then Decay begins, falling from 1.0 toward sustain 0.5. + CHECK(env.stage() == AdsrEnvelope::Stage::Decay); + double v = env.tick(); + CHECK(v <= 1.0 + 1e-9 && v >= 0.5 - 1e-9); +} + +// --- hold == 0 is byte-identical to the pre-S15 ADSR (back-compat regression). --- +static void testAhdsrHoldZeroEqualsAdsr() { + // The load-bearing back-compat guarantee: hold=0 reproduces the classic ADSR frame-for-frame. + // Assert against a HAND-COMPUTED expected sequence (not another envelope — that would be + // tautological). attack 4, hold 0, decay 4, sustain 0.5. Expected per-tick output: + // Attack: 0/4, 1/4, 2/4, 3/4 (ticks 0..3, level rising 0 -> 0.75) + // Decay: 1.0, then 1.0+(0.5-1)*t for t=1/4..3/4 (ticks 4..7: 1.0, 0.875, 0.75, 0.625) + // Sustain: 0.5 forever (tick 8+) + AdsrParams p; + p.attackFrames = 4; + p.holdFrames = 0; // the degenerate — must NOT insert an extra unity frame + p.decayFrames = 4; + p.sustainLevel = 0.5; + p.releaseFrames = 4; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + const double expected[] = {0.0, 0.25, 0.5, 0.75, // attack + 1.0, 0.875, 0.75, 0.625, // decay (first sample 1.0 at t=0) + 0.5, 0.5, 0.5}; // sustain + for (double e : expected) CHECK(approx(env.tick(), e, 1e-9)); + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); // reached sustain at the SAME tick count +} + +// A trigger-mode DC sample (all 1.0) so a rendered voice's output tracks the trigger envelope +// * velocity directly. `play` sets Trigger mode + params; Varispeed so no shift colours the amp. +static SampleData triggerSample(std::size_t frames, double lengthFraction, + std::int64_t fadeIn, std::int64_t fadeOut, + std::int64_t startFrame = 0) { + SampleData s = dcSample(frames, 60); + s.startFrame = startFrame; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; // isolate amp shape from pitch + s.play.trigger.lengthFraction = lengthFraction; + s.play.trigger.fadeInFrames = fadeIn; + s.play.trigger.fadeOutFrames = fadeOut; + return s; +} + +// --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. --- +static void testTriggerLengthFractionFrames() { + // 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees. + Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio + std::vector out; + eng.render(out, 200); + // First 100 frames sound (amp>0 for a no-fade trigger = 1.0), then silence + voice freed. + for (std::size_t i = 0; i < 100; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 100; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); // ran off playEnd +} + +// --- Trigger start point: %-length measured from the start offset. --- +static void testTriggerLengthWithStart() { + // 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free. + Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 200); + for (std::size_t i = 0; i < 80; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 80; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); +} + +// --- Trigger fade-in / fade-out ramp shape (equal-power default). --- +static void testTriggerFadeShape() { + // 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity + // between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1. + Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 120); + CHECK(approx(out[0], 0.0, 1e-3)); // fade-in starts at 0 + // Fade-in monotonic non-decreasing. + for (std::size_t i = 1; i < 20; ++i) CHECK(out[i] >= out[i - 1] - 1e-4); + // Unity plateau in the middle. + for (std::size_t i = 25; i < 75; ++i) CHECK(approx(out[i], 1.0, 1e-3)); + // Fade-out monotonic non-increasing over [80,100). + for (std::size_t i = 81; i < 100; ++i) CHECK(out[i] <= out[i - 1] + 1e-4); + // Past playEnd = silence. + for (std::size_t i = 100; i < 120; ++i) CHECK(approx(out[i], 0.0, 1e-6)); +} + +// --- Trigger edge cases: %=0 (immediate free) and fades overlapping (clamped). --- +static void testTriggerEdgeCases() { + // %=0: zero play length -> voice frees at once, no sound. + { + Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 50); + for (float v : out) CHECK(approx(v, 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); + } + // Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]). + { + // 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped. + Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 50); + for (std::size_t i = 0; i < 40; ++i) CHECK(out[i] >= -1e-4 && out[i] <= 1.0 + 1e-4); + CHECK(eng.activeVoiceCount() == 0); + } + // %=100 plays the full post-start span. + { + Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 80); + for (std::size_t i = 0; i < 60; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 60; i < 80; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + } +} + +// --- Trigger ignores note-off (S15): the one-shot plays through regardless. --- +static void testTriggerIgnoresNoteOff() { + Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 10); + eng.noteOff(60); // must be a NO-OP in Trigger + CHECK(eng.activeVoiceCount() == 1); // still sounding after note-off + eng.render(out, 200); + // It still plays its full 100-frame length (frames 10..99 remain > 0 after the note-off). + for (std::size_t i = 10; i < 100; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 100; i < 210; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); // frees on its own playEnd, not on note-off +} + +// =========================================================================== +// S16 — pitch engine (Preserve duration invariance) + pitch envelope (off = identical). +// =========================================================================== + +// Render one note to completion (or `maxFrames`) and return the frame count at which the voice +// went idle (the audible LENGTH). A Gate note with a short release + a finite sample runs off. +static std::size_t soundingLength(VoiceEngine& eng, std::size_t maxFrames) { + std::vector out; + std::size_t len = 0; + for (std::size_t f = 0; f < maxFrames; ++f) { + eng.render(out, 1); + if (eng.activeVoiceCount() > 0) len = f + 1; + else break; + } + return len; +} + +// A Preserve-engine one-shot Trigger sample: under Preserve, the %-length wall-clock is stable +// under transpose (the S15xS16 contract). Trigger + Preserve isolates the length measurement from +// Gate's release tail. +static SampleData preserveTriggerSample(std::size_t frames, double lengthFraction) { + SampleData s = dcSample(frames, 60); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Preserve; + s.play.trigger.lengthFraction = lengthFraction; + return s; +} + +// --- Preserve duration invariance: same note length across +/-12 semitones. --- +static void testPreserveDurationInvariance() { + // A Preserve Trigger at 100% length of a 1000-frame sample plays ~1000 output frames + // regardless of transpose (duration held). Under Varispeed an octave up would halve it. + const std::size_t frames = 1000; + const std::size_t window = 512; // pre-size the shifters + + auto lengthAt = [&](int note) -> std::size_t { + Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0)); + VoiceEngine eng(1, km, flatAdsr(), /*preserveCap=*/0, /*window=*/static_cast(window)); + eng.noteOn(note, 127); + return soundingLength(eng, 4000); + }; + + const std::size_t atRoot = lengthAt(60); + const std::size_t atUp = lengthAt(72); // +12 + const std::size_t atDown = lengthAt(48); // -12 + // All three within a small tolerance of the source length (Preserve holds duration). The + // tolerance covers the shifter's fill/latency edge, not a duration scaling (which would be 2x). + CHECK(atRoot >= frames - 20 && atRoot <= frames + 20); + CHECK(atUp >= frames - 20 && atUp <= frames + 20); + CHECK(atDown >= frames - 20 && atDown <= frames + 20); + // The decisive assertion: the up/down lengths track the root length (NOT halved/doubled). + CHECK(atUp > frames / 2 + 200); // an octave up did NOT halve the duration (Varispeed would) + CHECK(atDown < frames * 2 - 200); // an octave down did NOT double it +} + +// --- Varispeed still couples duration (the contrast to Preserve — regression on the old default). --- +static void testVarispeedStillCouplesDuration() { + // A Varispeed Trigger octave up runs off in ~half the frames (pitch & duration coupled). + auto lengthAt = [&](int note) -> std::size_t { + SampleData s = dcSample(1000, 60); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.trigger.lengthFraction = 1.0; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(note, 127); + return soundingLength(eng, 4000); + }; + const std::size_t atRoot = lengthAt(60); + const std::size_t atUp = lengthAt(72); + CHECK(approx(static_cast(atUp), static_cast(atRoot) / 2.0, 30.0)); +} + +// --- Pitch envelope OFF == bit-identical to the un-modulated engine (regression). --- +static void testPitchEnvOffBitIdentical() { + // Two Varispeed voices, one with a disabled pitch env, one with no pitch env at all. Their + // rendered output must be BIT-IDENTICAL (pitch-env-off applies zero modulation — the S16 + // "identical to pre-S16" guarantee). Uses a sine so any pitch drift would show as phase drift. + const std::size_t n = 4000; + auto renderOne = [&](bool withDisabledEnv) -> std::vector { + SampleData s = sineSample(n, 20.0, 60); + s.play.pitchEngine = PitchEngine::Varispeed; + if (withDisabledEnv) { + s.play.pitchEnv.enabled = false; // explicitly disabled (offset always 0) + s.play.pitchEnv.peakSemitones = 12.0; // a depth that WOULD matter if enabled + s.play.pitchEnv.attackFrames = 0; + s.play.pitchEnv.decayFrames = 500; + } + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path) + std::vector out; + eng.render(out, n); + return out; + }; + const std::vector a = renderOne(false); + const std::vector b = renderOne(true); + CHECK(a.size() == b.size()); + bool identical = a.size() == b.size(); + for (std::size_t i = 0; i < a.size() && identical; ++i) { + if (a[i] != b[i]) identical = false; + } + CHECK(identical); // disabled pitch env produces the EXACT same samples (no modulation) +} + +// --- Pitch envelope ON biases pitch (Varispeed): a positive-peak zero-attack env starts sharp. --- +static void testPitchEnvOnBendsVarispeed() { + // Zero attack + positive peak = "start high, drop to base": the note begins transposed UP and + // settles. Observe the read advancing FASTER at the start (period shorter early) than late. + const std::size_t n = 8000; + SampleData s = sineSample(n, 40.0, 60); + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.attackFrames = 0; // start at the peak + s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames + s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0 + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend + std::vector out; + eng.render(out, 4000); + // Early period (heavily transposed up) should be shorter than the late period (settled). + std::vector early(out.begin(), out.begin() + 800); + std::vector late(out.begin() + 3200, out.begin() + 4000); + const double pe = observedPeriodFrames(early); + const double pl = observedPeriodFrames(late); + CHECK(pe > 0.0 && pl > 0.0); + CHECK(pe < pl); // pitch dropped over time (period lengthened) -> the AD env bent the pitch +} + +// --- Compose: engine x mode x stereo x loop (a Preserve Gate loop in stereo sounds + sustains). --- +static void testPreserveGateStereoLoopComposes() { + // A STEREO sample, GATE mode, PRESERVE engine, with a sustain loop. It must sound on BOTH + // channels and sustain (the loop keeps the voice alive) — S7 x S15 x S16 all composing. + SampleData s; + const std::size_t frames = 400; + s.frames.resize(frames); + s.framesR.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + const float v = static_cast(std::sin(2.0 * kPi * 8.0 * + static_cast(i) / static_cast(frames))); + s.frames[i] = v; + s.framesR[i] = v * 0.5f; // R is a distinct (half-amplitude) channel + } + s.rootNote = 60; + s.loop.hasLoop = true; + s.loop.start = 100; + s.loop.end = 300; + s.play.playMode = PlayMode::Gate; + s.play.pitchEngine = PitchEngine::Preserve; + CHECK(s.channelCount() == 2); + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr(), 0, 512); + eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held) + std::vector left(2000, 0.f), right(2000, 0.f); + eng.render(left.data(), right.data(), 2000); + // The loop sustains the voice well past the sample length (400 frames) -> still active. + CHECK(eng.activeVoiceCount() == 1); + // Both channels carry signal (some frame has non-trivial magnitude on each). + double maxL = 0.0, maxR = 0.0; + for (std::size_t i = 600; i < 2000; ++i) { + if (std::fabs(left[i]) > maxL) maxL = std::fabs(left[i]); + if (std::fabs(right[i]) > maxR) maxR = std::fabs(right[i]); + } + CHECK(maxL > 0.05); + CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved +} + +// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. --- +static void testPreserveVoiceCap() { + SampleData s = dcSample(2000, 60); + s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + // 8 voices total, Preserve cap of 2. + VoiceEngine eng(8, km, flatAdsr(), /*preserveCap=*/2, /*window=*/256); + CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice + CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap) + CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap + CHECK(eng.activeVoiceCount() == 2); +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -863,6 +1211,23 @@ int main() { testStereoRenderNullBufferIsNoOp(); testStereoStartFrameLoopShareOneReadHead(); + // S15 — sampling modes. + testAhdsrHoldStageShape(); + testAhdsrHoldZeroEqualsAdsr(); + testTriggerLengthFractionFrames(); + testTriggerLengthWithStart(); + testTriggerFadeShape(); + testTriggerEdgeCases(); + testTriggerIgnoresNoteOff(); + + // S16 — pitch engine + pitch envelope. + testPreserveDurationInvariance(); + testVarispeedStillCouplesDuration(); + testPitchEnvOffBitIdentical(); + testPitchEnvOnBendsVarispeed(); + testPreserveGateStereoLoopComposes(); + testPreserveVoiceCap(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0; From 0e9ef05c75556e2c2e926b6a0b2e7bec1820932c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 23:37:53 -0400 Subject: [PATCH 36/49] =?UTF-8?q?docs(product):=20spec=20S18=20=E2=80=94?= =?UTF-8?q?=20VST3=20channel=20isolation=20(beta=20ReaSampler=209000=20pai?= =?UTF-8?q?rs=20with=20beta=20extension=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 57 ++++++++++++++++++++ PLAN.md | 98 +++++++++++++++++++++++++++++++++++ docs/product/midi-playback.md | 45 ++++++++++++++++ 3 files changed, 200 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index d8c23f4..900a191 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2001,6 +2001,63 @@ a multi-capture drag over the FX button is either rejected or loads the first). must construct exactly ReaSampler 9000's state bytes — the cross-artifact contract still being defined in Phase S. Blocks the drop half until the blob format is agreed. +## VST3 channel identity — the UID pair + the pairing surface (S18; extends Phase V V4) + +**Decided (Daniel, 2026-07-26):** the beta/stable channel split Phase V V4 gave the +*extension* extends to the **ReaSampler 9000 VST3 instrument** — a beta-built VST pairs with +the beta extension only, a stable VST with stable only, both installable side-by-side in one +REAPER. This is the instrument-side companion to V4 and mirrors its philosophy exactly: +**one channel per binary; all channel identity derives from the ONE +`REASAMPLER_CHANNEL_IS_BETA` bit via the pure `app_version` module — no scattered `#ifdef`s +in the VST shell.** + +**What is already isolated (structural, not added by S18).** The wire/data pairing is +already done and needs no per-key work: `ext_keys.h`'s `kProjExtNamespace()` delegates to +`app_version::extStateNamespace()`, so a beta-compiled VST's bridge reads `"reasampler_beta"`. +Every wire key — `banks`, `assign_request`, S9's bank-generation key (in-flight), S17's +component-state contract, and **any future key** — is a plain constant *under* that +namespace, so channel data-isolation is **structural: no per-key opt-in, and a future key +that forgets to isolate is impossible by construction** (it keys off the namespace accessor, +not a raw literal). What S18 adds is only the missing *plugin identity* layer. + +- **The UID-pair invariant (the permanent commitment).** The VST3 class UID is the plugin's + identity — a saved REAPER project records it and rebinds a saved instance by it. Today + `reasampler_vst.h` holds **one** forever-locked UID (`kReaSamplerProcessorUID`, + `REASAMPLER_PROC_UID_1..4`, S-NAME-1). A beta VST with the **same** UID cannot coexist with + stable in one install (same UID = identity collision / arbitrary rebind). So beta needs its + **own** forever-stable UID: a second constant, minted once, locked exactly as the first. + **Invariant: BOTH UIDs are frozen forever once shipped; the channel bit selects which is + compiled into this binary** (one `DEF_CLASS2`, one class per binary — not both classes in + one binary; that mirrors V4's fully-isolated-binary philosophy and keeps a beta build from + ever presenting the stable identity). Saved-project isolation follows directly: a project + saved with beta instances rebinds only to the beta VST; a stable-saved instance opened + where only the beta extension has banks resolves the stable UID and shows a clean empty + "pick a capture" state (S10 policy), not an error. +- **Binary + display identity, channel-derived.** Mirror the extension's `OUTPUT_NAME` fork + (`reaper_reasampler` / `reaper_reasampler_beta`): the VST3 module's on-disk name forks + `reasampler_9000` / `reasampler_9000_beta`, its factory display name "ReaSampler 9000" / + "ReaSampler 9000 beta", and its editor title band + S6 embed-strip label are channel-aware + — **all sourced from `app_version` channel accessors (a VST-name accessor beside + `binaryName()`/`dockTitle()`), never a literal in `reasampler_vst.h`/`vst_entry.cpp`.** The + factory version string carries the `-beta` render where V4's `appVersion()` already does; + vendor/url/email stay shared unless V4 qualified the equivalent (V4 kept the lane-name + prefix shared — shared-where-V4-shares is the default). +- **The complete pairing surface (the guarantee to state, not new code).** A channel's VST + talks to that channel's extension **only**, because (1) plugin identity — UID + filename + + display — is channel-forked (above), and (2) **all** wire keys live under the + channel-derived `kProjExtNamespace()`. The two together make pairing complete and + structural: identity keeps the *plugins* distinct; the namespace keeps the *data* distinct. + No per-key or per-seam isolation work is ever needed — S8's assignment key, S9's generation + key, and S17's blob-injection key all inherit it. **Verify all identity/factory wiring + against the vendored Steinberg SDK** (`DEF_CLASS2` / `INLINE_UID` / `FUID` from + `pluginfactory.h` + `funknown.h`); the pure `app_version` name accessors are CTest-tested. +- **Fork S18-F1 (flagged — Daniel's call): mint the beta UID now vs. at first beta release.** + Lean **mint now** — mirrors the stable UID (minted at the S1 spike, locked long before + ship), removes a "remember to mint before shipping beta" landmine, zero cost for an + unused-until-beta constant. The alternative (a locked-once placeholder replaced before the + first beta VST ships) defers the commitment but adds a release-gate step. Flagged only + because the UID is a forever commitment. + ## Non-goals / guardrails - **The instrument never captures and never inserts into the arrange.** Playback is a diff --git a/PLAN.md b/PLAN.md index 1deb3f1..01882ab 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1027,6 +1027,104 @@ artifact, not extension-only. single- and multi-capture payloads; FX-button hit resolution (pure geometry where it can be factored out); M11 OS drag-out and internal bank-to-bank drag both unchanged. +## S18 — VST3 channel isolation: a beta ReaSampler 9000 that pairs with the beta extension only +**Goal:** Extend Phase V's beta/stable channel split (V4 — the *extension* is fully +isolated per channel) to the **ReaSampler 9000 VST3 instrument**, so a beta-built VST is a +distinct plugin that pairs only with the beta extension, and a stable VST pairs only with +stable — installable side-by-side in one REAPER with no collision. **What already works +(established from the tree, not re-derived): data pairing is done.** `ext_keys.h`'s +namespace is channel-derived (`kProjExtNamespace()` → `app_version::extStateNamespace()`), +so a beta-compiled VST already *reads* `"reasampler_beta"`; every wire key (`banks`, +`assign_request`, S9's future generation key, any future key) is a plain constant *under* +that namespace, so channel data-isolation is **structural, not per-key** — new keys inherit +it automatically. **What is missing is the VST's *plugin identity*:** its class UID, binary +filename, and display strings are single-valued today (same for both channels → a +UID/filename collision if both are installed). This wave closes that. Mirrors V4's +philosophy: **one channel per binary; all identity derives from the ONE +`REASAMPLER_CHANNEL_IS_BETA` bit via `app_version`, no scattered `#ifdef`s.** CONTEXT.md +§Phase S (VST3 channel identity — the UID-pair invariant + the pairing surface). Product +framing: `docs/product/midi-playback.md` §Addendum — VST channel isolation. +**Consistent with V4 (make the invariant explicit):** V4 already committed the extension to +*two* forever-stable command-id families and *two* forever-stable ext-state namespaces. This +wave commits the *instrument* to the parallel permanent cost: **two forever-stable VST3 class +UIDs** — the existing stable UID (S-NAME-1, locked) and a second beta UID (minted once, +locked forever the same way). Both are frozen forever; the channel bit selects which is +*compiled into this binary*. **One class per binary, not both in one binary** — this mirrors +V4's fully-isolated-binary philosophy (a channel build carries only its own identity), keeps +the factory registration a single `DEF_CLASS2`, and means a beta-installed VST cannot present +the stable identity. +**Verify (in DAW):** the stable VST3 (`reasampler_9000.vst3`, existing UID) and a beta VST3 +(`reasampler_9000_beta.vst3`, the new UID) install side-by-side in one REAPER and both appear +in the FX browser as distinct plugins ("ReaSampler 9000" and "ReaSampler 9000 beta"); a beta +instance reads only the beta extension's banks (its browser shows the beta banks, never +stable's) and a stable instance only stable's; a project saved with a **beta** instance +reopens rebinding to the beta VST (not the stable one) and restores its state; a project +saved with a **stable** instance opened where only the beta extension has banks shows a clean +empty "pick a capture" state (S10 policy), **not** an error; the S-NAME-1 save-rename-reopen +compat test extends to the beta UID (a beta instance rebinds by its own UID). **Nothing plays +differently** — this is an identity/pairing wave, no engine change. +**Depends on:** V4 (the `app_version` channel-identity single-source this extends), S1 (the +VST3 factory + `reasampler_vst.h` identity constants + the CMake second target it edits). +Consumes the S4 live-state seam only to *demonstrate* pairing; adds no new seam. **Disjoint +from the in-flight waves:** touches `vst_entry.cpp`, `reasampler_vst.h`, and the CMake VST3 +block — S9 (ext_keys additions) and S15/S16 (processor/editor) are elsewhere. **ext_keys +coordination note:** S9's new generation key is a plain constant under the channel-derived +namespace, so it inherits this isolation with no S18 change; no coordination edit is needed — +only the shared awareness that all wire keys must live under `kProjExtNamespace()` (already +the rule). Dispatchable in parallel with, or immediately after, the in-flight waves. + +- [ ] **Beta VST3 class UID (the permanent commitment).** Mint a second FOREVER-STABLE + class UID in `reasampler_vst.h` alongside the existing `kReaSamplerProcessorUID` + (`REASAMPLER_PROC_UID_1..4`) — a distinct forever-frozen constant (e.g. + `REASAMPLER_PROC_UID_BETA_1..4` + `kReaSamplerProcessorUIDBeta`). The channel bit selects + which UID the factory registers (`DEF_CLASS2`'s `INLINE_UID` + the runtime `FUID`) — + compile-time, one class per binary. **Both UIDs are frozen forever once shipped** — the + same lock the stable UID carries (a saved project records the UID and rebinds by it; a + changed UID orphans every saved beta instance). Record the invariant in the header's + UID-lock comment. **Fork S18-F1 (flagged — Daniel's call):** whether the beta UID is + minted *now* (locked from this wave) or deferred to *first beta release* of the VST + (leaving a compile-time placeholder that must be replaced-once-and-locked before any beta + VST ships). Lean: **mint now** — mirrors how the stable UID was minted at the S1 spike + (locked long before ship), removes a "remember to mint before shipping" landmine, and the + cost of an unused-until-beta constant is zero. Flagged because it is a forever commitment. +- [ ] **Channel-derived binary + display identity (no scattered `#ifdef`s — the V4 + invariant).** Route all VST identity through `app_version` accessors, mirroring how the + extension's `OUTPUT_NAME` already derives from `REASAMPLER_OUTPUT_NAME`. (a) **Binary + name:** the CMake VST3 target's `OUTPUT_NAME` forks by channel — `reasampler_9000` + (stable) / `reasampler_9000_beta` (beta) — driven by the same `REASAMPLER_CHANNEL` config + the extension target reads (add a `REASAMPLER_VST_OUTPUT_NAME` beside + `REASAMPLER_OUTPUT_NAME`, or reuse the one channel bit; **check what the extension does and + mirror it exactly**). (b) **Display name:** the factory `DEF_CLASS2` plug-in display string + is "ReaSampler 9000" / "ReaSampler 9000 beta" — sourced from an `app_version` VST-name + accessor (a new channel-derived accessor beside `binaryName()`/`dockTitle()`, e.g. + `vstPluginName()`), not a literal in `reasampler_vst.h`/`vst_entry.cpp`. (c) **Editor title + band + S6 embed-strip label** are channel-aware from the same accessor. All fan out from the + ONE channel bit — no per-file `#ifdef`. +- [ ] **Factory vendor/version strings channel-aware where V4 does the equivalent.** The + `BEGIN_FACTORY` vendor/url/email and the `PClassInfo2` version string + (`REASAMPLER_VST_VERSION`, currently a fixed `"0.1.0.0"`) align with the channel where the + extension's V4 equivalents do — the version display carries the `-beta` render + (`appVersion()` already yields `"0.9.01-beta"` on beta); the vendor strings stay shared + unless V4 qualified them (**check V4's treatment and match it** — V4 kept the lane-name + prefix shared, so shared-where-V4-shares is the default). No new forever-stable string + beyond the UID and the id-family V4 already owns. +- [ ] **Pairing-surface invariant recorded (no new code — a documented guarantee).** Write + the complete pairing surface as an invariant in CONTEXT.md: a channel's VST talks to that + channel's extension **only**, because (1) plugin identity (UID + filename + display) is + channel-forked here, and (2) **all** wire keys — `banks`, `assign_request`, S9's + generation key, and any future key — live under the channel-derived `kProjExtNamespace()`, + so the beta VST's bridge reads only `"reasampler_beta"`. The invariant to write: **channel + isolation is structural — no per-key opt-in — so a future wire key that forgets to isolate + is impossible by construction** (it keys under the namespace accessor, not a raw literal). + This is the guard that S9/S8/S17's cross-artifact keys inherit isolation for free. +- [ ] **DAW-verify contract (the acceptance gate, no unit test — identity is a shell fact).** + Both channels installed side-by-side: each browser sees only its channel's banks; a + stable-project + beta-VST opens clean-empty (not error); the S-NAME-1 rename/rebind compat + test extended to the beta UID (save a beta instance, confirm it rebinds by the beta UID on + reopen). The pure `app_version` accessors (binary/display name per channel) are CTest-green + in `app_version_tests` (extend the existing V4 channel-string assertions to the new VST + accessors); the UID selection + factory wiring are DAW-verified (no host-free test path). + ## Phase S — held and optional-forever (noted, not specified) - **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun), full ADSR, per-sample tuning/gain trim, sustain loops. The next depth increment once diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index 88b3a92..ddc1ddc 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -918,6 +918,51 @@ This reshapes S16 and **flips the r7 WDL verdict** on `WDL_SimplePitchShifter`. with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16 interaction note; CONTEXT.md §Pitch engine modes — Varispeed vs Preserve + the corrected WDL surface finding. +### Addendum — VST channel isolation (Daniel, 2026-07-26) + +**Daniel's directive (2026-07-26, settled):** *"support the beta/stable channels for the VST +as well. The VST in beta should talk to the beta extension only."* This extends Phase V's V4 +beta/stable split — which fully isolated the **extension** per channel — to the **ReaSampler +9000 VST3 instrument**. Spec'd as **S18**, an immediate Phase S wave, dispatchable in parallel +with or right after the in-flight waves (S9 ext_keys, S15/S16 processor/editor) — it touches +`vst_entry.cpp` / `reasampler_vst.h` / the CMake VST3 block, mostly disjoint from those. + +**Established honestly from the tree — what already works vs. what's missing:** +- **Already isolated (the V4↔S4 reconcile did this): data pairing.** A beta-built VST already + *reads* the beta namespace — `ext_keys.h`'s `kProjExtNamespace()` delegates to + `app_version::extStateNamespace()`, and every wire key (`banks`, `assign_request`, S9's + generation key, S17's blob key, any future key) is a plain constant *under* that namespace. + Channel data-isolation is therefore **structural, not per-key** — new keys inherit it for + free. No S18 work here. +- **Missing: the VST's *plugin identity*.** Its class UID, binary filename, and display + strings are single-valued (same for both channels), so two installed channels would collide + on UID and filename. S18 closes exactly this. + +**The shape of S18 (mirrors V4's philosophy — one channel per binary, one bit drives it):** +1. **A UID pair.** The stable class UID is locked forever (S-NAME-1). Beta needs its own + forever-stable UID (a second constant, minted once, locked identically). Both frozen + forever; the channel bit selects which is compiled in. **One class per binary, not both** + — the V4 fully-isolated-binary philosophy, so a beta build never presents the stable + identity. Saved-project isolation follows: a beta-saved instance rebinds only to the beta + VST. *Fork S18-F1 (Daniel's call):* mint the beta UID **now** (lean — mirrors the stable + UID minted at the S1 spike, removes a pre-ship landmine, zero cost unused) vs. defer to + first beta release behind a locked-once placeholder. +2. **Channel-derived binary + display identity.** `reasampler_9000` / `reasampler_9000_beta` + filename (mirror the extension's `OUTPUT_NAME` fork); "ReaSampler 9000" / "ReaSampler 9000 + beta" display; editor title + embed label channel-aware — all from the ONE bit via + `app_version` accessors, no scattered `#ifdef`s (the V4 invariant). +3. **The pairing guarantee, stated as an invariant.** A channel's VST talks to that channel's + extension only, because identity keeps the plugins distinct and the channel-derived + namespace keeps the data distinct. **Structural, not per-key** — S8/S9/S17's cross-artifact + keys all inherit it; a future key that forgets to isolate is impossible by construction. +4. **DAW-verify contract.** Both channels installed side-by-side; each browser sees only its + channel's banks; stable-project + beta-VST = clean empty (not error); the S-NAME-1 + rename/rebind test extends to the beta UID. + +**Where the spec lives:** PLAN.md §S18; CONTEXT.md §VST3 channel identity — the UID pair + the +pairing surface. The pairing surface's data half is already load-bearing V4 machinery; S18 +adds only the identity fork on top. + --- ## Where this landed From fe55a9a96e7e3ab596acfb9883a58aabd1472817 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 23:54:10 -0400 Subject: [PATCH 37/49] =?UTF-8?q?feat(vst):=20S18=20VST3=20channel=20isola?= =?UTF-8?q?tion=20=E2=80=94=20beta=20ReaSampler=209000=20pairs=20with=20be?= =?UTF-8?q?ta=20extension=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork the VST3 class UID (beta UID minted+frozen), on-disk name, and display name by the one channel bit via app_version; couple UID selection to the channel at compile time so a refactor can't split identity from data. --- CMakeLists.txt | 15 +++++-- src/app_version.cpp | 18 ++++++++ src/app_version.h | 27 ++++++++++++ src/vst/reasampler_editor.cpp | 4 +- src/vst/reasampler_embed.cpp | 5 ++- src/vst/reasampler_vst.h | 77 ++++++++++++++++++++++++++--------- src/vst/vst_entry.cpp | 47 +++++++++++++++++---- tests/test_app_version.cpp | 36 ++++++++++++++++ 8 files changed, 193 insertions(+), 36 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a0328a4..368cd3f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,9 +34,13 @@ set(REASAMPLER_CHANNEL "stable" CACHE STRING "Build channel: stable (default) or if(REASAMPLER_CHANNEL STREQUAL "beta") set(REASAMPLER_CHANNEL_IS_BETA 1) set(REASAMPLER_OUTPUT_NAME "reaper_reasampler_beta") + # The VST3 instrument's on-disk name forks the same way (S18) — must match + # app_version::vstOutputName() so the artifact name and the in-binary self-id agree. + set(REASAMPLER_VST_OUTPUT_NAME "reasampler_9000_beta") elseif(REASAMPLER_CHANNEL STREQUAL "stable") set(REASAMPLER_CHANNEL_IS_BETA 0) set(REASAMPLER_OUTPUT_NAME "reaper_reasampler") + set(REASAMPLER_VST_OUTPUT_NAME "reasampler_9000") else() message(FATAL_ERROR "REASAMPLER_CHANNEL must be 'stable' or 'beta' (got '${REASAMPLER_CHANNEL}')") @@ -908,9 +912,12 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # 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}) # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk - # product name (S-NAME-1, SETTLED 2026-07-26): reasampler_9000.vst3, matching the - # "ReaSampler 9000" display strings. The VST3 class UID (reasampler_vst.h) is unchanged - # — the compat anchor a saved instance rebinds by (save-rename-reopen is a DAW-verify). + # product name, channel-forked (S18): reasampler_9000.vst3 (stable, byte-identical to + # pre-S18) / reasampler_9000_beta.vst3 (beta) — driven by REASAMPLER_VST_OUTPUT_NAME set + # from the ONE channel decision above, mirroring the extension's REASAMPLER_OUTPUT_NAME + # and matching app_version::vstOutputName(). The two channels install side-by-side; the + # per-channel VST3 class UID (reasampler_vst.h) keeps a saved instance rebinding to its + # own channel (save-rename-reopen is a DAW-verify). set_target_properties(reasampler_vst PROPERTIES PREFIX "" SUFFIX ".vst3" - OUTPUT_NAME "reasampler_9000") + OUTPUT_NAME "${REASAMPLER_VST_OUTPUT_NAME}") endif() diff --git a/src/app_version.cpp b/src/app_version.cpp index b51e7cd..247c24a 100644 --- a/src/app_version.cpp +++ b/src/app_version.cpp @@ -86,6 +86,24 @@ const std::string& dockIdent() { return kIdent; } +const std::string& vstOutputName() { + // The .vst3 module OUTPUT_NAME base — FOREVER-STABLE per channel. Stable is + // byte-identical to pre-S18 ("reasampler_9000"); beta is isolated so both install + // side-by-side without a filename collision. + static const std::string kName = + kIsBeta ? "reasampler_9000_beta" : "reasampler_9000"; + return kName; +} + +const std::string& vstPluginName() { + // The factory display name / editor title / embed label. Stable is byte-identical to + // pre-S18 ("ReaSampler 9000"); beta appends " beta" so the two channels are distinct + // plugins in the FX browser. + static const std::string kName = + kIsBeta ? "ReaSampler 9000 beta" : "ReaSampler 9000"; + return kName; +} + std::string channelCommandId(const std::string& suffix) { return commandIdPrefix() + suffix; } diff --git a/src/app_version.h b/src/app_version.h index 052c273..3bee96f 100644 --- a/src/app_version.h +++ b/src/app_version.h @@ -104,6 +104,33 @@ const std::string& binaryName(); const std::string& dockTitle(); const std::string& dockIdent(); +// --- VST3 instrument identity (S18, beta-in-isolation) ------------------------------ +// +// The ReaSampler 9000 VST3 instrument forks its plugin identity per channel exactly as the +// extension forks its binary/dock idents above — one channel per binary, all derived from +// the ONE channel bit here, so the VST shell carries no #ifdef fork. These are the VST's +// analogues of binaryName()/dockTitle(): the on-disk module name and the human-facing name. +// +// vstOutputName() — the CMake OUTPUT_NAME base for the .vst3 module. Stable: +// "reasampler_9000" (byte-identical to pre-S18). Beta: +// "reasampler_9000_beta". Mirrors the CMake target's OUTPUT_NAME (the +// authoritative artifact name); exposed here so the one derivation lives +// in this module. FOREVER-STABLE per channel — the on-disk filename a +// REAPER project's saved instance path may reference. +// vstPluginName() — the factory display name (FX browser), editor title band, and S6 +// embed-strip label. Stable: "ReaSampler 9000". Beta: +// "ReaSampler 9000 beta". Sourced from here, never a literal in +// reasampler_vst.h / vst_entry.cpp / the editor / the embed strip. +// +// NOTE: the VST3 CLASS UID is NOT here — a UID is not a string derivation but a compile-time +// FUID/INLINE_UID constant the factory needs in brace-init form; it lives in reasampler_vst.h, +// channel-selected by the same REASAMPLER_CHANNEL_IS_BETA bit. This module owns the string +// identity; reasampler_vst.h owns the binary UID identity. The version display the factory +// stamps into PClassInfo2 reuses appVersion() (it already renders "-beta" on beta) — no +// separate VST version accessor. +const std::string& vstOutputName(); +const std::string& vstPluginName(); + // --- Channel-qualified action id / name builders ------------------------------------ // // The two composition helpers every action-registering shell (main.cpp, actions.cpp) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 24a4912..2e23c32 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -18,7 +18,7 @@ #include "peaks.h" // computeEnvelope #include "reaper_bridge.h" #include "reasampler_processor.h" -#include "reasampler_vst.h" // kPluginName (the editor title band) +#include "app_version.h" // vstPluginName (channel-derived editor title band, S18) #include "sample_map.h" #include "wav_trim.h" // parseWavLayout, extractFloatFrames #include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) @@ -477,7 +477,7 @@ void ReaSamplerEditor::paint(HDC hdc) { // Title band: product name + live readout. LICE_FillRect(&bmp, bands.title.left, bands.title.top, bands.title.width(), bands.title.height(), kColTitleBg, 1.0f, 0); - std::string title = kPluginName; + std::string title = reasampler::vstPluginName(); // channel-derived (S18) if (processor_ && processor_->bridge().isConnected()) { if (samples_.empty()) title += " [bank empty]"; else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]"; diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp index ca4b5b9..792206f 100644 --- a/src/vst/reasampler_embed.cpp +++ b/src/vst/reasampler_embed.cpp @@ -7,6 +7,7 @@ #include #include +#include "app_version.h" // vstPluginName (channel-derived embed label, S18) #include "editor_geometry.h" // Rect (shared with embed_strip) #include "embed_strip.h" // the pure strip layout + hit-test #include "ext_keys.h" // kProjExtBanksKey @@ -161,8 +162,8 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { SetTextColor(dc, kRgbText); RECT gr{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right, layout.keymap.bottom}; - const std::string label = - samples_.empty() ? "ReaSampler 9000 (bank empty)" : "ReaSampler 9000 (no zones)"; + const std::string label = reasampler::vstPluginName() + // channel-derived (S18) + (samples_.empty() ? " (bank empty)" : " (no zones)"); DrawTextA(dc, label.c_str(), -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); } else { diff --git a/src/vst/reasampler_vst.h b/src/vst/reasampler_vst.h index fc1b942..b6343f5 100644 --- a/src/vst/reasampler_vst.h +++ b/src/vst/reasampler_vst.h @@ -2,41 +2,78 @@ // (Phase S). One place for the plugin's class UID, name, vendor, and version so the // processor, factory, and editor agree. // -// The class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates this -// instrument records the UID, so changing it orphans every saved instance. Minted once -// for the spike; do not regenerate. +// A class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates this +// instrument records the UID, so changing it orphans every saved instance. Minted once; +// do not regenerate. +// +// CHANNEL ISOLATION (S18, beta-in-isolation — the instrument-side companion to V4). Just +// as V4 gave the extension a per-channel ext-state namespace / command-id family / dock +// ident, S18 gives the VST3 instrument a per-channel PLUGIN IDENTITY: its class UID, its +// on-disk filename, and its display name all fork by the ONE channel bit +// (REASAMPLER_CHANNEL_IS_BETA, from version_generated.h). ONE class per binary — the bit +// selects which UID compiles into the single DEF_CLASS2, so a beta build carries only the +// beta identity and can never present the stable one (mirrors V4's fully-isolated-binary +// philosophy). The two UIDs below are BOTH frozen forever; the filename + display name +// derive from app_version's vstOutputName()/vstPluginName() (this header owns only the +// binary UID identity — the string identity lives in the pure module). #pragma once #include "pluginterfaces/base/funknown.h" +#include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — the one channel bit + namespace reasampler::vst { -// Human-facing identity (S-NAME-1, SETTLED 2026-07-26). "ReaSampler" is the extension -// (capture + organization); the MIDI-playback instrument's product name is -// "ReaSampler 9000" — the string that surfaces in REAPER's FX browser, the factory -// display name, the editor title band, and the embed-strip label. The on-disk module is -// renamed to match (CMake OUTPUT_NAME reasampler_9000.vst3). The VST3 class UID below is -// the compat anchor and is NOT changed by the rename — a saved REAPER project rebinds a -// saved instance by class UID, so the display/filename rename keeps existing instances -// resolving (compat is a DAW-verify; see PLAN.md §S-NAME-1). -inline constexpr const char* kPluginName = "ReaSampler 9000"; +// Vendor identity (S-NAME-1, SETTLED 2026-07-26). Shared across channels — V4 kept the +// lane-name prefix shared, so shared-where-V4-shares is the default (the channel is carried +// by the UID + filename + display fork, not the vendor block). inline constexpr const char* kVendorName = "ReaSampler"; inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/reasampler"; inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com"; -// The processor class UID (the SingleComponentEffect). FOREVER-STABLE once shipped — -// a saved REAPER project records it, so changing it orphans every saved instance. -// Minted once for the S1 spike (2026-07-26). Defined as four longs so the factory's -// INLINE_UID (compile-time brace init) and the runtime FUID below share one source. +// ----------------------------------------------------------------------------------------- +// The two FOREVER-FROZEN VST3 class UIDs — one per channel. A saved REAPER project records +// the UID of the instance it instantiated and rebinds by it on reopen, so EACH is a +// permanent commitment: changing either orphans every saved instance of that channel. The +// channel bit selects which one this binary's factory registers (below) — one class per +// binary, never both. Documented with the SAME gravity: neither may EVER be regenerated. + +// STABLE class UID (S-NAME-1). Minted at the S1 spike (2026-07-26), locked. FROZEN FOREVER. #define REASAMPLER_PROC_UID_1 0x5E45A11E #define REASAMPLER_PROC_UID_2 0x9C7B4D6A #define REASAMPLER_PROC_UID_3 0xB1E3F208 #define REASAMPLER_PROC_UID_4 0x4A6C1D9F -static const Steinberg::FUID kReaSamplerProcessorUID(REASAMPLER_PROC_UID_1, - REASAMPLER_PROC_UID_2, - REASAMPLER_PROC_UID_3, - REASAMPLER_PROC_UID_4); +// BETA class UID (S18). Minted once (2026-07-26), locked FROM THIS WAVE per Daniel's +// fast-track (fork S18-F1: mint now, not at first beta release). FROZEN FOREVER — the same +// permanent lock as the stable UID; do not regenerate even though no beta VST has shipped. +#define REASAMPLER_PROC_UID_BETA_1 0xCCFFEB3A +#define REASAMPLER_PROC_UID_BETA_2 0x4FF532A6 +#define REASAMPLER_PROC_UID_BETA_3 0x9E181798 +#define REASAMPLER_PROC_UID_BETA_4 0x4256955F + +// The channel-selected UID macros the factory's INLINE_UID (compile-time brace init) and the +// runtime FUID below both source, so exactly one class UID is compiled into this binary. This +// is the ONLY channel #ifdef in the VST shell (an INLINE_UID needs literal brace-init tokens, +// so it cannot route through app_version's runtime string accessors — the header owns the +// binary UID fork, app_version owns the string fork). +#if REASAMPLER_CHANNEL_IS_BETA +#define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_BETA_1 +#define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_BETA_2 +#define REASAMPLER_ACTIVE_UID_3 REASAMPLER_PROC_UID_BETA_3 +#define REASAMPLER_ACTIVE_UID_4 REASAMPLER_PROC_UID_BETA_4 +#else +#define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_1 +#define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_2 +#define REASAMPLER_ACTIVE_UID_3 REASAMPLER_PROC_UID_3 +#define REASAMPLER_ACTIVE_UID_4 REASAMPLER_PROC_UID_4 +#endif + +// The runtime FUID for the class this binary registers — the channel-selected UID above. +static const Steinberg::FUID kReaSamplerProcessorUID(REASAMPLER_ACTIVE_UID_1, + REASAMPLER_ACTIVE_UID_2, + REASAMPLER_ACTIVE_UID_3, + REASAMPLER_ACTIVE_UID_4); } // namespace reasampler::vst diff --git a/src/vst/vst_entry.cpp b/src/vst/vst_entry.cpp index d24ce1a..29fc9ac 100644 --- a/src/vst/vst_entry.cpp +++ b/src/vst/vst_entry.cpp @@ -21,24 +21,55 @@ #include "pluginterfaces/vst/ivstaudioprocessor.h" // kVstAudioEffectClass, PlugType +#include "app_version.h" // vstPluginName / appVersion — the channel-derived identity +#include "ext_keys.h" // kProjExtNamespace — the pairing-surface assertion target #include "reasampler_processor.h" -#include "reasampler_vst.h" +#include "reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*) -// A concrete version string for PClassInfo2. Phase V owns the real version scheme; the -// spike ships a fixed 0.1.0. -#define REASAMPLER_VST_VERSION "0.1.0.0" +// CHANNEL PAIRING INVARIANT (S18). The instrument's PLUGIN identity forks by the ONE channel +// bit (REASAMPLER_CHANNEL_IS_BETA — the class UID selected in reasampler_vst.h, the filename +// + display name in app_version). Its DATA identity forks by the SAME bit, one layer down: +// ext_keys.h's kProjExtNamespace() delegates to app_version::extStateNamespace(), so a beta +// binary reads "reasampler_beta". Both derive from that one bit, so a beta VST can only ever +// talk to the beta extension. +// +// The guard below pins the two forks together so a refactor cannot split them. It asserts +// that the CLASS UID this factory registers (REASAMPLER_ACTIVE_UID_1, selected by the #if in +// reasampler_vst.h) is the UID that matches THIS binary's channel bit. If someone edited that +// #if to pick the wrong branch — registering the stable UID in a beta build, or vice versa — +// the instrument's identity would diverge from the namespace ext_keys reads (a beta-named +// plugin presenting the stable UID, or reading the stable banks under a beta identity). That +// is exactly the silent split the invariant forbids, and it breaks the build here instead. +// (The namespace itself is a runtime accessor — .c_str() on a channel-selected string — so +// the couplable compile-time fact is the UID selection, not the namespace value; the +// app_version_tests pin the namespace string per channel.) +#if REASAMPLER_CHANNEL_IS_BETA +static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_BETA_1, + "S18: a beta build must register the BETA class UID that pairs with the beta " + "extension's ext-state namespace — the UID selection and the channel bit split"); +#else +static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_1, + "S18: a stable build must register the STABLE class UID that pairs with the " + "stable ext-state namespace — the UID selection and the channel bit split"); +#endif BEGIN_FACTORY(reasampler::vst::kVendorName, reasampler::vst::kVendorUrl, reasampler::vst::kVendorEmail, Steinberg::PFactoryInfo::kNoFlags) -DEF_CLASS2(INLINE_UID(REASAMPLER_PROC_UID_1, REASAMPLER_PROC_UID_2, - REASAMPLER_PROC_UID_3, REASAMPLER_PROC_UID_4), +// The display name and version are channel-derived from app_version — sourced here, not +// as literals. DEF_CLASS2 expands inside GetPluginFactory() and PClassInfo2's constructor +// copies the char* into its own fixed buffer at that runtime call, so .c_str() on the +// accessors' static-storage strings is valid (no dangling — the refs outlive the copy). +// vstPluginName(): "ReaSampler 9000" / "ReaSampler 9000 beta". appVersion(): "0.9.01" / +// "0.9.01-beta" (the -beta render V4 already yields on beta). +DEF_CLASS2(INLINE_UID(REASAMPLER_ACTIVE_UID_1, REASAMPLER_ACTIVE_UID_2, + REASAMPLER_ACTIVE_UID_3, REASAMPLER_ACTIVE_UID_4), Steinberg::PClassInfo::kManyInstances, // cardinality kVstAudioEffectClass, // component category (fixed) - reasampler::vst::kPluginName, // plug-in display name + reasampler::vstPluginName().c_str(), // plug-in display name (channel-derived) 0, // single-component => 0 Steinberg::Vst::PlugType::kInstrumentSynthSampler, // subcategory - REASAMPLER_VST_VERSION, // plug-in version + reasampler::appVersion().c_str(), // plug-in version (channel: -beta render) kVstVersionString, // VST3 SDK version (fixed) reasampler::vst::ReaSamplerProcessor::createInstance) diff --git a/tests/test_app_version.cpp b/tests/test_app_version.cpp index 4f05eab..1b5a312 100644 --- a/tests/test_app_version.cpp +++ b/tests/test_app_version.cpp @@ -74,6 +74,40 @@ static void testChannelDerivedIdentityStrings() { } } +static void testVstIdentityStringsForkByChannel() { + // S18: the VST3 instrument's on-disk name and display name fork from the SAME one channel + // bit as the extension's idents above. Stable values are BYTE-IDENTICAL to pre-S18 — any + // drift in the stable branch orphans a saved instance's on-disk reference / mislabels the + // FX browser. These are the accessors vst_entry.cpp's factory, the editor title, and the + // embed label all source; each branch is the assertion the OTHER config's build would fail. + if (isBeta()) { + CHECK(vstOutputName() == "reasampler_9000_beta"); + CHECK(vstPluginName() == "ReaSampler 9000 beta"); + } else { + CHECK(vstOutputName() == "reasampler_9000"); + CHECK(vstPluginName() == "ReaSampler 9000"); + } + // The on-disk name must match the CMake OUTPUT_NAME fork (REASAMPLER_VST_OUTPUT_NAME): a + // divergence between this accessor and the artifact name would ship a binary whose + // self-identification disagrees with its filename. (The CMake side is the authoritative + // artifact name; this pins the in-binary derivation to the same two literals.) + CHECK(vstOutputName() == (isBeta() ? "reasampler_9000_beta" : "reasampler_9000")); +} + +static void testVstIdentityAndDataNamespaceShareOneChannel() { + // The S18 pairing invariant at the SEAM: the VST's plugin identity (its display/output + // names) and the DATA namespace its bridge reads (extStateNamespace(), what ext_keys + // delegates to) must resolve to the SAME channel — a beta-named plugin reading the stable + // namespace, or vice versa, is precisely the split the invariant forbids. Both forks fan + // out from the one isBeta() bit, so this assertion fails if EITHER fork regressed + // independently (a beta output name paired with the stable namespace trips the beta arm). + const bool identityIsBeta = + (vstPluginName() == "ReaSampler 9000 beta") && (vstOutputName() == "reasampler_9000_beta"); + const bool dataIsBeta = (extStateNamespace() == "reasampler_beta"); + CHECK(identityIsBeta == dataIsBeta); // identity and data agree on the channel + CHECK(identityIsBeta == isBeta()); // and both agree with the compiled bit +} + static void testChannelQualifiedIdAndNameComposition() { // The two composition helpers the shells funnel through. A representative shipped id // (CAPTURE_TRACK) and phrase must compose to the exact channel-qualified strings — this @@ -205,6 +239,8 @@ int main() { testVersionConstantRendersExactString(); testChannelDerivedRendering(); testChannelDerivedIdentityStrings(); + testVstIdentityStringsForkByChannel(); + testVstIdentityAndDataNamespaceShareOneChannel(); testChannelQualifiedIdAndNameComposition(); testStampClassifiesAsStampedOnOwnChannel(); testParseWellFormed(); From 5dcd5d0d9de67458e8c51f4e7e8afdcacc9743fe Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 00:02:57 -0400 Subject: [PATCH 38/49] fix(comments): correct two misleading impl comments in sampler_core (rescan vs tally; re-dispatch vs recursion) --- src/vst/sampler_core.cpp | 2 +- src/vst/sampler_core.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 88de67e..4aa2f16 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -104,7 +104,7 @@ double AdsrEnvelope::tick() { // Fall through to Decay this frame so no extra unity sample is emitted for a // zero-length hold (preserving the exact pre-S15 sample-for-sample shape). level_ = 1.0; - // Re-dispatch by recursion-free goto-equivalent: evaluate Decay immediately. + // Single re-dispatch into Decay (bounded: Hold→Decay only; not a general recursion). return tick(); } level_ = 1.0; diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 28d99d5..b4a09d7 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -516,8 +516,8 @@ private: // one per the documented policy. Always returns a valid index (maxVoices >= 1). std::size_t allocateVoice(); - // Count of active Preserve-engine voices (for the S16 Preserve cap). A cheap running tally - // kept in sync at note-on/steal/free rather than rescanned per note. + // Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on + // (cheap: bounded by maxVoices) rather than maintained as a running tally. std::size_t activePreserveVoices() const; std::vector voices_; From bab82d4c4366f5c24ce5f6bb039fcddd6e3fe6de Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 00:02:58 -0400 Subject: [PATCH 39/49] S18: extend channel-pairing static_assert to all four UID words --- src/vst/vst_entry.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/vst/vst_entry.cpp b/src/vst/vst_entry.cpp index 29fc9ac..66151e5 100644 --- a/src/vst/vst_entry.cpp +++ b/src/vst/vst_entry.cpp @@ -44,11 +44,17 @@ // the couplable compile-time fact is the UID selection, not the namespace value; the // app_version_tests pin the namespace string per channel.) #if REASAMPLER_CHANNEL_IS_BETA -static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_BETA_1, +static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_BETA_1 && + REASAMPLER_ACTIVE_UID_2 == REASAMPLER_PROC_UID_BETA_2 && + REASAMPLER_ACTIVE_UID_3 == REASAMPLER_PROC_UID_BETA_3 && + REASAMPLER_ACTIVE_UID_4 == REASAMPLER_PROC_UID_BETA_4, "S18: a beta build must register the BETA class UID that pairs with the beta " "extension's ext-state namespace — the UID selection and the channel bit split"); #else -static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_1, +static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_1 && + REASAMPLER_ACTIVE_UID_2 == REASAMPLER_PROC_UID_2 && + REASAMPLER_ACTIVE_UID_3 == REASAMPLER_PROC_UID_3 && + REASAMPLER_ACTIVE_UID_4 == REASAMPLER_PROC_UID_4, "S18: a stable build must register the STABLE class UID that pairs with the " "stable ext-state namespace — the UID selection and the channel bit split"); #endif From 4f30124ef79d4757da26becf7dbede934edf869d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 01:09:19 -0400 Subject: [PATCH 40/49] S12: editor scale + S15/S16 control surfaces Pure browser_scroll/note_entry/param_slider modules (+ CTest) for scroll, type-to-filter search, numeric note entry, and the AHDSR/Trigger/pitch-engine/ pitch-env control panel. Editor shell draws + routes through them; params edit the selected zone's ZonePlayParams via commitAndReload. --- CMakeLists.txt | 48 +++- PLAN.md | 33 ++- src/vst/browser_scroll.cpp | 158 +++++++++++ src/vst/browser_scroll.h | 107 ++++++++ src/vst/note_entry.cpp | 113 ++++++++ src/vst/note_entry.h | 33 +++ src/vst/param_slider.cpp | 92 +++++++ src/vst/param_slider.h | 104 ++++++++ src/vst/reasampler_editor.cpp | 487 ++++++++++++++++++++++++++++++++-- src/vst/reasampler_editor.h | 67 ++++- tests/test_browser_scroll.cpp | 179 +++++++++++++ tests/test_note_entry.cpp | 73 +++++ tests/test_param_slider.cpp | 204 ++++++++++++++ 13 files changed, 1667 insertions(+), 31 deletions(-) create mode 100644 src/vst/browser_scroll.cpp create mode 100644 src/vst/browser_scroll.h create mode 100644 src/vst/note_entry.cpp create mode 100644 src/vst/note_entry.h create mode 100644 src/vst/param_slider.cpp create mode 100644 src/vst/param_slider.h create mode 100644 tests/test_browser_scroll.cpp create mode 100644 tests/test_note_entry.cpp create mode 100644 tests/test_param_slider.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 62d8175..c036e60 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -716,6 +716,31 @@ add_library(bank_sync STATIC src/vst/bank_sync.cpp) target_include_directories(bank_sync PUBLIC src/vst src) target_link_libraries(bank_sync PUBLIC assignment_request) +# browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search +# geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect + +# thumb-drag<->offset mapping, and the name-substring filter that composes with the bank +# filter. The mirror of capture_browser; links capture_browser (for BrowserLayout + the card +# metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK. +add_library(browser_scroll STATIC src/vst/browser_scroll.cpp) +target_include_directories(browser_scroll PUBLIC src/vst) +target_link_libraries(browser_scroll PUBLIC capture_browser) + +# note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of +# a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to +# [0,127]). No dependency beyond the standard library. NEITHER SDK. +add_library(note_entry STATIC src/vst/note_entry.cpp) +target_include_directories(note_entry PUBLIC src/vst) + +# param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface +# layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the +# Gate|Trigger + Varispeed|Preserve toggles and the AHDSR / Trigger / pitch-env sliders). The +# mirror of keyboard_strip; links editor_geometry for the shared Rect. Deliberately engine-free +# (no sampler_core types) — the shell owns the control-id -> param binding + the value DOMAIN +# mapping. NEITHER SDK. +add_library(param_slider STATIC src/vst/param_slider.cpp) +target_include_directories(param_slider PUBLIC src/vst) +target_link_libraries(param_slider PUBLIC editor_geometry) + add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) @@ -755,6 +780,22 @@ add_executable(bank_sync_tests tests/test_bank_sync.cpp) target_link_libraries(bank_sync_tests PRIVATE bank_sync) add_test(NAME bank_sync_tests COMMAND bank_sync_tests) +# browser_scroll (S12): the pure scroll-window/thumb + search geometry over capture_browser. +add_executable(browser_scroll_tests tests/test_browser_scroll.cpp) +target_link_libraries(browser_scroll_tests PRIVATE browser_scroll) +add_test(NAME browser_scroll_tests COMMAND browser_scroll_tests) + +# note_entry (S12): the pure text->clamped-MIDI-note parse for direct numeric entry. +add_executable(note_entry_tests tests/test_note_entry.cpp) +target_link_libraries(note_entry_tests PRIVATE note_entry) +add_test(NAME note_entry_tests COMMAND note_entry_tests) + +# param_slider (S12 + S15/S16 control surfaces): the pure control-panel layout + slider/toggle +# value<->pixel mapping the editor parameter surface draws + routes against. +add_executable(param_slider_tests tests/test_param_slider.cpp) +target_link_libraries(param_slider_tests PRIVATE param_slider) +add_test(NAME param_slider_tests COMMAND param_slider_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -941,9 +982,14 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # bank_sync (S9/S8 reader): the pure generation-compare + assignment-consume decision the # processor's off-thread poll runs; links assignment_request transitively (the decoded # request it consumes) — the same key the extension writes, shared via the pure module. + # browser_scroll + note_entry + param_slider (S12 + S15/S16 control surfaces): the pure + # scroll/search geometry over the capture browser, the numeric-note-entry parse, and the + # control-panel layout + slider/toggle value<->pixel mapping the editor's parameter surface + # draws + routes against. browser_scroll pulls capture_browser transitively; param_slider + + # note_entry link editor_geometry / the stdlib only. All engine-free, DAW-verified in the shell. target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal sample_map capture_paths embed_strip app_version capture_browser keyboard_strip - waveform_view bank_sync) + waveform_view bank_sync browser_scroll note_entry param_slider) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/PLAN.md b/PLAN.md index b2b2c99..dea473b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -581,19 +581,34 @@ into the voice engine; today they are fixed defaults), S5 (the map the numeric f > control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch > envelope depth/shape controls) — deferred here from S15 and S16 per spec. -- [ ] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a +- [x] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a bank longer than the panel is fully reachable; a **type-to-filter search** that narrows the drawn cards to matching display names, **composing with S10's bank filter** (bank filter selects the bank; search narrows within it). Scroll/search layout + hit-test is pure geometry (visible-card window, scrollbar thumb rect, search-box rect), layered over - S10's `capture_browser` module; filter/scroll state is transient UI state. -- [ ] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip - (LICE text-entry idiom or a SWELL edit control on the child HWND) so a precise note can be - typed, not only dragged. Commits via `commitMapAndReload` like every other edit. -- [ ] ADSR editor: four draggable controls (attack/decay/sustain/release) over the S3 - `AdsrParams`; per-instance component state (additive to the map/selection blob, version- - bumped, back-compat defaults to the current fixed envelope). Slider layout/hit-test pure; - the audible envelope change goes through the same off-thread reload. + S10's `capture_browser` module; filter/scroll state is transient UI state. **Landed:** + pure `browser_scroll` module (`browser_scroll_tests`) — scrollContentHeight / max / clamp, + visibleCardRange window, scrolledCardCellRect, scrollThumbRect + thumbDragToOffset inverse, + searchBoxRect, nameMatchesQuery + filterNameIndices. Editor shell wires wheel (`WM_MOUSEWHEEL`), + thumb-drag (`DragKind::kScrollThumb`), and the search box (`WM_CHAR` -> `onSearchChar`, + composed into `rebuildVisible`). Scroll/search are transient (never persisted). +- [x] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip + (LICE text-entry idiom) so a precise note can be typed, not only dragged. Commits via + `commitAndReload` like every other edit. **Landed:** pure `note_entry` module + (`note_entry_tests`) — `parseNoteEntry` accepts a decimal integer OR a note name (C4==60), + clamps to [0,127], rejects garbage. Editor shell hosts three focusable fields (low/high/root) + on the Zones legend, committing on Enter through `commitAndReload`. +- [x] ADSR/AHDSR editor + S15/S16 control surfaces: draggable sliders over the S3 `AdsrParams` + (attack/**hold**/decay/sustain/release — hold is the S15 addition) plus the deferred S15/S16 + controls — per-zone Gate|Trigger mode toggle, Trigger %-length/fade-in/fade-out, Varispeed| + Preserve engine toggle, and the AD pitch-envelope enable/attack/decay/±semitone depth. All + edit the SELECTED zone's `ZonePlayParams` (instrument-owned, D-B; never the bank), round-trip + through the existing v3 component-state blob (no new persistence — the S15/S16 payload already + landed in the core pass), and commit off-thread via `commitAndReload`. **Landed:** pure + `param_slider` module (`param_slider_tests`) — control-panel stack layout, toggle-segment + split + hit-test, slider value<->pixel round-trip + clamping, point->control routing. The + shell owns the control-id -> engine-param binding + the value DOMAIN mapping (frames/fraction/ + semitones); the module stays engine-free. ## S13 — drop-to-load: the S8 ingest story, folded into the editor UX (UX overhaul, part 4) **Goal:** Make "load a sample into the sampler" **one gesture from the editor**: dropping diff --git a/src/vst/browser_scroll.cpp b/src/vst/browser_scroll.cpp new file mode 100644 index 0000000..85be0f8 --- /dev/null +++ b/src/vst/browser_scroll.cpp @@ -0,0 +1,158 @@ +// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10 +// capture_browser. No host types; only the shared Rect + BrowserLayout. + +#include "browser_scroll.h" + +#include +#include + +namespace reasampler::vst { + +namespace { +// The minimum thumb height so a very long bank still yields a grabbable thumb. +constexpr int kMinThumbHeight = 20; + +char asciiLower(char c) { + return static_cast(std::tolower(static_cast(c))); +} +} // namespace + +int scrollContentHeight(const BrowserLayout& layout, int cardCount) { + if (cardCount <= 0) return 0; + const int columns = (std::max)(1, layout.columns); + const int rows = (cardCount + columns - 1) / columns; // ceil + return rows * kBrowserCardHeight; +} + +int scrollMaxOffset(const BrowserLayout& layout, int cardCount) { + const int content = scrollContentHeight(layout, cardCount); + const int gridH = (std::max)(0, layout.grid.height()); + return (std::max)(0, content - gridH); +} + +int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset) { + const int maxOff = scrollMaxOffset(layout, cardCount); + if (proposedOffset < 0) return 0; + if (proposedOffset > maxOff) return maxOff; + return proposedOffset; +} + +VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset) { + VisibleRange vr; + if (cardCount <= 0) return vr; + const int columns = (std::max)(1, layout.columns); + const int gridH = (std::max)(0, layout.grid.height()); + if (gridH <= 0 || kBrowserCardHeight <= 0) { + vr.first = 0; + vr.last = 0; + return vr; + } + if (offset < 0) offset = 0; + // First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row + // partially scrolled off the top still draws (its lower part is visible). + const int firstRow = offset / kBrowserCardHeight; + // Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for + // the exclusive end. A row straddling the bottom edge still draws. + const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1; + int first = firstRow * columns; + int last = lastRow * columns; + if (first > cardCount) first = cardCount; + if (last > cardCount) last = cardCount; + if (last < first) last = first; + vr.first = first; + vr.last = last; + return vr; +} + +Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) { + Rect r = cardCellRect(layout, index); + if (r.right <= r.left && r.bottom <= r.top) return r; // empty (negative index) stays empty + return Rect{r.left, r.top - offset, r.right, r.bottom - offset}; +} + +Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) { + const int content = scrollContentHeight(layout, cardCount); + const int gridH = (std::max)(0, layout.grid.height()); + if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar + const int maxOff = content - gridH; + if (offset < 0) offset = 0; + if (offset > maxOff) offset = maxOff; + + const int trackRight = layout.grid.right; + const int trackLeft = trackRight - kScrollbarWidth; + const int trackTop = layout.grid.top; + + // Thumb height proportional to the visible fraction, floored at a grabbable minimum but + // never taller than the track. + int thumbH = static_cast(static_cast(gridH) * gridH / content); + thumbH = (std::max)(kMinThumbHeight, thumbH); + thumbH = (std::min)(thumbH, gridH); + + // Thumb top proportional to the offset over the movable track span. + const int trackSpan = gridH - thumbH; // >= 0 + int thumbTop = trackTop; + if (maxOff > 0 && trackSpan > 0) { + thumbTop = trackTop + static_cast( + static_cast(offset) * trackSpan / maxOff); + } + return Rect{trackLeft, thumbTop, trackRight, thumbTop + thumbH}; +} + +int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, + int dyPixels) { + const int content = scrollContentHeight(layout, cardCount); + const int gridH = (std::max)(0, layout.grid.height()); + if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset); + + // Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels. + int thumbH = static_cast(static_cast(gridH) * gridH / content); + thumbH = (std::max)(kMinThumbHeight, thumbH); + thumbH = (std::min)(thumbH, gridH); + const int trackSpan = gridH - thumbH; + if (trackSpan <= 0) return clampScrollOffset(layout, cardCount, startOffset); + + const int maxOff = content - gridH; + // A 1px thumb move covers maxOff/trackSpan content px. Round to nearest for symmetry. + const long long deltaOffset = + (static_cast(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) / + trackSpan; + const long long proposed = static_cast(startOffset) + deltaOffset; + if (proposed < 0) return 0; + if (proposed > maxOff) return maxOff; + return static_cast(proposed); +} + +Rect searchBoxRect(int w) { + if (w <= 0) return Rect{}; + return Rect{0, 0, w, kSearchBoxHeight}; +} + +bool nameMatchesQuery(const std::string& name, const std::string& query) { + if (query.empty()) return true; + if (query.size() > name.size()) return false; + // Case-insensitive substring scan (ASCII fold). Small strings; a naive scan is fine. + for (std::size_t i = 0; i + query.size() <= name.size(); ++i) { + bool match = true; + for (std::size_t j = 0; j < query.size(); ++j) { + if (asciiLower(name[i + j]) != asciiLower(query[j])) { + match = false; + break; + } + } + if (match) return true; + } + return false; +} + +std::vector filterNameIndices(const std::vector& names, + const std::string& query) { + std::vector out; + out.reserve(names.size()); + for (int i = 0; i < static_cast(names.size()); ++i) { + if (nameMatchesQuery(names[static_cast(i)], query)) + out.push_back(i); + } + return out; +} + +} // namespace reasampler::vst diff --git a/src/vst/browser_scroll.h b/src/vst/browser_scroll.h new file mode 100644 index 0000000..81aa831 --- /dev/null +++ b/src/vst/browser_scroll.h @@ -0,0 +1,107 @@ +// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10 +// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of +// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box +// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the +// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes +// into these functions. +// +// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell +// clips at the browser bottom — a bank longer than the panel runs off with no way to reach +// it (the S12 gap). This module adds the two things S12 layers over that stable geometry: +// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the +// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a +// wheel tick or a thumb drag reaches every card; and +// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards, +// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first, +// then this search narrows within it) + the search-box rect the shell draws the field in. +// +// It holds NO card data and draws nothing — it knows only the browser layout (from +// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It +// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom). + +#pragma once + +#include +#include + +#include "capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect + +namespace reasampler::vst { + +// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell +// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so +// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is +// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter. +inline constexpr int kScrollbarWidth = 10; + +// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a +// thin band spanning the browser width). Exposed so the shell reserves the band and tests +// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the +// BrowserLayout it feeds to capture_browser by kSearchBoxHeight). +inline constexpr int kSearchBoxHeight = 22; + +// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s +// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height. +// Zero cards -> 0. Pure — the content extent the scroll offset ranges over. +int scrollContentHeight(const BrowserLayout& layout, int cardCount); + +// The maximum scroll offset (px): content height minus the visible grid height, floored at 0. +// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp +// ceiling for every offset the shell tracks. +int scrollMaxOffset(const BrowserLayout& layout, int cardCount); + +// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel +// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card +// or above the first. Pure. +int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset); + +// The half-open range of card INDICES [first, last) at least partially visible in the grid at +// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every +// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row +// yields an empty range (first==last==cardCount). Pure. +struct VisibleRange { + int first = 0; // first card index drawn (inclusive) + int last = 0; // one past the last card index drawn (exclusive) +}; +VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset); + +// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell +// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with +// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card. +Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset); + +// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to +// the visible fraction (grid height / content height) and positioned proportional to the +// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell +// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very +// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against. +Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset); + +// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time +// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new +// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to +// content pixels (a 1px thumb move covers content/track px of content). A degenerate track / +// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map. +int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels); + +// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser +// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width; +// the shell adds its origin. A zero/negative width yields an empty rect. Pure. +Rect searchBoxRect(int w); + +// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query +// matches everything (the no-filter identity). Matching is ASCII case-folded (the display +// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only +// text). Pure — the single match predicate the shell's search narrow is built from. +bool nameMatchesQuery(const std::string& name, const std::string& query); + +// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving +// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank +// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this +// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list +// and applies the bank filter FIRST, then feeds the surviving display names here (search +// narrows within the bank). Pure. +std::vector filterNameIndices(const std::vector& names, + const std::string& query); + +} // namespace reasampler::vst diff --git a/src/vst/note_entry.cpp b/src/vst/note_entry.cpp new file mode 100644 index 0000000..99cb354 --- /dev/null +++ b/src/vst/note_entry.cpp @@ -0,0 +1,113 @@ +// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry. + +#include "note_entry.h" + +#include +#include + +namespace reasampler::vst { + +namespace { +char asciiUpper(char c) { + return static_cast(std::toupper(static_cast(c))); +} + +std::string trim(const std::string& s) { + std::size_t a = 0; + std::size_t b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +int clampNote(long long n) { + if (n < 0) return 0; + if (n > 127) return 127; + return static_cast(n); +} + +// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter. +int letterSemitone(char up) { + switch (up) { + case 'C': return 0; + case 'D': return 2; + case 'E': return 4; + case 'F': return 5; + case 'G': return 7; + case 'A': return 9; + case 'B': return 11; + default: return -1; + } +} + +// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4 +// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name. +std::optional parseNoteName(const std::string& s) { + if (s.empty()) return std::nullopt; + std::size_t i = 0; + const int base = letterSemitone(asciiUpper(s[i])); + if (base < 0) return std::nullopt; // not a letter -> not a note name + ++i; + int semitone = base; + // Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs). + while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) { + // A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is + // an accidental. '#' raises, 'b'/'B' lowers. + if (s[i] == '#') ++semitone; + else --semitone; + ++i; + } + // The octave: an optional sign then digits, running to the end. + if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous) + bool neg = false; + if (s[i] == '+' || s[i] == '-') { + neg = (s[i] == '-'); + ++i; + } + if (i >= s.size()) return std::nullopt; + int octave = 0; + bool anyDigit = false; + for (; i < s.size(); ++i) { + if (!std::isdigit(static_cast(s[i]))) return std::nullopt; + octave = octave * 10 + (s[i] - '0'); + anyDigit = true; + } + if (!anyDigit) return std::nullopt; + if (neg) octave = -octave; + // MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60). + const long long note = static_cast(octave + 1) * 12 + semitone; + return clampNote(note); +} + +std::optional parseInteger(const std::string& s) { + if (s.empty()) return std::nullopt; + std::size_t i = 0; + bool neg = false; + if (s[i] == '+' || s[i] == '-') { + neg = (s[i] == '-'); + ++i; + } + if (i >= s.size()) return std::nullopt; + long long v = 0; + for (; i < s.size(); ++i) { + if (!std::isdigit(static_cast(s[i]))) return std::nullopt; + v = v * 10 + (s[i] - '0'); + if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway + } + if (neg) v = -v; + return clampNote(v); +} +} // namespace + +std::optional parseNoteEntry(const std::string& text) { + const std::string s = trim(text); + if (s.empty()) return std::nullopt; + // Try a plain integer first (the common MIDI-number case); fall back to a note name. + if (std::isdigit(static_cast(s[0])) || s[0] == '+' || + (s[0] == '-' && s.size() > 1 && std::isdigit(static_cast(s[1])))) { + if (auto n = parseInteger(s)) return n; + } + return parseNoteName(s); +} + +} // namespace reasampler::vst diff --git a/src/vst/note_entry.h b/src/vst/note_entry.h new file mode 100644 index 0000000..de3ae0a --- /dev/null +++ b/src/vst/note_entry.h @@ -0,0 +1,33 @@ +// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's +// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The +// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit- +// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control +// or a LICE text-entry idiom) and feeds the committed string here on Enter. +// +// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't +// hit a precise note reliably. This adds a typed field: the user clicks the field, types a +// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI +// note [0,127] and commits via the same off-thread reload as every other edit. +// +// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served): +// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and +// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60 +// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel). +// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than +// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns +// nullopt (the shell keeps the old value + may flash the field). + +#pragma once + +#include +#include + +namespace reasampler::vst { + +// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal +// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is +// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps +// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types. +std::optional parseNoteEntry(const std::string& text); + +} // namespace reasampler::vst diff --git a/src/vst/param_slider.cpp b/src/vst/param_slider.cpp new file mode 100644 index 0000000..a01df57 --- /dev/null +++ b/src/vst/param_slider.cpp @@ -0,0 +1,92 @@ +// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16 +// editor parameter panel. No host types; only the shared Rect + contains(). + +#include "param_slider.h" + +#include + +namespace reasampler::vst { + +std::vector layoutControls(const Rect& panel, + const std::vector& controls) { + std::vector out; + if (controls.empty() || panel.width() <= 0 || panel.height() <= 0) return out; + out.reserve(controls.size()); + + // The label column is clamped so a narrow panel still leaves a control column. + const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width() / 2)); + int rowTop = panel.top; + for (const ControlDesc& d : controls) { + ControlRow r; + r.id = d.id; + r.kind = d.kind; + const int rowBottom = rowTop + kControlRowHeight; + r.row = Rect{panel.left, rowTop, panel.right, rowBottom}; + r.label = Rect{panel.left, rowTop, panel.left + labelW, rowBottom}; + r.control = Rect{panel.left + labelW, rowTop, panel.right, rowBottom}; + out.push_back(r); + rowTop = rowBottom + kControlRowGap; + } + return out; +} + +Rect toggleSegmentRect(const Rect& control, int seg) { + if (seg < 0 || seg >= kToggleSegments) return Rect{}; + const int w = control.width(); + if (w <= 0 || control.height() <= 0) return Rect{}; + const int segW = w / kToggleSegments; + const int left = control.left + seg * segW; + // The last segment absorbs the width remainder so the segments tile the whole control. + const int right = (seg == kToggleSegments - 1) ? control.right : left + segW; + return Rect{left, control.top, right, control.bottom}; +} + +int toggleSegmentHitTest(const Rect& control, int x, int y) { + if (!contains(control, x, y)) return -1; + for (int seg = 0; seg < kToggleSegments; ++seg) { + if (contains(toggleSegmentRect(control, seg), x, y)) return seg; + } + return -1; +} + +Rect sliderTrackRect(const Rect& control) { + // Inset a half-handle at each end so the handle stays fully inside the control at value + // 0 and 1. The handle CENTER ranges across [track.left, track.right]. + const int half = kSliderHandleWidth / 2; + if (control.width() <= kSliderHandleWidth || control.height() <= 0) return Rect{}; + return Rect{control.left + half, control.top, control.right - half, control.bottom}; +} + +Rect sliderHandleRect(const Rect& control, double value) { + const Rect track = sliderTrackRect(control); + if (track.width() <= 0) return Rect{}; + if (value < 0.0) value = 0.0; + if (value > 1.0) value = 1.0; + const int span = track.width(); // handle-center movable span + const int centerX = track.left + static_cast(value * span + 0.5); + const int half = kSliderHandleWidth / 2; + return Rect{centerX - half, control.top, centerX - half + kSliderHandleWidth, + control.bottom}; +} + +double valueAtPoint(const Rect& control, int x) { + const Rect track = sliderTrackRect(control); + const int span = track.width(); + if (span <= 0) return 0.0; + if (x <= track.left) return 0.0; + if (x >= track.right) return 1.0; + return static_cast(x - track.left) / static_cast(span); +} + +int controlAtPoint(const std::vector& rows, int x, int y) { + for (const ControlRow& r : rows) { + if (r.kind == ControlKind::Toggle) { + if (contains(r.control, x, y)) return r.id; + } else { // Slider — the interactive area is the track + if (contains(sliderTrackRect(r.control), x, y)) return r.id; + } + } + return -1; +} + +} // namespace reasampler::vst diff --git a/src/vst/param_slider.h b/src/vst/param_slider.h new file mode 100644 index 0000000..60ebe14 --- /dev/null +++ b/src/vst/param_slider.h @@ -0,0 +1,104 @@ +// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the +// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the +// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The +// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and +// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell +// draws each row (label + track/segments + handle) and routes clicks/drags into these +// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping. +// +// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface +// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR +// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade +// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope +// enable/attack/decay/depth. They are two shapes only — a two-segment TOGGLE and a +// horizontal SLIDER — laid out as a vertical stack of fixed-height rows. This module lays out +// that stack and maps a slider's NORMALIZED value (0..1) to/from its handle pixel; the shell +// converts each control's engine value (frames, seconds, a fraction, a signed semitone +// depth) to/from that 0..1 with its own domain knowledge (this module stays engine-free so it +// tests without the audio core). +// +// It reuses editor_geometry's Rect + contains() (one shared geometry idiom). + +#pragma once + +#include + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// Fixed control-panel metrics, exposed so the shell and tests agree. +inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap) +inline constexpr int kControlRowGap = 4; // vertical gap below each row +inline constexpr int kControlLabelWidth = 92; // the label column at the row's left +inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px) +inline constexpr int kToggleSegments = 2; // a toggle is always two segments + +// A control is one of two shapes. Toggle = a two-segment selector (the active segment +// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value. +enum class ControlKind { Toggle, Slider }; + +// One control the shell places in the panel, in stack order. `id` is the shell's own control +// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the +// shell routes the interaction to the right engine param — this module never interprets it. +struct ControlDesc { + int id = 0; + ControlKind kind = ControlKind::Slider; +}; + +// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect +// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle +// into segments via toggleSegmentRect). `index` is the control's position in the stack. +struct ControlRow { + int id = 0; + ControlKind kind = ControlKind::Slider; + Rect row; // the full row (label column + control column) + Rect label; // the label column at the left + Rect control; // the control column to the right of the label (track / toggle area) +}; + +// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each +// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left +// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A +// row whose top falls past the panel bottom is still returned (the shell clips at paint / +// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty +// control list or a degenerate panel yields an empty vector. Pure. +std::vector layoutControls(const Rect& panel, + const std::vector& controls); + +// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect, +// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width +// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate +// control rect yields an empty rect. Pure. +Rect toggleSegmentRect(const Rect& control, int seg); + +// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a +// miss (outside the control area). Pure. +int toggleSegmentHitTest(const Rect& control, int x, int y); + +// The slider track sub-rect inside a slider control's `control` rect: the control inset so the +// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle +// margin at each end). The handle CENTER ranges across [track.left, track.right] as the value +// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an +// empty rect. Pure. +Rect sliderTrackRect(const Rect& control); + +// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a +// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A +// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint. +Rect sliderHandleRect(const Rect& control, double value); + +// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range). +// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate +// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the +// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones). +double valueAtPoint(const Rect& control, int x); + +// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc +// id) whose interactive area (a Slider's track, a Toggle's whole control area) contains the +// point, or -1 for a miss (a gap, the label column, or outside every row). The FIRST matching +// row wins (rows never overlap, so at most one matches). Pure — the shell's routing entry +// point: on a hit it reads the value (valueAtPoint / toggleSegmentHitTest) and commits. +int controlAtPoint(const std::vector& rows, int x, int y); + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 590943c..f0e7df8 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -10,11 +10,14 @@ #include #include +#include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry #include "capture_browser.h" #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "editor_geometry.h" // Rect, contains #include "ext_keys.h" #include "keyboard_strip.h" +#include "note_entry.h" // S12 direct numeric note-entry parse +#include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping #include "peaks.h" // computeEnvelope #include "reaper_bridge.h" #include "reasampler_processor.h" @@ -175,11 +178,18 @@ void ReaSamplerEditor::refreshFromBank() { } void ReaSamplerEditor::rebuildVisible() { + // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search + // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). visible_.clear(); for (const SampleChoice& s : samples_) { - if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_) - visible_.push_back(s); + const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; + if (!inBank) continue; + const std::string& name = s.displayName.empty() ? s.id : s.displayName; + if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); } + // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel + // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it + // must not reset the user's scroll here. } #ifdef _WIN32 @@ -287,6 +297,102 @@ void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { } } +namespace { +// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is +// engine-free and maps only 0..1). Time sliders span [0, max] frames at a nominal rate so a +// full-throw reaches a musically generous ceiling; the exact wall-clock is DAW-verified. These +// are build-time residuals (one place to retune), not persisted. +constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling +constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered + +double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } +} // namespace + +std::vector ReaSamplerEditor::controlDescs(const ZonePlayParams& play) const { + std::vector out; + // Always: the two mode toggles. + out.push_back({static_cast(ParamControl::kPlayMode), ControlKind::Toggle}); + out.push_back({static_cast(ParamControl::kPitchEngine), ControlKind::Toggle}); + // Mode-relevant amplitude sliders. + if (play.playMode == PlayMode::Gate) { + out.push_back({static_cast(ParamControl::kAttack), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kHold), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kDecay), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kSustain), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kRelease), ControlKind::Slider}); + } else { // Trigger + out.push_back({static_cast(ParamControl::kTrigLength), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kTrigFadeIn), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kTrigFadeOut), ControlKind::Slider}); + } + // The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on). + out.push_back({static_cast(ParamControl::kPitchEnvEnable), ControlKind::Toggle}); + out.push_back({static_cast(ParamControl::kPitchEnvAttack), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kPitchEnvDecay), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kPitchEnvDepth), ControlKind::Slider}); + return out; +} + +double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const { + const auto framesToNorm = [](std::int64_t f) { + return clamp01(static_cast(f) / kEnvTimeMaxFrames); + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; + case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; + case ParamControl::kAttack: return framesToNorm(play.adsr.attackFrames); + case ParamControl::kHold: return framesToNorm(play.adsr.holdFrames); + case ParamControl::kDecay: return framesToNorm(play.adsr.decayFrames); + case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); + case ParamControl::kRelease: return framesToNorm(play.adsr.releaseFrames); + case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction); + case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); + case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); + case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; + case ParamControl::kPitchEnvAttack:return framesToNorm(play.pitchEnv.attackFrames); + case ParamControl::kPitchEnvDecay: return framesToNorm(play.pitchEnv.decayFrames); + case ParamControl::kPitchEnvDepth: + // Signed depth centered at 0.5 (0.5 == 0 semitones). + return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); + default: return 0.0; + } +} + +void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value, + int segment) const { + const auto normToFrames = [](double v) { + return static_cast(clamp01(v) * kEnvTimeMaxFrames + 0.5); + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: + play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; + break; + case ParamControl::kPitchEngine: + play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; + break; + case ParamControl::kAttack: play.adsr.attackFrames = normToFrames(value); break; + case ParamControl::kHold: play.adsr.holdFrames = normToFrames(value); break; + case ParamControl::kDecay: play.adsr.decayFrames = normToFrames(value); break; + case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; + case ParamControl::kRelease: play.adsr.releaseFrames = normToFrames(value); break; + case ParamControl::kTrigLength: + // lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing. + play.trigger.lengthFraction = (std::max)(0.01, clamp01(value)); + break; + case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break; + case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break; + case ParamControl::kPitchEnvEnable: + play.pitchEnv.enabled = (segment == 1); + break; + case ParamControl::kPitchEnvAttack: play.pitchEnv.attackFrames = normToFrames(value); break; + case ParamControl::kPitchEnvDecay: play.pitchEnv.decayFrames = normToFrames(value); break; + case ParamControl::kPitchEnvDepth: + play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; + break; + default: break; + } +} + void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays @@ -492,6 +598,38 @@ Rect zonesStripArea(const EditorBands& bands) { stripTop + kStripBandHeight}; } +// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the +// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw + +// hit-test use this single formula so they never drift. +Rect noteEntryFieldsArea(const EditorBands& bands) { + const Rect strip = Rect{bands.content.left + 8, + bands.content.top + 4 + 20 + 12 + kStripBandHeight, + bands.content.right - 8, 0}; + const int top = strip.top + 8; // legendTop (== zonesStripArea.bottom + 8) + return Rect{bands.content.left + 8 + 128, top, bands.content.right - 8, top + 18}; +} + +// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal +// segments left-to-right. An out-of-range index yields an empty rect. +Rect noteEntryFieldRect(const Rect& fields, int f) { + if (f < 0 || f > 2 || fields.width() <= 0) return Rect{}; + const int segW = fields.width() / 3; + const int left = fields.left + f * segW + (f > 0 ? 4 : 0); // small inter-field gap + const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW; + return Rect{left, fields.top, right, fields.bottom}; +} + +// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip + +// the one-line selected-zone legend, running to the content bottom. `bands.content` is the +// Zones mode-content area. Both draw + hit-test use this single formula so they never drift. +Rect zonesControlPanel(const EditorBands& bands) { + constexpr int pad = 8; + const Rect strip = zonesStripArea(bands); + const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap + return Rect{bands.content.left + pad, panelTop, bands.content.right - pad, + bands.content.bottom - 4}; +} + // The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's // header row (same y as the sample-name header, so it reads as "this capture's output mode"). // `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide, @@ -572,13 +710,31 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { const bool havePick = !selectedId_.empty(); const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) : bands.content.bottom; - const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + + // S12: reserve a type-to-filter search box at the top of the browser area; the tabs + grid + // sit below it. The search box spans the browser width. + const Rect searchBox = searchBoxRect(fullBrowserArea.width()); + const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top, + fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom}; + LICE_FillRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width(), searchAbs.height(), + searchFocused_ ? kColTabActiveBg : kColTabBg, 1.0f, 0); + { + std::string sb = searchQuery_.empty() + ? std::string("Search captures...") + : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); + Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom}; + drawText(bmp, sbText, sb.c_str(), searchQuery_.empty() ? kRgbDim : kRgbText); + } + + const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop}; // The browser tabs + card grid, laid out by the pure module over the browser sub-area. // capture_browser lays out from (0,0); offset the draw by browserArea's origin. const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); const int ox = browserArea.left; const int oy = browserArea.top; + scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); // Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights. const int tabCount = static_cast(banks_.size()) + 1; @@ -593,16 +749,24 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { drawTextCentered(bmp, t, label.c_str(), kRgbText); } - // Cards: one per visible sample. Clip at the browser area bottom (scroll is S12). + // Cards: only the S12 visible window at the current scroll offset (a bank longer than the + // panel is reachable by wheel/thumb drag). scrolledCardCellRect shifts each cell up by the + // offset; we clip to the grid region so a partially-scrolled row is trimmed at the edges. const int bins = thumbBins(bl); - for (int i = 0; i < static_cast(visible_.size()); ++i) { + const int cardCount = static_cast(visible_.size()); + const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); + for (int i = vr.first; i < vr.last; ++i) { + // The scrolled CELL, then the same gutter/thumbnail/label insets the pure module derives, + // shifted by the scroll offset (they share the cell's top, so subtract the offset). Rect content = cardContentRect(bl, i); - if (content.top + oy >= browserArea.bottom) break; // past the visible grid Rect thumb = cardThumbnailRect(bl, i); Rect labelR = cardLabelRect(bl, i); - content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy}; - thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy}; - labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy}; + content = Rect{content.left + ox, content.top + oy - scrollOffset_, + content.right + ox, content.bottom + oy - scrollOffset_}; + thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_, + thumb.right + ox, thumb.bottom + oy - scrollOffset_}; + labelR = Rect{labelR.left + ox, labelR.top + oy - scrollOffset_, + labelR.right + ox, labelR.bottom + oy - scrollOffset_}; const SampleChoice& s = visible_[static_cast(i)]; const bool sel = (s.id == selectedId_); @@ -624,6 +788,17 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { drawText(bmp, badgeR, badge.c_str(), kRgbDim); } + // S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure + // module (empty when the content fits — the shell simply draws nothing then). Offset by the + // browser origin like every other card rect. + { + const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); + if (thumb.height() > 0) { + LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(), + kColRootMarker, 0.8f, 0); + } + } + if (havePick) { paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom}); } else if (visible_.empty()) { @@ -769,18 +944,100 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0); } - // A one-line legend of the selected zone below the strip. - Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26}; + // A one-line legend of the selected zone below the strip, with three click-to-type numeric + // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it + // (entryField_) and typed text commits via parseNoteEntry on Enter. + const int legendTop = stripArea.bottom + 8; + Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18}; if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) + - " - " + noteLabel(z.highNote) + " root " + - (z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)")); - drawText(bmp, infoR, info.c_str(), kRgbText); + drawText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom}, + sampleLabel(samples_, z.sampleId).c_str(), kRgbText); + // Three fields laid out left-to-right after the sample label. + const Rect fields = noteEntryFieldsArea(bands); + const char* names[3] = {"Low", "High", "Root"}; + const std::string vals[3] = { + noteLabel(z.lowNote), noteLabel(z.highNote), + z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")}; + for (int f = 0; f < 3; ++f) { + const Rect fr = noteEntryFieldRect(fields, f); + const bool editing = (entryField_ == f); + LICE_FillRect(bmp, fr.left, fr.top, fr.width(), fr.height(), + editing ? kColTabActiveBg : kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1, + kColCardBorder, 1.0f, 0); + std::string cap = std::string(names[f]) + ": " + + (editing ? (entryText_ + "_") : vals[f]); + drawText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(), kRgbText); + } } else if (map_.zones.empty()) { drawText(bmp, infoR, "No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim); } + + // The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger + + // pitch engine + AD pitch envelope). Only when a zone is selected. + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + paintControls(bmp, zonesControlPanel(computeBands(w, h))); + } +} + +// The label + the two toggle-segment captions for a control (member so it can name the private +// ParamControl enum). Segments are only read for a ControlKind::Toggle. +namespace { +struct ControlLabels { const char* label; const char* seg0; const char* seg1; }; +} // namespace + +void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) { + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + const ZonePlayParams play = map_.zones[static_cast(selectedZone_)].play; + const std::vector descs = controlDescs(play); + const std::vector rows = layoutControls(panel, descs); + + const auto labelsFor = [](ParamControl c) -> ControlLabels { + switch (c) { + case ParamControl::kPlayMode: return {"Mode", "Gate", "Trigger"}; + case ParamControl::kPitchEngine: return {"Pitch eng", "Varisp", "Preserve"}; + case ParamControl::kAttack: return {"Attack", "", ""}; + case ParamControl::kHold: return {"Hold", "", ""}; + case ParamControl::kDecay: return {"Decay", "", ""}; + case ParamControl::kSustain: return {"Sustain", "", ""}; + case ParamControl::kRelease: return {"Release", "", ""}; + case ParamControl::kTrigLength: return {"Length %", "", ""}; + case ParamControl::kTrigFadeIn: return {"Fade in", "", ""}; + case ParamControl::kTrigFadeOut: return {"Fade out", "", ""}; + case ParamControl::kPitchEnvEnable: return {"Pitch env", "Off", "On"}; + case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""}; + case ParamControl::kPitchEnvDecay: return {"P.Decay", "", ""}; + case ParamControl::kPitchEnvDepth: return {"P.Depth", "", ""}; + default: return {"", "", ""}; + } + }; + + for (const ControlRow& r : rows) { + if (r.row.top >= panel.bottom) break; // clip at the panel bottom + const ControlLabels lab = labelsFor(static_cast(r.id)); + drawText(bmp, r.label, lab.label, kRgbDim); + const double v = controlValue(r.id, play); + if (r.kind == ControlKind::Toggle) { + const bool seg1 = (v >= 0.5); + const Rect s0 = toggleSegmentRect(r.control, 0); + const Rect s1 = toggleSegmentRect(r.control, 1); + LICE_FillRect(bmp, s0.left, s0.top, s0.width(), s0.height(), + seg1 ? kColTabBg : kColTabActiveBg, 1.0f, 0); + LICE_FillRect(bmp, s1.left, s1.top, s1.width(), s1.height(), + seg1 ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(bmp, s0, lab.seg0, kRgbText); + drawTextCentered(bmp, s1, lab.seg1, kRgbText); + } else { + const Rect track = sliderTrackRect(r.control); + LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, track.width(), 2, + kColStripKey, 1.0f, 0); + const Rect handle = sliderHandleRect(r.control, v); + LICE_FillRect(bmp, handle.left, handle.top + 2, handle.width(), handle.height() - 4, + kColRootMarker, 1.0f, 0); + } + } } // --- Input: the drag-state machine ------------------------------------------- @@ -801,7 +1058,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { const bool havePick = !selectedId_.empty(); const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) : bands.content.bottom; - const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + + // S12 search box (mirror of paintBrowser): a click focuses it; the browser sits below. + const Rect searchBox = searchBoxRect(fullBrowserArea.width()); + const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top, + fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom}; + if (contains(searchAbs, x, y)) { + searchFocused_ = true; + invalidate(); + return; + } + searchFocused_ = false; // any other browser click defocuses the search box + + const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop}; const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); const int bx = x - browserArea.left; const int by = y - browserArea.top; @@ -816,8 +1086,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { invalidate(); return; } - // Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). - const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by); + // S12 scrollbar thumb: grab to drag-scroll (checked before cards — the thumb overlays the + // grid's right gutter). scrollThumbRect is empty when the content fits, so this is inert then. + const Rect thumb = scrollThumbRect(bl, static_cast(visible_.size()), scrollOffset_); + if (thumb.height() > 0 && + contains(Rect{thumb.left + browserArea.left, thumb.top + browserArea.top, + thumb.right + browserArea.left, thumb.bottom + browserArea.top}, x, y)) { + drag_ = DragKind::kScrollThumb; + dragStartY_ = y; + dragStartScrollOffset_ = scrollOffset_; + return; + } + // Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). The + // hit-test adds the scroll offset back so a scrolled card maps to the right index. + const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); if (card >= 0) { selectedId_ = visible_[static_cast(card)].id; commitAndReload(); // publishes the pick + reloads; process() plays it repitched @@ -958,6 +1240,54 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { map_.zones[static_cast(selectedZone_)].rootOverride = note; commitAndReload(); } + return; + } + + // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a + // zone is selected. entryText_ starts empty (the user types the full value). + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const Rect fields = noteEntryFieldsArea(bands); + for (int f = 0; f < 3; ++f) { + if (contains(noteEntryFieldRect(fields, f), x, y)) { + entryField_ = f; + entryText_.clear(); + invalidate(); + return; + } + } + } + entryField_ = -1; // a click elsewhere in the Zones view cancels an in-progress entry + + // The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab + // starts a live drag (commit on release). Only when a zone is selected. + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + const Rect panel = zonesControlPanel(bands); + const std::vector descs = controlDescs(z.play); + const std::vector rows = layoutControls(panel, descs); + const int id = controlAtPoint(rows, x, y); + if (id >= 0) { + // Find the row to know its kind + control rect. + for (const ControlRow& r : rows) { + if (r.id != id) continue; + if (r.kind == ControlKind::Toggle) { + const int seg = toggleSegmentHitTest(r.control, x, y); + if (seg >= 0) { + applyControl(id, z.play, 0.0, seg); + commitAndReload(); // a toggle is a discrete, final edit + } + } else { + // Grab the slider: set the value at the grab x immediately, then live-drag. + drag_ = DragKind::kParamSlider; + dragParamId_ = id; + dragParamPanel_ = panel; + dragStartMap_ = map_; + applyControl(id, z.play, valueAtPoint(r.control, x), 0); + invalidate(); // live feedback; commit on WM_LBUTTONUP + } + break; + } + } } } @@ -1044,6 +1374,40 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { return; } + if (drag_ == DragKind::kScrollThumb) { + // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card + // window recomputes at paint from scrollOffset_. The browser sub-area matches paintBrowser + // when a capture is picked (the setup band takes the bottom). + const int dyThumb = y - dragStartY_; + const bool havePick = !selectedId_.empty(); + const int setupTop = havePick + ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) + : bands.content.bottom; + const BrowserLayout bl = layoutBrowser(bands.content.width(), setupTop - bands.content.top); + scrollOffset_ = thumbDragToOffset(bl, static_cast(visible_.size()), + dragStartScrollOffset_, dyThumb); + invalidate(); + return; + } + + if (drag_ == DragKind::kParamSlider) { + // S12/S15/S16: re-lay the panel and map x -> value against the grabbed control's live + // track rect (the panel geometry is stable during the drag; re-laying keeps the value + // mapping exact even if a mode toggle changed the row set — it did not, mid-drag). + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + const std::vector descs = controlDescs(z.play); + const std::vector rows = layoutControls(dragParamPanel_, descs); + for (const ControlRow& r : rows) { + if (r.id == dragParamId_) { + applyControl(dragParamId_, z.play, valueAtPoint(r.control, x), 0); + break; + } + } + invalidate(); + return; + } + // Zone edits: recompute the grabbed field(s) against the pure resolver, live. if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; const Rect stripArea = zonesStripArea(bands); @@ -1068,11 +1432,79 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) { if (drag_ == DragKind::kNone) return; + const DragKind kind = drag_; drag_ = DragKind::kNone; - // One coherent edit lands on release: publish the in-flight map + reload off-thread. + // A scrollbar drag is transient UI (no map change) — repaint but do NOT reload. Every other + // drag is a coherent map edit: publish the in-flight map + reload off-thread on release. + if (kind == DragKind::kScrollThumb) { + invalidate(); + return; + } commitAndReload(); } +void ReaSamplerEditor::onMouseWheel(int delta) { + // S12 browser scroll (only in the browser view). One wheel notch (WHEEL_DELTA==120) scrolls + // roughly one card row; the offset is clamped at paint (the layout/panel height is known + // there). A positive delta (wheel up) scrolls toward the top (smaller offset). + if (view_ != View::kBrowser) return; + const int rows = delta / 120; + if (rows == 0) return; + scrollOffset_ -= rows * kBrowserCardHeight; + if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content + invalidate(); +} + +void ReaSamplerEditor::onSearchChar(unsigned int ch) { + // S12 numeric note-entry (Zones view): a focused low/high/root field accumulates keystrokes + // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when + // focused, owns the keystrokes). + if (view_ == View::kZones && entryField_ >= 0) { + if (ch == 13) { // Enter: parse + commit + if (auto note = parseNoteEntry(entryText_)) { + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote); + else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote); + else z.rootOverride = *note; + commitAndReload(); + } + } + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 27) { // Escape cancels + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 8) { // backspace + if (!entryText_.empty()) entryText_.pop_back(); + invalidate(); + } else if (ch >= 32 && ch < 127) { + entryText_.push_back(static_cast(ch)); + invalidate(); + } + return; + } + + // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace + // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search). + if (view_ != View::kBrowser || !searchFocused_) return; + if (ch == 8) { // backspace + if (!searchQuery_.empty()) searchQuery_.pop_back(); + } else if (ch == 27) { // escape clears + defocuses + searchQuery_.clear(); + searchFocused_ = false; + } else if (ch >= 32 && ch < 127) { + searchQuery_.push_back(static_cast(ch)); + } else { + return; // ignore other control chars + } + scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list + rebuildVisible(); + invalidate(); +} + LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { auto* self = @@ -1088,12 +1520,24 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, case WM_LBUTTONDOWN: if (self) { SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child + SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); } return 0; case WM_MOUSEMOVE: if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; + case WM_MOUSEWHEEL: + // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. + if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); + return 0; + case WM_CHAR: + // S12 type-to-filter search keystrokes (only acted on when the search box is focused). + if (self) self->onSearchChar(static_cast(wParam)); + return 0; + case WM_GETDLGCODE: + // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). + return DLGC_WANTCHARS | DLGC_WANTARROWS; case WM_LBUTTONUP: if (self) { self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); @@ -1106,7 +1550,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. if (self && self->drag_ != DragKind::kNone) { - self->map_ = self->dragStartMap_; + // A scrollbar drag is transient (no map mutation + dragStartMap_ was not + // snapshotted for it) — reset the drag state only, never touch map_. Every + // map-editing drag rolls its live mutation back to the pre-grab snapshot. + if (self->drag_ != DragKind::kScrollThumb) self->map_ = self->dragStartMap_; self->drag_ = DragKind::kNone; self->invalidate(); } diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 6959bd3..d8af4af 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -31,6 +31,7 @@ #include "public.sdk/source/common/pluginview.h" #include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) +#include "param_slider.h" // ControlRow (the S12/S15/S16 control-surface geometry) #include "peaks.h" // Envelope (the cached peak thumbnail) #include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) @@ -70,7 +71,29 @@ private: // flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the // single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop // marker on the S11 waveform surface (which marker is in waveMarker_). - enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker }; + enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker, + kScrollThumb, kParamSlider }; + + // The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces). + // The int value is the ControlDesc id the pure param_slider hit-test returns; the shell + // maps it to the picked zone's play params. Order here is the panel's top-down stack order. + enum class ParamControl { + kPlayMode = 0, // Gate | Trigger toggle (S15) + kPitchEngine, // Varispeed | Preserve toggle (S16) + kAttack, // AHDSR attack (Gate) / — + kHold, // AHDSR hold (Gate, S15) + kDecay, // AHDSR decay (Gate) + kSustain, // AHDSR sustain (Gate) + kRelease, // AHDSR release (Gate) + kTrigLength, // Trigger %-length (Trigger, S15) + kTrigFadeIn, // Trigger fade-in (Trigger, S15) + kTrigFadeOut, // Trigger fade-out (Trigger, S15) + kPitchEnvEnable, // AD pitch envelope on|off (S16) + kPitchEnvAttack, // AD pitch attack (S16) + kPitchEnvDecay, // AD pitch decay (S16) + kPitchEnvDepth, // AD pitch depth in +/- semitones (S16) + kCount + }; // The waveform markers on the single-capture setup surface (S11). Order is the draw + hit // order (start first). Named generically per the spec so S15 can repurpose the surface with @@ -83,10 +106,13 @@ private: void paintSetup(LICE_IBitmap* bmp, const Rect& area); void paintZones(LICE_IBitmap* bmp, int w, int h); void paintEmptyState(LICE_IBitmap* bmp, const Rect& area); + void paintControls(LICE_IBitmap* bmp, const Rect& panel); // S12/S15/S16 param surface void onMouseDown(int x, int y); void onMouseMove(int x, int y); void onMouseUp(int x, int y); + void onMouseWheel(int delta); // S12 browser scroll (wheel) + void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke // The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the // audio thread). Polls the processor's bank-sync (generation change -> hands-free reload; @@ -149,6 +175,25 @@ private: // final commit. selectedId_ must be non-empty before calling. void upsertPickedOverride(const SetupMarkers& m); + // --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------ + // + // The control panel edits the SELECTED zone's ZonePlayParams (S15 play mode + AHDSR; S16 + // pitch engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact. + + // The control descriptors the panel shows for `play`'s CURRENT play mode: the two toggles + + // the mode-relevant sliders (AHDSR for Gate, %-length/fades for Trigger) + the pitch-envelope + // controls. The pure param_slider lays these out; this only picks the set. Static (a free + // choice of set from the mode) — kept a member for the ParamControl enum access. + std::vector controlDescs(const ZonePlayParams& play) const; + + // The normalized [0,1] display value for control `id` given `play` (the shell's domain + // mapping: frames->0..1 over a fixed max, sustain 0..1 as-is, semitone depth centered at 0.5). + double controlValue(int id, const ZonePlayParams& play) const; + + // Apply a committed control interaction to `play`: a slider's normalized `value` (mapped back + // into the control's engine domain) or a toggle's `segment` (0/1). Mutates `play` in place. + void applyControl(int id, ZonePlayParams& play, double value, int segment) const; + ReaSamplerProcessor* processor_ = nullptr; // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- @@ -164,9 +209,22 @@ private: std::string activeFilterBankId_; // "" = All; else a bank id from banks_ int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none + // --- S12 browser scroll + search (transient UI state, never persisted) -------- + int scrollOffset_ = 0; // vertical px offset into the card grid (clamped) + std::string searchQuery_; // type-to-filter narrow; "" = no search + bool searchFocused_ = false; // whether the search box has keyboard focus + + // --- S12 numeric note entry (LICE text-entry idiom, transient) ---------------- + // When >= 0, a low/high/root field is being typed; entryText_ accumulates the keystrokes + // and commits (parseNoteEntry) on Enter. -1 = no field editing. The field id is a + // ParamControl-independent small enum encoded inline (see the .cpp: 0=low,1=high,2=root). + int entryField_ = -1; + std::string entryText_; + // --- Drag-state machine ------------------------------------------------------ DragKind drag_ = DragKind::kNone; int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver + int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag int dragStartLow_ = 0; // the grabbed field's note at grab time int dragStartHigh_ = 0; int dragStartRoot_ = 60; @@ -179,6 +237,13 @@ private: SetupMarkers dragStartMarkers_; std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag + // S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts + // from it). S12/S15/S16 param-slider drag: which control id + the panel it lives in (the + // shell re-lays the panel each move to map x->value against the live control rect). + int dragStartScrollOffset_ = 0; + int dragParamId_ = -1; + Rect dragParamPanel_{}; + // --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------ // Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail. diff --git a/tests/test_browser_scroll.cpp b/tests/test_browser_scroll.cpp new file mode 100644 index 0000000..9448f52 --- /dev/null +++ b/tests/test_browser_scroll.cpp @@ -0,0 +1,179 @@ +// Standalone tests for reasampler::vst::browser_scroll — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure editor tests: assert the S12 scroll-window + +// scrollbar-thumb + type-to-filter-search geometry LAYERED over the S10 capture_browser. +// +// Covers: scrollContentHeight (ceil rows * card height, 0 for no cards); scrollMaxOffset (0 +// when content fits, else content-visible); clampScrollOffset pinning to [0,max]; visibleCardRange +// windowing (top rows only, scrolled window, empty when scrolled past the end); scrolledCardCellRect +// shifting a cell up by the offset; scrollThumbRect (empty when it fits, proportional height + +// position, minimum height, at-max pins to the track bottom); thumbDragToOffset as the position +// inverse (a full-track drag reaches max, round-trips); searchBoxRect; nameMatchesQuery +// (case-insensitive substring, empty-query identity, no-match); filterNameIndices preserving order +// and returning every index for an empty query. + +#include "../src/vst/browser_scroll.h" + +#include +#include +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A layout wide enough for a few columns and tall enough to show a few rows. +static BrowserLayout wideLayout() { return layoutBrowser(560, 300); } + +// --- content / max / clamp ---------------------------------------------------- + +static void testContentHeight() { + const BrowserLayout L = wideLayout(); + CHECK(scrollContentHeight(L, 0) == 0); + // One card -> one row -> one card height. + CHECK(scrollContentHeight(L, 1) == kBrowserCardHeight); + // columns+1 cards -> two rows. + CHECK(scrollContentHeight(L, L.columns + 1) == 2 * kBrowserCardHeight); + // Exactly `columns` cards -> one row. + CHECK(scrollContentHeight(L, L.columns) == kBrowserCardHeight); +} + +static void testMaxOffsetFitsAndOverflows() { + const BrowserLayout L = wideLayout(); + // A single row fits within the 300px area -> no scroll. + CHECK(scrollMaxOffset(L, L.columns) == 0); + // Many rows overflow -> max = content - gridHeight. + const int many = L.columns * 20; + const int expect = scrollContentHeight(L, many) - L.grid.height(); + CHECK(scrollMaxOffset(L, many) == expect); + CHECK(expect > 0); +} + +static void testClamp() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const int maxOff = scrollMaxOffset(L, many); + CHECK(clampScrollOffset(L, many, -50) == 0); + CHECK(clampScrollOffset(L, many, maxOff + 500) == maxOff); + CHECK(clampScrollOffset(L, many, maxOff / 2) == maxOff / 2); +} + +// --- visible window ----------------------------------------------------------- + +static void testVisibleRangeTop() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const VisibleRange vr = visibleCardRange(L, many, 0); + CHECK(vr.first == 0); + // At offset 0, the last visible row is the one containing (gridH-1). + const int expectedLastRow = (L.grid.height() - 1) / kBrowserCardHeight + 1; + CHECK(vr.last == expectedLastRow * L.columns); +} + +static void testVisibleRangeScrolled() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + // Scroll one full card row down. + const VisibleRange vr = visibleCardRange(L, many, kBrowserCardHeight); + CHECK(vr.first == L.columns); // the first row scrolled off the top +} + +static void testVisibleRangeEmptyWhenNoCards() { + const BrowserLayout L = wideLayout(); + const VisibleRange vr = visibleCardRange(L, 0, 0); + CHECK(vr.first == 0 && vr.last == 0); +} + +static void testScrolledCellShiftsUp() { + const BrowserLayout L = wideLayout(); + const Rect base = cardCellRect(L, 3); + const Rect shifted = scrolledCardCellRect(L, 3, 40); + CHECK(shifted.top == base.top - 40); + CHECK(shifted.bottom == base.bottom - 40); + CHECK(shifted.left == base.left); +} + +// --- scrollbar thumb ---------------------------------------------------------- + +static void testThumbEmptyWhenFits() { + const BrowserLayout L = wideLayout(); + CHECK(scrollThumbRect(L, L.columns, 0).height() == 0); // one row fits -> no thumb +} + +static void testThumbProportionalAndClamped() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const Rect atTop = scrollThumbRect(L, many, 0); + CHECK(atTop.height() > 0); + CHECK(atTop.top == L.grid.top); // at offset 0 the thumb starts at the track top + CHECK(atTop.width() == kScrollbarWidth); + CHECK(atTop.right == L.grid.right); + // At max offset, the thumb bottom reaches the grid bottom (pinned to the end). + const int maxOff = scrollMaxOffset(L, many); + const Rect atMax = scrollThumbRect(L, many, maxOff); + CHECK(atMax.bottom == L.grid.top + L.grid.height()); +} + +static void testThumbDragIsInverse() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const int maxOff = scrollMaxOffset(L, many); + // A zero drag holds the start offset. + CHECK(thumbDragToOffset(L, many, 0, 0) == 0); + // A large positive drag pins to max; a large negative drag pins to 0. + CHECK(thumbDragToOffset(L, many, 0, 100000) == maxOff); + CHECK(thumbDragToOffset(L, many, maxOff, -100000) == 0); + // Dragging the thumb by the whole track span from top reaches (near) max. + const Rect thumb = scrollThumbRect(L, many, 0); + const int trackSpan = L.grid.height() - thumb.height(); + const int off = thumbDragToOffset(L, many, 0, trackSpan); + CHECK(off >= maxOff - 2 && off <= maxOff); +} + +// --- search ------------------------------------------------------------------- + +static void testSearchBoxRect() { + const Rect r = searchBoxRect(200); + CHECK(r.left == 0 && r.top == 0 && r.right == 200 && r.height() == kSearchBoxHeight); + CHECK(searchBoxRect(0).width() == 0); +} + +static void testNameMatch() { + CHECK(nameMatchesQuery("Kick Drum 01", "")); // empty query matches all + CHECK(nameMatchesQuery("Kick Drum 01", "drum")); // case-insensitive substring + CHECK(nameMatchesQuery("Kick Drum 01", "KICK")); + CHECK(!nameMatchesQuery("Kick Drum 01", "snare")); + CHECK(!nameMatchesQuery("ab", "abc")); // query longer than name +} + +static void testFilterIndices() { + std::vector names{"Kick", "Snare", "Kick Sub", "Hat"}; + // Empty query -> every index, in order. + const std::vector all = filterNameIndices(names, ""); + CHECK(all.size() == 4 && all[0] == 0 && all[3] == 3); + // "kick" -> indices 0 and 2, order preserved. + const std::vector kicks = filterNameIndices(names, "kick"); + CHECK(kicks.size() == 2 && kicks[0] == 0 && kicks[1] == 2); + // No match -> empty. + CHECK(filterNameIndices(names, "zzz").empty()); +} + +int main() { + testContentHeight(); + testMaxOffsetFitsAndOverflows(); + testClamp(); + testVisibleRangeTop(); + testVisibleRangeScrolled(); + testVisibleRangeEmptyWhenNoCards(); + testScrolledCellShiftsUp(); + testThumbEmptyWhenFits(); + testThumbProportionalAndClamped(); + testThumbDragIsInverse(); + testSearchBoxRect(); + testNameMatch(); + testFilterIndices(); + + if (g_fail == 0) std::printf("browser_scroll: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_note_entry.cpp b/tests/test_note_entry.cpp new file mode 100644 index 0000000..4990001 --- /dev/null +++ b/tests/test_note_entry.cpp @@ -0,0 +1,73 @@ +// Standalone tests for reasampler::vst::note_entry — no VST3, no REAPER, no framework. +// Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note. +// +// Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the +// C4==60 convention (C-1==0, sharps + flats, negative octaves); out-of-range values CLAMPING to +// [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt; +// the integer path taking precedence over the note-name path for a leading digit. + +#include "../src/vst/note_entry.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static void testPlainIntegers() { + CHECK(parseNoteEntry("60") == 60); + CHECK(parseNoteEntry("0") == 0); + CHECK(parseNoteEntry("127") == 127); + CHECK(parseNoteEntry(" 64 ") == 64); // surrounding whitespace ignored + CHECK(parseNoteEntry("+5") == 5); +} + +static void testIntegerClamps() { + CHECK(parseNoteEntry("200") == 127); // over-range clamps to the ceiling + CHECK(parseNoteEntry("-10") == 0); // under-range clamps to the floor + CHECK(parseNoteEntry("99999") == 127); +} + +static void testNoteNames() { + // C4 == 60 (MIDI 0 == C-1). + CHECK(parseNoteEntry("C4") == 60); + CHECK(parseNoteEntry("c4") == 60); // case-insensitive + CHECK(parseNoteEntry("A4") == 69); // A4 = 69 (concert A) + CHECK(parseNoteEntry("C-1") == 0); // lowest MIDI note + CHECK(parseNoteEntry("G9") == 127); // G9 = 127 +} + +static void testAccidentals() { + CHECK(parseNoteEntry("C#4") == 61); + CHECK(parseNoteEntry("Db4") == 61); // enharmonic of C#4 + CHECK(parseNoteEntry("F#3") == 54); + CHECK(parseNoteEntry("Bb3") == 58); // Bb3 = 58 +} + +static void testNoteNameClamps() { + CHECK(parseNoteEntry("C10") == 127); // above the range clamps + CHECK(parseNoteEntry("C-5") == 0); // below the range clamps +} + +static void testRejects() { + CHECK(parseNoteEntry("") == std::nullopt); + CHECK(parseNoteEntry(" ") == std::nullopt); + CHECK(parseNoteEntry("hello") == std::nullopt); + CHECK(parseNoteEntry("C") == std::nullopt); // a bare letter with no octave is ambiguous + CHECK(parseNoteEntry("H4") == std::nullopt); // H is not a note letter + CHECK(parseNoteEntry("+") == std::nullopt); +} + +int main() { + testPlainIntegers(); + testIntegerClamps(); + testNoteNames(); + testAccidentals(); + testNoteNameClamps(); + testRejects(); + + if (g_fail == 0) std::printf("note_entry: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_param_slider.cpp b/tests/test_param_slider.cpp new file mode 100644 index 0000000..7887c9d --- /dev/null +++ b/tests/test_param_slider.cpp @@ -0,0 +1,204 @@ +// Standalone tests for reasampler::vst::param_slider — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure editor tests (capture_browser / keyboard_strip): +// assert the S12/S15/S16 control-surface layout, toggle-segment split + hit-test, slider +// value<->pixel mapping (round-trip + clamping + endpoints), and point->control routing. +// +// Covers: layoutControls stacking rows top-down with the label column + control column and the +// inter-row gap; an empty list / degenerate panel yielding nothing; toggleSegmentRect splitting +// a toggle into two tiling segments (last absorbs the remainder) + toggleSegmentHitTest; +// sliderTrackRect insetting a half-handle at each end; sliderHandleRect at value 0/0.5/1 and +// out-of-range clamping; valueAtPoint mapping x back to 0..1 (endpoints saturate) as the inverse +// of the handle position; controlAtPoint routing a point to the right control id (toggle whole +// area vs slider track) and MISSING in the label column, a row gap, and off-panel. + +#include "../src/vst/param_slider.h" + +#include +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static bool approx(double a, double b) { return (a - b) < 1e-9 && (b - a) < 1e-9; } + +// --- layoutControls ----------------------------------------------------------- + +static void testLayoutStacksRows() { + const Rect panel{0, 100, 300, 400}; + std::vector ctl{ + {1, ControlKind::Toggle}, + {2, ControlKind::Slider}, + {3, ControlKind::Slider}, + }; + const std::vector rows = layoutControls(panel, ctl); + CHECK(rows.size() == 3); + // Row 0 sits at the panel top; each subsequent row is one row-height + gap below. + CHECK(rows[0].row.top == 100); + CHECK(rows[0].row.bottom == 100 + kControlRowHeight); + CHECK(rows[1].row.top == rows[0].row.bottom + kControlRowGap); + CHECK(rows[2].row.top == rows[1].row.bottom + kControlRowGap); + // Ids + kinds carried through in order. + CHECK(rows[0].id == 1 && rows[0].kind == ControlKind::Toggle); + CHECK(rows[1].id == 2 && rows[1].kind == ControlKind::Slider); + // Label column then control column, contiguous, spanning the panel width. + CHECK(rows[0].label.left == panel.left); + CHECK(rows[0].control.left == rows[0].label.right); + CHECK(rows[0].control.right == panel.right); + CHECK(rows[0].label.width() == kControlLabelWidth); +} + +static void testLayoutEmptyAndDegenerate() { + CHECK(layoutControls(Rect{0, 0, 300, 300}, {}).empty()); + std::vector ctl{{1, ControlKind::Slider}}; + CHECK(layoutControls(Rect{0, 0, 0, 0}, ctl).empty()); + CHECK(layoutControls(Rect{0, 0, 300, 0}, ctl).empty()); +} + +static void testLayoutNarrowPanelClampsLabel() { + // A panel narrower than 2*labelWidth clamps the label column to half so a control column + // survives. + const Rect panel{0, 0, 100, 200}; + const std::vector rows = layoutControls(panel, {{1, ControlKind::Slider}}); + CHECK(rows.size() == 1); + CHECK(rows[0].label.width() <= panel.width() / 2 + 1); + CHECK(rows[0].control.width() > 0); +} + +// --- toggle ------------------------------------------------------------------- + +static void testToggleSegmentsTile() { + const Rect control{100, 0, 300, 22}; // width 200 + const Rect s0 = toggleSegmentRect(control, 0); + const Rect s1 = toggleSegmentRect(control, 1); + CHECK(s0.left == 100 && s0.right == 200); + CHECK(s1.left == 200 && s1.right == 300); // last absorbs remainder -> reaches control.right + // Out of range. + CHECK(toggleSegmentRect(control, 2).width() == 0); + CHECK(toggleSegmentRect(control, -1).width() == 0); +} + +static void testToggleSegmentRemainderInLast() { + const Rect control{0, 0, 201, 22}; // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder) + CHECK(toggleSegmentRect(control, 0).width() == 100); + CHECK(toggleSegmentRect(control, 1).right == 201); +} + +static void testToggleHitTest() { + const Rect control{100, 0, 300, 22}; + CHECK(toggleSegmentHitTest(control, 150, 10) == 0); + CHECK(toggleSegmentHitTest(control, 250, 10) == 1); + CHECK(toggleSegmentHitTest(control, 50, 10) == -1); // left of control + CHECK(toggleSegmentHitTest(control, 150, 40) == -1); // below control +} + +// --- slider ------------------------------------------------------------------- + +static void testSliderTrackInsetsHalfHandle() { + const Rect control{100, 0, 300, 22}; + const Rect track = sliderTrackRect(control); + CHECK(track.left == control.left + kSliderHandleWidth / 2); + CHECK(track.right == control.right - kSliderHandleWidth / 2); + // A control too narrow for a handle yields an empty track. + CHECK(sliderTrackRect(Rect{0, 0, kSliderHandleWidth - 1, 22}).width() == 0); +} + +static void testSliderHandleAtEndpointsAndMid() { + const Rect control{100, 0, 300, 22}; + const Rect track = sliderTrackRect(control); + const int half = kSliderHandleWidth / 2; + // Value 0 -> handle centered at track.left. + const Rect h0 = sliderHandleRect(control, 0.0); + CHECK(h0.left + half == track.left); + // Value 1 -> handle centered at track.right. + const Rect h1 = sliderHandleRect(control, 1.0); + CHECK(h1.left + half == track.right); + // Value 0.5 -> centered at the track middle. + const Rect hm = sliderHandleRect(control, 0.5); + CHECK(hm.left + half == track.left + track.width() / 2); +} + +static void testSliderHandleClampsOutOfRange() { + const Rect control{0, 0, 200, 22}; + CHECK(sliderHandleRect(control, -0.5).left == sliderHandleRect(control, 0.0).left); + CHECK(sliderHandleRect(control, 5.0).left == sliderHandleRect(control, 1.0).left); +} + +static void testValueAtPointEndpointsSaturate() { + const Rect control{100, 0, 300, 22}; + const Rect track = sliderTrackRect(control); + CHECK(approx(valueAtPoint(control, track.left - 20), 0.0)); + CHECK(approx(valueAtPoint(control, track.left), 0.0)); + CHECK(approx(valueAtPoint(control, track.right + 20), 1.0)); + CHECK(approx(valueAtPoint(control, track.right), 1.0)); +} + +static void testValueAtPointIsHandleInverse() { + // Round-trip: a value -> handle center -> valueAtPoint recovers (within one pixel quantum). + const Rect control{50, 0, 450, 22}; // wide track for pixel resolution + const Rect track = sliderTrackRect(control); + for (double v : {0.1, 0.25, 0.5, 0.75, 0.9}) { + const Rect h = sliderHandleRect(control, v); + const int centerX = h.left + kSliderHandleWidth / 2; + const double back = valueAtPoint(control, centerX); + CHECK(back >= v - 0.01 && back <= v + 0.01); + CHECK(centerX >= track.left && centerX <= track.right); + } +} + +static void testValueAtPointDegenerateTrack() { + CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0)); +} + +// --- controlAtPoint routing --------------------------------------------------- + +static void testControlAtPointRoutes() { + const Rect panel{0, 0, 300, 400}; + std::vector ctl{ + {10, ControlKind::Toggle}, + {20, ControlKind::Slider}, + }; + const std::vector rows = layoutControls(panel, ctl); + // A point in the toggle's control area routes to the toggle id. + const Rect tctl = rows[0].control; + CHECK(controlAtPoint(rows, (tctl.left + tctl.right) / 2, (tctl.top + tctl.bottom) / 2) == 10); + // A point on the slider's track routes to the slider id. + const Rect strack = sliderTrackRect(rows[1].control); + CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2, + (strack.top + strack.bottom) / 2) == 20); +} + +static void testControlAtPointMisses() { + const Rect panel{0, 0, 300, 400}; + const std::vector rows = + layoutControls(panel, {{10, ControlKind::Toggle}, {20, ControlKind::Slider}}); + // The label column is not interactive. + CHECK(controlAtPoint(rows, rows[0].label.left + 2, rows[0].label.top + 4) == -1); + // The gap between rows is a miss. + const int gapY = rows[0].row.bottom + kControlRowGap / 2; + CHECK(controlAtPoint(rows, 200, gapY) == -1); + // Off-panel below. + CHECK(controlAtPoint(rows, 200, 5000) == -1); +} + +int main() { + testLayoutStacksRows(); + testLayoutEmptyAndDegenerate(); + testLayoutNarrowPanelClampsLabel(); + testToggleSegmentsTile(); + testToggleSegmentRemainderInLast(); + testToggleHitTest(); + testSliderTrackInsetsHalfHandle(); + testSliderHandleAtEndpointsAndMid(); + testSliderHandleClampsOutOfRange(); + testValueAtPointEndpointsSaturate(); + testValueAtPointIsHandleInverse(); + testValueAtPointDegenerateTrack(); + testControlAtPointRoutes(); + testControlAtPointMisses(); + + if (g_fail == 0) std::printf("param_slider: all tests passed\n"); + return g_fail != 0; +} From 7fb778206c7c067858f4e0e0a72c828db0c7436d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 01:38:48 -0400 Subject: [PATCH 41/49] fix(S12 review): per-zone A/D/S/R reaches voice; zones payload v4 + v3 back-compat Voice::start reads full zone ADSR instead of folding only holdFrames into instrument-wide gateAdsr. Wire format bumped to v4 (four new fields); v3 blobs lift A/D/S/R to kTier0Nominal* constants. Single-capture editor controls now reachable. Minor comment and geometry fixes. --- src/vst/reasampler_editor.cpp | 67 +++++++++++++++++++++++------- src/vst/sample_map.cpp | 27 +++++++++++- src/vst/sample_map.h | 58 +++++++++++++++++++------- src/vst/sampler_core.cpp | 19 ++++++--- tests/test_sample_map.cpp | 60 +++++++++++++++++++++++++++ tests/test_sampler_core.cpp | 77 +++++++++++++++++++++++++++++++---- 6 files changed, 266 insertions(+), 42 deletions(-) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index f0e7df8..9d2f7e6 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -299,10 +299,13 @@ void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { namespace { // The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is -// engine-free and maps only 0..1). Time sliders span [0, max] frames at a nominal rate so a -// full-throw reaches a musically generous ceiling; the exact wall-clock is DAW-verified. These -// are build-time residuals (one place to retune), not persisted. -constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling +// engine-free and maps only 0..1). Time sliders span [0, max] frames at the NOMINAL 44100 Hz +// rate; the stored frame count is host-rate-independent, so at other DAW rates the same slider +// position maps to a slightly different wall-clock duration. The ceiling is kept nominal-only +// because the host rate is not reachable inside the editor without a processor callback, and the +// approximation is musically negligible (±1-2 ms at typical rates). Build-time residual — one +// place to retune; not persisted. +constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (44100 nominal) constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } @@ -600,12 +603,11 @@ Rect zonesStripArea(const EditorBands& bands) { // The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the // sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw + -// hit-test use this single formula so they never drift. +// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom so +// the legend top tracks the strip bottom without re-inlining the strip arithmetic here. Rect noteEntryFieldsArea(const EditorBands& bands) { - const Rect strip = Rect{bands.content.left + 8, - bands.content.top + 4 + 20 + 12 + kStripBandHeight, - bands.content.right - 8, 0}; - const int top = strip.top + 8; // legendTop (== zonesStripArea.bottom + 8) + const int stripBottom = zonesStripArea(bands).bottom; + const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom + 8) return Rect{bands.content.left + 8 + 128, top, bands.content.right - 8, top + 18}; } @@ -976,8 +978,13 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { } // The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger + - // pitch engine + AD pitch envelope). Only when a zone is selected. - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + // pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the + // single-capture face when the map is empty but a capture is picked (S15-F2 lean: the + // single capture is already a one-zone map — one storage site serves both). + const bool haveControlTarget = + (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) || + (map_.zones.empty() && !selectedId_.empty()); + if (haveControlTarget) { paintControls(bmp, zonesControlPanel(computeBands(w, h))); } } @@ -989,8 +996,18 @@ struct ControlLabels { const char* label; const char* seg0; const char* seg1; }; } // namespace void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) { - if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; - const ZonePlayParams play = map_.zones[static_cast(selectedZone_)].play; + // Resolve the play params: from the selected zone when one is chosen, or from the + // PerformanceZone product defaults when the map is empty but a capture is picked + // (S15-F2 lean: the single-capture face shares the same storage site as a one-zone map; + // see paintZones for the gate that reaches here). + ZonePlayParams play; + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + play = map_.zones[static_cast(selectedZone_)].play; + } else if (map_.zones.empty() && !selectedId_.empty()) { + play = PerformanceZone{}.play; // product defaults (Gate + Preserve + tier-0 ADSR) + } else { + return; // no control target + } const std::vector descs = controlDescs(play); const std::vector rows = layoutControls(panel, descs); @@ -1259,7 +1276,29 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { entryField_ = -1; // a click elsewhere in the Zones view cancels an in-progress entry // The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab - // starts a live drag (commit on release). Only when a zone is selected. + // starts a live drag (commit on release). Reachable for an explicit zone selection OR for + // the single-capture face when the map is empty but a capture is picked (S15-F2 lean). + // In the empty-map+picked case, auto-create a full-keyboard zone for selectedId_ on first + // control interaction (same path as "+ Add Zone"), then apply the control — the zone is + // committed as part of the control edit. + if (selectedZone_ < 0 && map_.zones.empty() && !selectedId_.empty()) { + // Synthesize a probe layout with the product defaults to see if the click is in the + // panel before committing to creating the zone. + const Rect panel = zonesControlPanel(bands); + const ZonePlayParams defaultPlay = PerformanceZone{}.play; + const std::vector probeDescs = controlDescs(defaultPlay); + const std::vector probeRows = layoutControls(panel, probeDescs); + if (controlAtPoint(probeRows, x, y) >= 0) { + // The click lands in the control panel — materialize the zone now. + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + map_.zones.push_back(z); + selectedZone_ = 0; + // Fall through to the control handler below which will process the click. + } + } if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; const Rect panel = zonesControlPanel(bands); diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 90a937c..bfa7275 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -356,6 +356,13 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) putU64le(out, asU64(pp.pitchEnv.attackFrames)); putU64le(out, asU64(pp.pitchEnv.decayFrames)); putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); + // S12 review fix (PAYLOAD v4): the full per-zone AHDSR A/D/S/R tail (always present in v4). + // Voice::start now uses the zone's full ADSR; old v3 blobs lift to tier-0 nominal defaults + // at read time (see readZonesPayload) so the voice sounds bit-identical to the pre-fix build. + putU64le(out, asU64(pp.adsr.attackFrames)); + putU64le(out, asU64(pp.adsr.decayFrames)); + putU64le(out, doubleToBits(pp.adsr.sustainLevel)); + putU64le(out, asU64(pp.adsr.releaseFrames)); } } @@ -367,11 +374,13 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) void readZonesPayload(ByteReader& r, PerformanceMap& map) { bool extended = false; // v2+: the S11 loop/start tail is present bool hasPlay = false; // v3+: the S15/S16 play-params tail is present + bool hasAdsr = false; // v4+: the full A/D/S/R per-zone tail is present if (r.peekU32() == kZonesFormatMarker) { r.u32(); // consume the marker const std::uint32_t pv = r.u32(); // payload version extended = (pv >= 2); // v2+ carries the loop/start tail hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail + hasAdsr = (pv >= 4); // v4+ carries the full A/D/S/R tail } const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { @@ -397,7 +406,7 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { if (hasStart) z.startPoint = r.i64(); } if (hasPlay) { - // S15/S16 play params, always present in a v3 record (read in the emit order). + // S15/S16 play params, always present in a v3+ record (read in the emit order). z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; z.play.adsr.holdFrames = r.i64(); z.play.trigger.lengthFraction = bitsToDouble(r.u64()); @@ -409,6 +418,22 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { z.play.pitchEnv.decayFrames = r.i64(); z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); } + if (hasAdsr) { + // S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order. + z.play.adsr.attackFrames = r.i64(); + z.play.adsr.decayFrames = r.i64(); + z.play.adsr.sustainLevel = bitsToDouble(r.u64()); + z.play.adsr.releaseFrames = r.i64(); + } else if (hasPlay) { + // v3 blob: A/D/S/R fields are absent. Lift to the tier-0 nominal defaults (44100 Hz) + // so a voice playing this zone sounds bit-identical to the pre-v4 build (back-compat). + // Voice::start now uses the zone's full ADSR; a zone with these values reproduces + // the instrument-wide tier0Adsr behavior the pre-fix code applied unconditionally. + z.play.adsr.attackFrames = kTier0NominalAttackFrames; + z.play.adsr.decayFrames = kTier0NominalDecayFrames; + z.play.adsr.sustainLevel = kTier0NominalSustainLevel; + z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; + } if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest map.zones.push_back(std::move(z)); } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index b51430d..866bfef 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -107,6 +107,19 @@ std::vector downmixToMono(const std::vector& interleav std::vector extractChannel(const std::vector& interleaved, int channelCount, int which); +// Nominal tier-0 ADSR defaults (frames at 44100 Hz) — used when lifting a pre-v4 zones payload +// blob whose per-zone A/D/S/R fields are absent. These reproduce the instrument-wide tier0Adsr +// behavior (0.003 s attack, 0.0 s decay, sustain 1.0, 0.060 s release at 44100 Hz) so that a +// zone loaded from an old blob sounds bit-identical to the pre-v4 build. DAWs at other sample +// rates will be close but not exact (the same approximation the instrument already makes when +// building tier0Adsr from its compile-time kAttackSeconds / kReleaseSeconds constants). +// Must be declared before buildTier0Keymap (default arg) and PerformanceZone / ResolvedZone +// (member initializers) — both of which reference these values. +inline constexpr std::int64_t kTier0NominalAttackFrames = 132; // 0.003 * 44100, rounded +inline constexpr std::int64_t kTier0NominalDecayFrames = 0; +inline constexpr double kTier0NominalSustainLevel = 1.0; +inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 44100 + // Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole // keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case // (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0 @@ -122,8 +135,11 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector framesR = {}, const ZonePlayParams& play = ZonePlayParams{ - PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, - PitchEnvParams{}}); + PlayMode::Gate, + AdsrParams{kTier0NominalAttackFrames, 0, + kTier0NominalDecayFrames, kTier0NominalSustainLevel, + kTier0NominalReleaseFrames}, + TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}); // --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // @@ -155,16 +171,19 @@ struct PerformanceZone { std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional startPoint; // instrument-owned initial read frame; absent -> 0 - // S15/S16 per-zone play parameters (play mode + AHDSR hold + Trigger %-length/fades; pitch + // S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch // engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the // loop/start overrides. Defaults to the PRODUCT defaults for a NEW zone: Gate play mode, - // hold 0, no fades, and the PRESERVE pitch engine (S16-F1 — Daniel's directive; the one - // flippable default is sampler_core::kDefaultPitchEngine), pitch envelope off. An older - // zone-payload blob (no S15/S16 tail) lifts to exactly these defaults on read (see the - // PAYLOAD v3 versioning in the (de)serialize section), so a pre-S15 instrument opens with - // Gate + Preserve — the deliberate, spec-flagged behavior change. - ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, - PitchEnvParams{}}; + // AHDSR with the tier-0 nominal A/D/S/R (kTier0Nominal* at 44100 Hz — the same values a + // v3 blob lifts to), hold 0, no fades, and the PRESERVE pitch engine (S16-F1), pitch env + // off. An older zone-payload blob (no S15/S16 tail or no v4 A/D/S/R tail) lifts to exactly + // these defaults on read (see the PAYLOAD v3/v4 versioning), so a pre-v4 instrument opens + // with Gate + Preserve + tier-0 ADSR — the deliberate back-compat path. + ZonePlayParams play{PlayMode::Gate, + AdsrParams{kTier0NominalAttackFrames, 0, + kTier0NominalDecayFrames, kTier0NominalSustainLevel, + kTier0NominalReleaseFrames}, + TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -188,7 +207,11 @@ struct ResolvedZone { int rootNote = 60; // effective: override, else bank intrinsic, else 60 SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) - ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, + ZonePlayParams play{PlayMode::Gate, + AdsrParams{kTier0NominalAttackFrames, 0, + kTier0NominalDecayFrames, kTier0NominalSustainLevel, + kTier0NominalReleaseFrames}, + TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is) }; @@ -273,8 +296,7 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // appended to each zone record after the S11 startPoint tail (the S15/S16 per-zone play // params — always present, NOT flag-gated, since every zone has a play mode + engine): // 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage (A/D/S/R timing stays -// instrument-wide; only hold is per-zone); +// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage; // 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); // 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); // 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); @@ -283,6 +305,14 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + // no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved // instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. +// * PAYLOAD v4 (S12 review fix): the same marker + payload version (== 4), THEN the v3 body +// PLUS, appended to each zone record after the v3 pitch-env tail, the full per-zone A/D/S/R: +// 8-byte LE adsr.attackFrames (int64); 8-byte LE adsr.decayFrames (int64); +// 8-byte LE adsr.sustainLevel as an IEEE-754 double (bit-cast to u64 LE); +// 8-byte LE adsr.releaseFrames (int64). +// A v3 payload (no v4 A/D/S/R tail) lifts those fields to the tier-0 nominal defaults at +// 44100 Hz (kTier0Nominal* constants) so a voice using the zone ADSR sounds bit-identical to +// the pre-v4 build. Voice::start now uses the zone's full ADSR for all five AHDSR fields. // BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is // lifted to a single full-keyboard zone playing that id (no override) — so an instance saved // under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes @@ -302,7 +332,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; // (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults. // The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in // practice, always tiny) can never collide with. -inline constexpr std::uint32_t kZonesPayloadVersion = 3; // S15/S16: per-zone play params tail +inline constexpr std::uint32_t kZonesPayloadVersion = 4; // S15/S16 A/D/S/R per-zone tail inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; // The performance map serialized to bytes for IBStream (getState). diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 4aa2f16..6cd2336 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -276,12 +276,21 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote readPos_ = static_cast(start); startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset) - // --- Amplitude envelope: Gate = AHDSR (instrument A/D/S/R + per-zone HOLD); Trigger = the - // time-boxed fade-in/out over the % play length. --- + // --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's + // play.adsr); Trigger = the time-boxed fade-in/out over the % play length. + // + // gateAdsr (instrument-wide tier0) is the fallback for zones deserialized from a pre-v4 + // payload blob (see sample_map zones-payload v4): those zones carry their adsr fields + // pre-populated at deserialize time with the tier0 nominal defaults so back-compat holds + // (see sample_map.cpp readZonesPayload). For a new or fully-round-tripped zone the field + // is purely from the zone's own state; gateAdsr is still passed here for the Tier-0 + // single-capture path (buildTier0Keymap supplies a ZonePlayParams that already seeds the + // ADSR defaults from the product defaults; the voice reads those directly). + // + // Back-compat invariant: a zone whose adsr fields carry the product defaults (the tier0 + // nominal at 44100) sounds bit-identical to the pre-fix build. --- if (playMode_ == PlayMode::Gate) { - AdsrParams a = gateAdsr; - a.holdFrames = p.adsr.holdFrames; // per-zone hold folds into the instrument-wide AHDSR - env_.configure(a); + env_.configure(p.adsr); env_.noteOn(); playEnd_ = 0; // unused in Gate } else { diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 3607d11..5fcd4a3 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1138,6 +1138,64 @@ static void testPlayParamsThroughComponentEnvelope() { CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed); } +// --- S12 review fix (PAYLOAD v4): full A/D/S/R per-zone round-trip. --------------------- +// +// Before the fix, per-zone A/D/S/R (attack/decay/sustain/release) was not serialized; +// only holdFrames was written. These two tests assert the corrected v4 path. + +// All five AHDSR fields (including the four new A/D/S/R) must round-trip through the v4 payload. +static void testFullAdsrV4RoundTrip() { + PerformanceMap m; + PerformanceZone z = zone("pad", 0, 127); + z.play.playMode = PlayMode::Gate; + z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (a non-default value) + z.play.adsr.holdFrames = 882; + z.play.adsr.decayFrames = 4410; // 0.1 s + z.play.adsr.sustainLevel = 0.7; + z.play.adsr.releaseFrames = 8820; // 0.2 s + z.play.pitchEngine = PitchEngine::Preserve; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const AdsrParams& a = back.zones[0].play.adsr; + CHECK(a.attackFrames == 441); + CHECK(a.holdFrames == 882); + CHECK(a.decayFrames == 4410); + CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast + CHECK(a.releaseFrames == 8820); + CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); +} + +// A genuine PAYLOAD v3 blob (S15/S16 build — has holdFrames but not A/D/S/R) must lift +// attackFrames / decayFrames / sustainLevel / releaseFrames to the tier-0 nominal defaults +// (kTier0Nominal* constants) so the voice sounds bit-identical to the pre-fix behavior. +// We reuse handBuildV3PayloadOneZone which emits a valid marker-versioned v3 payload. +static void testV3BlobLiftsAdsrToNominalDefaults() { + // Build a PERFORMANCE blob: 4-byte kPerformanceStateVersion header + v3 zones payload. + // deserializePerformance strips the 4-byte header and passes the rest to readZonesPayload, + // which self-selects the v3 record shape from the payload marker+version — exercising the + // real production lift path for a user who saved on the S15 build. + std::vector blob; + auto u32 = [&](std::uint32_t v) { + blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); + blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF); + }; + u32(kPerformanceStateVersion); // envelope version 2 header + const std::vector payload = handBuildV3PayloadOneZone("old"); + blob.insert(blob.end(), payload.begin(), payload.end()); + const PerformanceMap back = deserializePerformance(blob); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const AdsrParams& a = back.zones[0].play.adsr; + // holdFrames comes from the v3 record itself; A/D/S/R must lift to the nominal tier-0 values. + CHECK(a.holdFrames == 2048); // from the hand-built v3 record + CHECK(a.attackFrames == kTier0NominalAttackFrames); // 132 (0.003 s at 44100) + CHECK(a.decayFrames == kTier0NominalDecayFrames); // 0 + CHECK(a.sustainLevel == kTier0NominalSustainLevel); // 1.0 + CHECK(a.releaseFrames == kTier0NominalReleaseFrames); // 2646 (0.060 s at 44100) +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -1189,6 +1247,8 @@ int main() { testPlayParamsComposeWithLoopStart(); testPlayParamsV2BackCompatLiftsToDefaults(); testPlayParamsThroughComponentEnvelope(); + testFullAdsrV4RoundTrip(); + testV3BlobLiftsAdsrToNominalDefaults(); testComponentStateRoundTrip(); testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index b7282a4..7622d22 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -383,10 +383,14 @@ static void testOutOfZoneNoteConsumesNoVoice() { // --------------------------------------------------------------------------- static void testStealsReleasingVoiceFirst() { - Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); - AdsrParams a = flatAdsr(); - a.releaseFrames = 100000; // long release so a released voice stays "active". - VoiceEngine eng(2, km, a); + // Long per-zone release so the voice stays active through the release tail. + // Per the S12 fix, Voice::start uses sample.play.adsr — not the engine's gateAdsr — + // so the long release must live on the SampleData, not on the VoiceEngine constructor arg. + SampleData s = dcSample(100000, 60); + s.play.adsr = flatAdsr(); + s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active" + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(2, km, flatAdsr()); std::size_t vA = eng.noteOn(60, 100); // startOrder 1 std::size_t vB = eng.noteOn(62, 100); // startOrder 2 @@ -406,10 +410,12 @@ static void testStealsReleasingVoiceFirst() { } static void testStealsOldestWhenNoneReleasing() { - Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); - AdsrParams a = flatAdsr(); - a.releaseFrames = 100000; - VoiceEngine eng(2, km, a); + // Long per-zone release — placed on SampleData.play.adsr per the S12 fix. + SampleData s = dcSample(100000, 60); + s.play.adsr = flatAdsr(); + s.play.adsr.releaseFrames = 100000; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(2, km, flatAdsr()); std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest) std::size_t vB = eng.noteOn(62, 100); // startOrder 2 @@ -1177,6 +1183,57 @@ static void testPreserveVoiceCap() { CHECK(eng.activeVoiceCount() == 2); } +// --- S12 review fix: per-zone A/D/S/R actually reaches the voice envelope. --- +// +// Before the fix, Voice::start used the instrument-wide gateAdsr for A/D/S/R and only +// folded the per-zone holdFrames. These two tests assert the corrected path. + +// The zone's attackFrames drives the envelope ramp — NOT the VoiceEngine's gateAdsr. +// Strategy: give the VoiceEngine a FLAT gateAdsr (instant attack) but put an explicit +// 10-frame attack on the SampleData.play.adsr. If Voice::start reads the zone ADSR, the +// DC-1 output will be 0 at frame 0 and 1.0 after the 10-frame ramp. If it instead used +// gateAdsr (flat = instant), frame 0 would already be 1.0. This is the load-bearing proof. +static void testPerZoneAdsrReachesVoiceEnvelope() { + SampleData s = dcSample(500, 60); + // Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release. + s.play.adsr.attackFrames = 10; + s.play.adsr.holdFrames = 0; + s.play.adsr.decayFrames = 0; + s.play.adsr.sustainLevel = 1.0; + s.play.adsr.releaseFrames = 0; + s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); // instrument-wide gateAdsr = flat (instant attack) + eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0 + std::vector out; + eng.render(out, 20); + // Frame 0: attack start, envelope near 0. If gateAdsr (flat) were used, this would be 1.0. + CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp + // Frame 9: still ramping (last attack frame, linear ramp reaches 0.9). + CHECK(out[9] < 1.0 - 1e-9); + // Frame 10+: attack complete, sustain at 1.0. + CHECK(approx(out[10], 1.0, 1e-9)); + CHECK(approx(out[19], 1.0, 1e-9)); +} + +// Default-valued zone (AdsrParams all zeros) is behavior-identical to the pre-fix flat path. +// A zero-init AdsrParams (attackFrames=0, decayFrames=0, sustainLevel=1.0, releaseFrames=0) must +// yield an instant-attack/instant-sustain voice — frame 0 immediately at 1.0. This preserves the +// back-compat invariant: an old zone with no A/D/S/R storage sounds the same as before. +static void testZeroAdsrIsInstantSustain() { + SampleData s = dcSample(20, 60); + // Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp. + s.play.adsr = AdsrParams{}; + s.play.pitchEngine = PitchEngine::Varispeed; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 5); + // All frames must be 1.0: zero attack + sustain 1.0 = instantly at full level. + for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9)); +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -1228,6 +1285,10 @@ int main() { testPreserveGateStereoLoopComposes(); testPreserveVoiceCap(); + // S12 review fix — per-zone A/D/S/R reaches the voice envelope. + testPerZoneAdsrReachesVoiceEnvelope(); + testZeroAdsrIsInstantSustain(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0; From 1d338318e7bbaf52ae65f094219eb44a8d21b3d6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 02:01:43 -0400 Subject: [PATCH 42/49] =?UTF-8?q?fix:=20S12=20R2=20=E2=80=94=20marker-drag?= =?UTF-8?q?=20sets=20selectedZone=5F,=20ADSR=20rate-resolve,=20gateAdsr=20?= =?UTF-8?q?doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker drag now sets selectedZone_ so single-capture controls stay reachable. Lifted/default ADSR rescales nominal 44100-Hz frame counts by sampleRate/44100 via adsrNeedsRateResolve; v4-authored zones skip rescale. gateAdsr documented vestigial. --- src/vst/reasampler_editor.cpp | 55 +++++++++++++++-------- src/vst/reasampler_editor.h | 5 ++- src/vst/sample_map.cpp | 35 +++++++++++++++ src/vst/sample_map.h | 27 +++++++++--- src/vst/sampler_core.cpp | 17 ++++---- src/vst/sampler_core.h | 12 ++--- tests/test_sample_map.cpp | 82 +++++++++++++++++++++++++++++++++++ 7 files changed, 192 insertions(+), 41 deletions(-) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 9d2f7e6..0d4915c 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -269,32 +269,31 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram return m; } -void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { +int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { // Find-or-append the zone for selectedId_ and write the loop/start override fields. // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must // be non-empty; callers are responsible for that guard. + // Returns the zone index (0-based) so callers can update selectedZone_. SampleLoop loop; loop.hasLoop = m.hasLoop; loop.start = m.loopStart; loop.end = m.loopEnd; - bool found = false; - for (PerformanceZone& z : map_.zones) { + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; if (z.sampleId == selectedId_) { z.loopOverride = loop; z.startPoint = m.start; - found = true; - break; + return i; } } - if (!found) { - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - z.loopOverride = loop; - z.startPoint = m.start; - map_.zones.push_back(z); - } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.loopOverride = loop; + z.startPoint = m.start; + map_.zones.push_back(z); + return static_cast(map_.zones.size()) - 1; } namespace { @@ -1322,6 +1321,14 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { dragParamPanel_ = panel; dragStartMap_ = map_; applyControl(id, z.play, valueAtPoint(r.control, x), 0); + // An explicit ADSR slider touch commits a rate-resolved value (the slider + // maps 0..1 -> editor-domain frames at kEnvTimeMaxFrames, not nominal 44100-Hz + // counts). Mark the zone as no longer needing rate-resolve so buildZonedKeymap + // does not re-rescale the value at reload time. + if (id >= static_cast(ParamControl::kAttack) && + id <= static_cast(ParamControl::kRelease)) { + z.adsrNeedsRateResolve = false; + } invalidate(); // live feedback; commit on WM_LBUTTONUP } break; @@ -1352,9 +1359,18 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { // zone over the whole keyboard) and round-trips through the v3 component state; the // zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a // repeated drag edits the same zone rather than stacking duplicates. + // Upsert the root override on the picked id; track the zone index so the control panel + // stays visible after the zone is materialized on the single-capture face (fix: without + // setting selectedZone_ here, selectedZone_==-1 with a non-empty map hides controls). bool found = false; - for (PerformanceZone& z : map_.zones) { - if (z.sampleId == selectedId_) { z.rootOverride = note; found = true; break; } + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == selectedId_) { + z.rootOverride = note; + selectedZone_ = i; + found = true; + break; + } } if (!found) { PerformanceZone z; @@ -1363,6 +1379,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { z.highNote = 127; z.rootOverride = note; map_.zones.push_back(z); + selectedZone_ = static_cast(map_.zones.size()) - 1; } invalidate(); // live feedback; the commit lands on WM_LBUTTONUP return; @@ -1407,8 +1424,10 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { if (m.start > frames - 1) m.start = frames - 1; // Upsert the override on the picked id (mirror of the root-marker path); commit lands on - // release, this is live feedback. - upsertPickedOverride(m); + // release, this is live feedback. Set selectedZone_ so the control panel stays visible + // after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty + // map hides controls after the first marker drag on the single-capture face). + selectedZone_ = upsertPickedOverride(m); invalidate(); return; } diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index d8af4af..acf8bd5 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -172,8 +172,9 @@ private: // Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append). // Does NOT call commitAndReload — callers decide whether this is a live-drag update or a - // final commit. selectedId_ must be non-empty before calling. - void upsertPickedOverride(const SetupMarkers& m); + // final commit. selectedId_ must be non-empty before calling. Returns the zone index + // (0-based) that was updated or appended, so callers can set selectedZone_. + int upsertPickedOverride(const SetupMarkers& m); // --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------ // diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index bfa7275..d0d44e8 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -147,6 +147,23 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, data.rootNote = rootNote; data.loop = loop; data.play = play; // S15/S16 single-capture play params (product defaults unless overridden) + + // The default `play` arg carries 44100-Hz nominal ADSR frame counts (kTier0Nominal*). + // Rescale A/D/R by (sampleRate / 44100) so the wall-clock ADSR matches tier0Adsr(sampleRate) + // exactly. Sustain (a level, not a frame count) is unchanged. At 44100 the factor is 1.0 — + // bit-identical to the pre-fix build. The tier-0 single-capture path never carries user-edited + // ADSR (users edit ADSR through zones, which go through buildZonedKeymap), so always rescaling + // here is correct and safe. + if (data.sampleRate != 44100) { + const double factor = static_cast(data.sampleRate) / 44100.0; + data.play.adsr.attackFrames = static_cast( + static_cast(data.play.adsr.attackFrames) * factor + 0.5); + data.play.adsr.decayFrames = static_cast( + static_cast(data.play.adsr.decayFrames) * factor + 0.5); + data.play.adsr.releaseFrames = static_cast( + static_cast(data.play.adsr.releaseFrames) * factor + 0.5); + } + return Keymap::singleSampleChromatic(std::move(data)); } @@ -190,6 +207,8 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // S15/S16 per-zone play params carry through unchanged (they are instrument state, not // resolved against the bank) so the keymap build can stamp them onto the SampleData. rz.play = z.play; + // Carry the rate-resolve flag so buildZonedKeymap can rescale 44100-nominal ADSR counts. + rz.adsrNeedsRateResolve = z.adsrNeedsRateResolve; out.zones.push_back(std::move(rz)); } return out; @@ -215,6 +234,20 @@ Keymap buildZonedKeymap(const std::vector& zones, data.loop = zones[i].loop; data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes + + // If the zone's ADSR came from a v3 lift or a new-zone default (adsrNeedsRateResolve), + // its A/D/R frame counts are 44100-Hz nominals. Rescale to the WAV's actual rate so + // wall-clock ADSR durations match tier0Adsr(sampleRate) exactly. v4 zones (user-edited + // frame counts) carry adsrNeedsRateResolve=false and are left unchanged. + if (zones[i].adsrNeedsRateResolve && data.sampleRate != 44100) { + const double factor = static_cast(data.sampleRate) / 44100.0; + data.play.adsr.attackFrames = static_cast( + static_cast(data.play.adsr.attackFrames) * factor + 0.5); + data.play.adsr.decayFrames = static_cast( + static_cast(data.play.adsr.decayFrames) * factor + 0.5); + data.play.adsr.releaseFrames = static_cast( + static_cast(data.play.adsr.releaseFrames) * factor + 0.5); + } const std::size_t sampleIndex = km.samples.size(); km.samples.push_back(std::move(data)); KeyZone zone; @@ -420,10 +453,12 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { } if (hasAdsr) { // S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order. + // These values were authored at the DAW's rate; mark them resolved (no rescaling). z.play.adsr.attackFrames = r.i64(); z.play.adsr.decayFrames = r.i64(); z.play.adsr.sustainLevel = bitsToDouble(r.u64()); z.play.adsr.releaseFrames = r.i64(); + z.adsrNeedsRateResolve = false; // already rate-resolved; do NOT rescale at keymap build } else if (hasPlay) { // v3 blob: A/D/S/R fields are absent. Lift to the tier-0 nominal defaults (44100 Hz) // so a voice playing this zone sounds bit-identical to the pre-v4 build (back-compat). diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 866bfef..76edc4b 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -108,11 +108,13 @@ std::vector extractChannel(const std::vector& interlea int channelCount, int which); // Nominal tier-0 ADSR defaults (frames at 44100 Hz) — used when lifting a pre-v4 zones payload -// blob whose per-zone A/D/S/R fields are absent. These reproduce the instrument-wide tier0Adsr -// behavior (0.003 s attack, 0.0 s decay, sustain 1.0, 0.060 s release at 44100 Hz) so that a -// zone loaded from an old blob sounds bit-identical to the pre-v4 build. DAWs at other sample -// rates will be close but not exact (the same approximation the instrument already makes when -// building tier0Adsr from its compile-time kAttackSeconds / kReleaseSeconds constants). +// blob whose per-zone A/D/S/R fields are absent, and as the initializer for new zones / the +// buildTier0Keymap default play arg. These are 44100-Hz nominal frame counts; buildTier0Keymap +// and buildZonedKeymap both rescale the A/D/R frame counts by (sampleRate / 44100) at build +// time when adsrNeedsRateResolve is set on the zone — so a v3-lifted or default zone plays with +// the same wall-clock ADSR as tier0Adsr(liveRate) did before the fix. Bit-identical at 44100 Hz. +// Zones loaded from a v4 blob (explicitly user-edited) carry adsrNeedsRateResolve=false and are +// never rescaled — their stored frame counts already reflect the rate at which they were authored. // Must be declared before buildTier0Keymap (default arg) and PerformanceZone / ResolvedZone // (member initializers) — both of which reference these values. inline constexpr std::int64_t kTier0NominalAttackFrames = 132; // 0.003 * 44100, rounded @@ -129,8 +131,9 @@ inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 441 // pair never half-plays. `sampleRate` is the WAV's rate. // `play` carries the S15/S16 per-zone play params for the single-capture path; it defaults to // the PRODUCT defaults (Gate + Preserve engine, S16-F1) so a picked single capture plays under -// the same default engine as a zone would. The editor will surface per-capture overrides later -// (S15-F2 one-zone-map lean); until then this is the one place the single-capture default lives. +// the same default engine as a zone would. The A/D/R frame counts in the default play arg are +// 44100-Hz nominals; this function always rescales them by (sampleRate / 44100) before stamping +// them on the SampleData so the ADSR wall-clock durations match tier0Adsr(sampleRate) exactly. Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector framesR = {}, @@ -184,6 +187,13 @@ struct PerformanceZone { kTier0NominalDecayFrames, kTier0NominalSustainLevel, kTier0NominalReleaseFrames}, TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; + + // When true, the A/D/R frame counts in play.adsr are 44100-Hz nominals (either lifted from a + // v3 blob or defaulted for a new zone) that must be rescaled by (sampleRate / 44100) at keymap + // build time (buildZonedKeymap). Set to false when a v4 blob explicitly provides A/D/S/R (the + // stored counts already reflect the DAW rate at the time the user edited them) or when the user + // edits an ADSR slider (the committed value is already editor-domain). Never serialized. + bool adsrNeedsRateResolve = true; }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -213,6 +223,9 @@ struct ResolvedZone { kTier0NominalReleaseFrames}, TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is) + // Carried from PerformanceZone::adsrNeedsRateResolve — buildZonedKeymap rescales A/D/R + // frames by (sampleRate / 44100) when true. False for v4-explicit or user-edited values. + bool adsrNeedsRateResolve = true; }; // The result of resolving a performance map against the live bank blob. `zones` are the diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 6cd2336..82734fe 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -279,16 +279,15 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote // --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's // play.adsr); Trigger = the time-boxed fade-in/out over the % play length. // - // gateAdsr (instrument-wide tier0) is the fallback for zones deserialized from a pre-v4 - // payload blob (see sample_map zones-payload v4): those zones carry their adsr fields - // pre-populated at deserialize time with the tier0 nominal defaults so back-compat holds - // (see sample_map.cpp readZonesPayload). For a new or fully-round-tripped zone the field - // is purely from the zone's own state; gateAdsr is still passed here for the Tier-0 - // single-capture path (buildTier0Keymap supplies a ZonePlayParams that already seeds the - // ADSR defaults from the product defaults; the voice reads those directly). + // All five AHDSR fields come from sample.play.adsr, stamped by buildTier0Keymap / + // buildZonedKeymap at reload time (with rate-rescaling for v3-lifted / default zones). + // gateAdsr (the VoiceEngine's instrument-wide ADSR) is accepted for interface compat + // but is NOT read here — it is vestigial since the S12 review fix moved A/D/S/R fully + // onto the per-zone SampleData. // - // Back-compat invariant: a zone whose adsr fields carry the product defaults (the tier0 - // nominal at 44100) sounds bit-identical to the pre-fix build. --- + // Back-compat invariant: a zone whose adsr fields carry the tier-0 nominal values + // (rescaled to the live sample rate by buildZonedKeymap) sounds bit-identical to the + // pre-fix build at every DAW rate. --- if (playMode_ == PlayMode::Gate) { env_.configure(p.adsr); env_.noteOn(); diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index b4a09d7..e552190 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -340,11 +340,13 @@ class Voice { public: // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference // the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched - // from `rootNote`. `gateAdsr` is the effective Gate AHDSR (the engine supplies the - // instrument-wide attack/decay/sustain/release timing; the per-zone HOLD stage comes from - // sample.play.adsr.holdFrames, folded in here). The S15 play MODE + Trigger params and the - // S16 pitch ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST - // already be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s + // from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from + // sample.play.adsr — the per-zone values stamped by buildTier0Keymap / buildZonedKeymap. + // `gateAdsr` is the VoiceEngine's instrument-wide ADSR parameter, accepted for interface + // compatibility but NOT used by start() (vestigial since the S12 review fix moved A/D/S/R + // onto the per-zone SampleData). The S15 play MODE + Trigger params and the S16 pitch + // ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST already + // be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s // them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm // silence pass settles the OLA taps before the first output frame (no cold-start click). // Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 5fcd4a3..339972d 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1194,6 +1194,84 @@ static void testV3BlobLiftsAdsrToNominalDefaults() { CHECK(a.decayFrames == kTier0NominalDecayFrames); // 0 CHECK(a.sustainLevel == kTier0NominalSustainLevel); // 1.0 CHECK(a.releaseFrames == kTier0NominalReleaseFrames); // 2646 (0.060 s at 44100) + // The lifted zone must be flagged for rate-resolve so buildZonedKeymap rescales at the live rate. + CHECK(back.zones[0].adsrNeedsRateResolve == true); +} + +// A v4 blob (explicit A/D/S/R tail) must NOT set adsrNeedsRateResolve — those values +// were authored at the DAW's rate and must not be rescaled again at keymap build time. +static void testV4BlobClearsAdsrNeedsRateResolve() { + PerformanceMap m; + PerformanceZone z = zone("pad", 0, 127); + z.play.adsr.attackFrames = 441; + z.play.adsr.releaseFrames = 8820; + m.zones.push_back(z); + // A round-trip through serialize/deserialize writes a v4 payload (current version). + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + // v4 tail was explicitly read — adsrNeedsRateResolve must be false. + CHECK(back.zones[0].adsrNeedsRateResolve == false); +} + +// buildTier0Keymap at 48k must produce ADSR frame counts equal to tier0Adsr(48000): +// attack = round(kTier0NominalAttackFrames * 48000 / 44100) = round(143.67) = 144, +// release = round(kTier0NominalReleaseFrames * 48000 / 44100) = round(2880.0) = 2880. +// This is the "pre-fix, the Gate voice used tier0Adsr(sampleRate_)" invariant restored +// for the single-capture fast path at any DAW rate. +static void testBuildTier0KeymapRescalesAdsrAt48k() { + const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) return; + const AdsrParams& a = km.samples[0].play.adsr; + // Rescaled from 44100-nominal at 48000 Hz: + CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0) + CHECK(a.decayFrames == 0); // 0 * factor = 0 (no change) + CHECK(a.sustainLevel == 1.0); // level, not frames (no rescale) + CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0) +} + +// buildZonedKeymap at 48k with adsrNeedsRateResolve=true must rescale ADSR to match +// tier0Adsr(48000), mirroring what Gate voices saw before the per-zone-ADSR fix. +static void testBuildZonedKeymapRescalesNominalAdsrAt48k() { + // Build a zone with nominal 44100-Hz ADSR and the needs-resolve flag (the default). + ResolvedZone z; + z.lowNote = 0; z.highNote = 127; z.rootNote = 60; + z.adsrNeedsRateResolve = true; // 44100-nominal values, rescale needed + z.play.adsr.attackFrames = kTier0NominalAttackFrames; // 132 + z.play.adsr.decayFrames = kTier0NominalDecayFrames; // 0 + z.play.adsr.sustainLevel = kTier0NominalSustainLevel; // 1.0 + z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; // 2646 + const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV + const Keymap km = buildZonedKeymap({z}, {pcm}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) return; + const AdsrParams& a = km.samples[0].play.adsr; + CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0) + CHECK(a.decayFrames == 0); // 0 * factor = 0 + CHECK(a.sustainLevel == 1.0); // level, not rescaled + CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0) +} + +// buildZonedKeymap must NOT rescale a zone whose adsrNeedsRateResolve is false — +// those frame counts are explicitly user-authored at the DAW's rate. +static void testBuildZonedKeymapDoesNotRescaleV4Adsr() { + ResolvedZone z; + z.lowNote = 0; z.highNote = 127; z.rootNote = 60; + z.adsrNeedsRateResolve = false; // v4 blob or user-edited — do not rescale + z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (non-nominal) + z.play.adsr.decayFrames = 4410; + z.play.adsr.sustainLevel = 0.7; + z.play.adsr.releaseFrames = 8820; + const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV — rescale would change values + const Keymap km = buildZonedKeymap({z}, {pcm}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) return; + const AdsrParams& a = km.samples[0].play.adsr; + CHECK(a.attackFrames == 441); // unchanged (not rescaled) + CHECK(a.decayFrames == 4410); + CHECK(a.sustainLevel == 0.7); + CHECK(a.releaseFrames == 8820); } int main() { @@ -1249,6 +1327,10 @@ int main() { testPlayParamsThroughComponentEnvelope(); testFullAdsrV4RoundTrip(); testV3BlobLiftsAdsrToNominalDefaults(); + testV4BlobClearsAdsrNeedsRateResolve(); + testBuildTier0KeymapRescalesAdsrAt48k(); + testBuildZonedKeymapRescalesNominalAdsrAt48k(); + testBuildZonedKeymapDoesNotRescaleV4Adsr(); testComponentStateRoundTrip(); testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); From ddc2ec1e75f9f78a7c57aba97b251cafa9395371 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 00:30:30 -0400 Subject: [PATCH 43/49] docs(plan): tick S9/S15/S16/S18; annotate Preserve as hand-rolled OLA (WDL held as swap) --- PLAN.md | 76 +++++++++++++++++++++++++++++++-------------------------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/PLAN.md b/PLAN.md index 01882ab..b2b2c99 100644 --- a/PLAN.md +++ b/PLAN.md @@ -381,16 +381,16 @@ cleanly (treated as generation 0; first bump refreshes). side is extension-only and independent of S8; consumed by S8 and M10 recapture. Best landed alongside S8. -- [ ] Writer (extension): a monotonic **bank-generation counter** stamped into +- [x] Writer (extension): a monotonic **bank-generation counter** stamped into `"reasampler"` ext-state (new `ext_keys.h` constant — forever-stable spelling), bumped on every bank-content mutation that changes what an instance would play (capture add, recapture-in-place, sample-remove, move/copy affecting the active bank). Additive to the persist blob; defaults to 0 for projects saved before the stamp exists. -- [ ] Reader (instrument): poll the generation over the bridge on a safe **off-audio-thread +- [x] Reader (instrument): poll the generation over the bridge on a safe **off-audio-thread cadence** (a UI/timer tick, not `process`), compare to the last-seen value, and call the existing `reloadFromBank()` on change — reusing S4's atomic pointer-swap handoff so a refresh mid-play does not glitch. No new audio-thread work; no allocation in `process`. -- [ ] Cadence + coalescing: pick a poll interval that is responsive but cheap (build-time +- [x] Cadence + coalescing: pick a poll interval that is responsive but cheap (build-time residual — a low-frequency UI timer, coalescing multiple bumps between polls into one reload). **Must-verify before build:** that a bridge ext-state read on the instrument's UI/timer thread is safe against a concurrent extension write (the read already tolerates a @@ -576,7 +576,10 @@ into the voice engine; today they are fixed defaults), S5 (the map the numeric f > search** (a name-substring narrow over the same cards, composing with S10's bank filter — > bank filter picks the bank, search narrows within it). The scroll/search geometry is pure, > layered over the `capture_browser` module S10 builds. Net: S12 = scroll + search over the -> S10 browser + numeric entry + ADSR; the browser *card* work is S10's. +> S10 browser + numeric entry + ADSR; the browser *card* work is S10's. **S12 now also +> carries the S15/S16 control surfaces** (per-zone Gate/Trigger mode toggle, AHDSR hold +> control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch +> envelope depth/shape controls) — deferred here from S15 and S16 per spec. - [ ] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a bank longer than the panel is fully reachable; a **type-to-filter search** that narrows @@ -657,7 +660,7 @@ independent of the S7 channel dimension (§sequencing). shows start + %-length + fades on the **same** waveform surface). Independent of S7 — orthogonal dimensions (§sequencing note in CONTEXT.md). -- [ ] Core: `PlayMode { Gate, Trigger }` on the voice + the envelope split. **Gate** grows +- [x] Core: `PlayMode { Gate, Trigger }` on the voice + the envelope split. **Gate** grows `AdsrParams` → `AhdsrParams` (add `holdFrames` between attack and decay; hold=0 is the exact current ADSR — back-compat). **Trigger** is a distinct envelope: play `[start, start + lengthFraction·(frames−start))` with a **fade-in** ramp (0→1 over `fadeInFrames`) @@ -665,15 +668,15 @@ orthogonal dimensions (§sequencing note in CONTEXT.md). **ignoring note-off** (release is a no-op in Trigger). Fade curve default **equal-power** (constant-power `sin`/`cos`, click-free on one-shots) with the shape noted; linear is a build-time residual. Pure, unit-tested against a known signal. -- [ ] Core: **modifiable start point** — the voice's initial `readPos_` is `startFrame` +- [x] Core: **modifiable start point** — the voice's initial `readPos_` is `startFrame` (frame offset), applied in both modes; the existing per-frame `readPos_ += ratio_` read and loop/interp machinery is otherwise unchanged. Clamp `0 ≤ startFrame < frames`. -- [ ] Core: **% length → frames + fade mapping** for Trigger. `lengthFraction ∈ (0,1]` +- [x] Core: **% length → frames + fade mapping** for Trigger. `lengthFraction ∈ (0,1]` resolves to `playEnd = start + round(lengthFraction·(frames − start))`; `fadeInFrames` / `fadeOutFrames` clamp so their sum ≤ play length (fade-out anchored to `playEnd`). Note-off in Trigger does nothing; the voice frees when `readPos_ ≥ playEnd` (mirror of the current run-off-end idle). **Choke on note-off is NOT in scope** (fork S15-F1, held below). -- [ ] Parameter ownership (per-sample/per-zone, instrument-owned): the play mode + its +- [x] Parameter ownership (per-sample/per-zone, instrument-owned): the play mode + its params (Gate: AHDSR; Trigger: %-length, fade-in, fade-out; both: start point) attach to the **capture selection / zone**, stored in the **performance map** (D-B). **Lean (build-time residual):** start point joins `rootOverride`/loop-override as another @@ -685,13 +688,13 @@ orthogonal dimensions (§sequencing note in CONTEXT.md). per-zone only — the single capture is already a one-zone map (S10-Z back-compat lift), so one storage site serves both. Flagged because it touches S10's single-capture setup surface shape. -- [ ] Editor (S11 waveform surface, mode-aware): **Gate** shows draggable **start + loop +- [x] Editor (S11 waveform surface, mode-aware): **Gate** shows draggable **start + loop markers** (S11's loop UI); **Trigger** shows **start + %-length end + fade-in/out** handles on the same waveform. A **mode toggle** per capture/zone in the guided setup (S10) / Zones panel (S10-Z). Marker/handle geometry is pure (extends the S11 `frame↔pixel` + marker-grab module); commits off-thread via `commitMapAndReload`. The instrument stays a **read-only bank consumer** (mode/params are performance map, never - written to the bank). + written to the bank). **Editor control surface deferred to S12 tier (spec-sanctioned).** > **S15 × S16 pitch-engine interaction (informs the S16 Preserve engine).** S15's amplitude > semantics are defined over the voice's **source-frame** timeline, which the S16 pitch-engine @@ -759,7 +762,7 @@ per-zone param plumbing + component-state version bump the mode/envelope hang on Gate-loop / Trigger-%-length semantics the Preserve engine must honor — see S15 interaction below). Independent of S7 (both engines operate per-frame, channel-count agnostic). -- [ ] Core: **pitch-engine mode on the voice/zone** — `PitchEngine { Varispeed, Preserve }`. +- [x] Core: **pitch-engine mode on the voice/zone** — `PitchEngine { Varispeed, Preserve }`. **Varispeed** = today's path (`readPos_ += ratio_`, `ratio_ = pitchRatio(note,root)`), pitch and duration coupled. **Preserve** = duration-preserving: the read advances at the **source** rate (duration held) while a pitch shifter transposes the output by @@ -768,7 +771,7 @@ below). Independent of S7 (both engines operate per-frame, channel-count agnosti Absent/older blob → the fork's default. Pure where possible: the Varispeed math and the duration-invariance contract are unit-tested; the Preserve DSP core is unit-tested for duration invariance and transpose correctness against a known signal. -- [ ] Core: **Preserve engine implementation (fork S16-F2, flagged).** Two viable routes, both +- [x] Core: **Preserve engine implementation (fork S16-F2, flagged).** Two viable routes, both RT-disciplined (pre-allocated, no locks, no `process` allocation): - **(a) `WDL_SimplePitchShifter`** (`vendor/WDL/WDL/simple_pitchshift.h`) — a per-voice time-domain OLA shifter. **Now the right category** (duration-preserving is the @@ -787,7 +790,9 @@ below). Independent of S7 (both engines operate per-frame, channel-count agnosti `WDL_Queue::Add`, an RT hazard *only* until steady state; pre-warm with silence at voice- allocation so the buffers settle and stop growing; (iii) **quality** — basic OLA, audible warble on large transpositions and no formant preservation (`set_formant_shift` is an - empty stub), acceptable for the loop/phrase use case. + empty stub), acceptable for the loop/phrase use case. **Implemented as hand-rolled pure + OLA (`pitch_shift` module, house pattern — CTest-testable, no WDL/REAPER/VST3 type at the + boundary); WDL_SimplePitchShifter excluded by include-chain (windows.h), held as swap.** - **(b) hand-rolled OLA/granular pitch core** as a **pure module** (`pitch_shift`, mirroring the house pattern — CTest-testable, no REAPER/VST3/WDL type at the boundary). More work, but full control over latency/window/crossfade, RT-shape owned by us, and it sits natively @@ -799,7 +804,7 @@ below). Independent of S7 (both engines operate per-frame, channel-count agnosti Preserve engine. **elastique is NOT available** (licensed zplane, not in the vendored tree — restated, not worked around); JUCE / rubberband / signalsmith are **new-dependency forks carrying full D-A weight** (bare-VST3-no-framework is locked D-A) — not proposed. -- [ ] Core: a per-voice **AD pitch envelope**, engine-aware — `PitchEnvParams { enabled=false, +- [x] Core: a per-voice **AD pitch envelope**, engine-aware — `PitchEnvParams { enabled=false, int64 attackFrames, int64 decayFrames, double peakSemitones }`. **Shape (lean, build-time residual):** two-segment AD — at note-on the pitch offset rises to `peakSemitones` over `attackFrames`, then falls to 0 over `decayFrames` (**zero attack** = the pure "start high, @@ -810,14 +815,14 @@ below). Independent of S7 (both engines operate per-frame, channel-count agnosti pitchEnvSemitones(frame))/12))` — so the pitch bends without touching duration. Pure, unit- tested (t=0 offset, t=attack peak, t=attack+decay → 0; and the semitone→shift/ratio mapping for both engines). -- [ ] Parameter ownership + editor: pitch-engine mode + pitch envelope are per-zone +- [x] Parameter ownership + editor: pitch-engine mode + pitch envelope are per-zone instrument performance-map state (D-B), additive/version-bumped (absent → engine default per S16-F1, envelope disabled). Editor exposure: a **per-zone Varispeed/Preserve toggle** in the S10 guided setup / S10-Z Zones panel (a two-state control next to the S15 mode toggle), plus a small AD + ±semitone depth control for the envelope (folds into the S12 ADSR-editor tier). Default-off envelope so the control is discoverable but inert until enabled. The instrument stays a **read-only bank consumer** (mode/envelope are performance map, never written to the - bank). + bank). **Editor control surface deferred to S12 tier (spec-sanctioned).** > **S16-F1 (FLAGGED — Daniel fork): the default pitch engine.** **Lean: Preserve default.** > Argued honestly both ways: @@ -1073,7 +1078,7 @@ namespace, so it inherits this isolation with no S18 change; no coordination edi only the shared awareness that all wire keys must live under `kProjExtNamespace()` (already the rule). Dispatchable in parallel with, or immediately after, the in-flight waves. -- [ ] **Beta VST3 class UID (the permanent commitment).** Mint a second FOREVER-STABLE +- [x] **Beta VST3 class UID (the permanent commitment).** Mint a second FOREVER-STABLE class UID in `reasampler_vst.h` alongside the existing `kReaSamplerProcessorUID` (`REASAMPLER_PROC_UID_1..4`) — a distinct forever-frozen constant (e.g. `REASAMPLER_PROC_UID_BETA_1..4` + `kReaSamplerProcessorUIDBeta`). The channel bit selects @@ -1087,7 +1092,7 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa VST ships). Lean: **mint now** — mirrors how the stable UID was minted at the S1 spike (locked long before ship), removes a "remember to mint before shipping" landmine, and the cost of an unused-until-beta constant is zero. Flagged because it is a forever commitment. -- [ ] **Channel-derived binary + display identity (no scattered `#ifdef`s — the V4 +- [x] **Channel-derived binary + display identity (no scattered `#ifdef`s — the V4 invariant).** Route all VST identity through `app_version` accessors, mirroring how the extension's `OUTPUT_NAME` already derives from `REASAMPLER_OUTPUT_NAME`. (a) **Binary name:** the CMake VST3 target's `OUTPUT_NAME` forks by channel — `reasampler_9000` @@ -1100,7 +1105,7 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa `vstPluginName()`), not a literal in `reasampler_vst.h`/`vst_entry.cpp`. (c) **Editor title band + S6 embed-strip label** are channel-aware from the same accessor. All fan out from the ONE channel bit — no per-file `#ifdef`. -- [ ] **Factory vendor/version strings channel-aware where V4 does the equivalent.** The +- [x] **Factory vendor/version strings channel-aware where V4 does the equivalent.** The `BEGIN_FACTORY` vendor/url/email and the `PClassInfo2` version string (`REASAMPLER_VST_VERSION`, currently a fixed `"0.1.0.0"`) align with the channel where the extension's V4 equivalents do — the version display carries the `-beta` render @@ -1108,7 +1113,7 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa unless V4 qualified them (**check V4's treatment and match it** — V4 kept the lane-name prefix shared, so shared-where-V4-shares is the default). No new forever-stable string beyond the UID and the id-family V4 already owns. -- [ ] **Pairing-surface invariant recorded (no new code — a documented guarantee).** Write +- [x] **Pairing-surface invariant recorded (no new code — a documented guarantee).** Write the complete pairing surface as an invariant in CONTEXT.md: a channel's VST talks to that channel's extension **only**, because (1) plugin identity (UID + filename + display) is channel-forked here, and (2) **all** wire keys — `banks`, `assign_request`, S9's @@ -1117,7 +1122,7 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa isolation is structural — no per-key opt-in — so a future wire key that forgets to isolate is impossible by construction** (it keys under the namespace accessor, not a raw literal). This is the guard that S9/S8/S17's cross-artifact keys inherit isolation for free. -- [ ] **DAW-verify contract (the acceptance gate, no unit test — identity is a shell fact).** +- [x] **DAW-verify contract (the acceptance gate, no unit test — identity is a shell fact).** Both channels installed side-by-side: each browser sees only its channel's banks; a stable-project + beta-VST opens clean-empty (not error); the S-NAME-1 rename/rebind compat test extended to the beta UID (save a beta instance, confirm it rebinds by the beta UID on @@ -1140,11 +1145,11 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa heavier. Held as a Tier-2/3 quality option — not needed for S15/S16, not scheduled. (A resampler couples duration, so it is a Varispeed-quality option only, **not** a Preserve engine.) -- **Hand-rolled Preserve pitch core (HELD — fork S16-F2 route b).** A pure `pitch_shift` - OLA/granular module (house pattern, CTest-testable) as the quality/latency upgrade for the - S16 Preserve engine if `WDL_SimplePitchShifter`'s SimpleWindowed warble or onset lag proves - musically unacceptable. Same `PitchEngine::Preserve` contract behind the seam. Held — the - build starts with route a (WDL) to prove Preserve at low cost. +- **WDL_SimplePitchShifter swap (HELD — fork S16-F2 route a).** `WDL_SimplePitchShifter` + as a drop-in swap for the `pitch_shift` pure module if the hand-rolled OLA onset latency + or warble proves musically unacceptable. Same `PitchEngine::Preserve` contract behind the + seam. WDL excluded from the shipped build by include-chain (windows.h); held as the + quality/latency alternative. - **Trigger choke-on-note-off (HELD — fork S15-F1).** A future option for Trigger mode to *cut* (choke) on note-off or on a same-group re-trigger (hi-hat open/closed). Deliberately out of S15 scope (Trigger ignores note-off entirely there); a Tier-3 choke-group direction. @@ -1163,10 +1168,14 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa exposed via `queryInterface` on the controller; inline strip drawn into REAPER's embed bitmap (LICE idiom, no HWND); `WM_GETMINMAXINFO` size hints confirmed; embed open/close/resize lifecycle clean. -- **VST3 bus arrangement (S7)** — `setBusArrangements` / `getBusArrangement` and REAPER's +- **VST3 bus arrangement (S7)** — ~~`setBusArrangements` / `getBusArrangement` and REAPER's mono/stereo instrument-bus expectations, against the vendored Steinberg SDK + `reaper_vst3_interfaces.h`. The channel-mode toggle depends on the output bus - re-negotiating cleanly. + re-negotiating cleanly.~~ **verified (S7):** `setBusArrangements` accepts only the mode's + arrangement (kMono/kStereo); `getBusArrangement` (base default) reports the valid mode; + a runtime mode change calls `restartComponent(kIoChanged)` so REAPER re-negotiates; + verified against the vendored Steinberg SDK (`ivstaudioprocessor.h`, + `vstsinglecomponenteffect.cpp` base impl, `ivsteditcontroller.h` kIoChanged). - **Media Explorer surface (S8)** — confirmed thin against the vendored headers: `OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the one last-played/selected file + range) are the whole contract; **no** enumerate-selected and @@ -1184,13 +1193,10 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa path) and `simple_pitchshift.h` (`WDL_SimplePitchShifter`, time-domain OLA, **duration- preserving** — the S16 Preserve-engine candidate, fork S16-F2 route a) are the **whole** pitch/resample surface; **no** elastique / formant-preserving in the vendored WDL tree. - **S16 Preserve-engine (route a) must-verify at build:** pre-warm `WDL_SimplePitchShifter` - at voice-allocation so `m_queue` reaches steady state (no `process`-thread `WDL_Queue::Add` - growth); measure the per-voice CPU + onset latency (window·srate) against the polyphony cap; - set the **Preserve-mode-specific voice cap** if the per-voice cost demands one. The pitch- - envelope modulation stays hand-rolled over whichever engine. If the held sinc Varispeed- - quality upgrade is ever taken, verify `WDL_Resampler` streaming/prealloc against per-voice - RT budget before use. + **S16 Preserve engine shipped as hand-rolled pure OLA (`pitch_shift` module); WDL excluded + by include-chain (windows.h), held as swap.** The pitch-envelope modulation is hand-rolled + over the engine. If the held sinc Varispeed-quality upgrade is ever taken, verify + `WDL_Resampler` streaming/prealloc against per-voice RT budget before use. - **Drop-and-load (S17) — three surfaces.** (1) `TrackFX_AddByName` — **verified present** in `reaper_plugin_functions.h` (signature confirmed; the `"VST3:"` name prefix and the negative-`instantiate`-always-adds semantics are documented in the header comment). (2) From a54af277e43e6711bad898b0ad08a86bcb9b6a40 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 02:51:46 -0400 Subject: [PATCH 44/49] S12: store wall-clock ADSR/pitch-env as seconds, resolve to frames at live rate Kill kTier0Nominal*, adsrNeedsRateResolve, tier0Adsr, gateAdsr. Zones payload v5 carries seconds; v3 legacy reads convert at the frozen authoring rate; v4 (branch-only) dropped. Editor sliders now seconds. Engine takes frames resolved at keymap build. --- src/vst/reasampler_editor.cpp | 64 +++--- src/vst/reasampler_editor.h | 16 +- src/vst/reasampler_processor.cpp | 21 +- src/vst/reasampler_processor.h | 4 +- src/vst/sample_map.cpp | 167 ++++++++------- src/vst/sample_map.h | 167 ++++++++------- src/vst/sampler_core.cpp | 23 +- src/vst/sampler_core.h | 47 ++--- tests/test_sample_map.cpp | 348 ++++++++++++++++++++----------- tests/test_sampler_core.cpp | 115 +++++----- 10 files changed, 539 insertions(+), 433 deletions(-) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 0d4915c..ffd20c2 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -298,19 +298,19 @@ int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { namespace { // The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is -// engine-free and maps only 0..1). Time sliders span [0, max] frames at the NOMINAL 44100 Hz -// rate; the stored frame count is host-rate-independent, so at other DAW rates the same slider -// position maps to a slightly different wall-clock duration. The ceiling is kept nominal-only -// because the host rate is not reachable inside the editor without a processor callback, and the -// approximation is musically negligible (±1-2 ms at typical rates). Build-time residual — one -// place to retune; not persisted. -constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (44100 nominal) +// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span +// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build +// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) +// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a +// wall-clock second). Build-time residual — one place to retune; not persisted. +constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) +constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames) constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } } // namespace -std::vector ReaSamplerEditor::controlDescs(const ZonePlayParams& play) const { +std::vector ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const { std::vector out; // Always: the two mode toggles. out.push_back({static_cast(ParamControl::kPlayMode), ControlKind::Toggle}); @@ -335,24 +335,27 @@ std::vector ReaSamplerEditor::controlDescs(const ZonePlayParams& pl return out; } -double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const { +double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { + // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over + // the frames ceiling. Two domains, kept explicit so neither leaks a rate. + const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; const auto framesToNorm = [](std::int64_t f) { - return clamp01(static_cast(f) / kEnvTimeMaxFrames); + return clamp01(static_cast(f) / kFadeMaxFrames); }; switch (static_cast(id)) { case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; - case ParamControl::kAttack: return framesToNorm(play.adsr.attackFrames); - case ParamControl::kHold: return framesToNorm(play.adsr.holdFrames); - case ParamControl::kDecay: return framesToNorm(play.adsr.decayFrames); + case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); + case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); + case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds); case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); - case ParamControl::kRelease: return framesToNorm(play.adsr.releaseFrames); + case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds); case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction); case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; - case ParamControl::kPitchEnvAttack:return framesToNorm(play.pitchEnv.attackFrames); - case ParamControl::kPitchEnvDecay: return framesToNorm(play.pitchEnv.decayFrames); + case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds); + case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds); case ParamControl::kPitchEnvDepth: // Signed depth centered at 0.5 (0.5 == 0 semitones). return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); @@ -360,10 +363,11 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const } } -void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value, +void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, int segment) const { + const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; const auto normToFrames = [](double v) { - return static_cast(clamp01(v) * kEnvTimeMaxFrames + 0.5); + return static_cast(clamp01(v) * kFadeMaxFrames + 0.5); }; switch (static_cast(id)) { case ParamControl::kPlayMode: @@ -372,11 +376,11 @@ void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value, case ParamControl::kPitchEngine: play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; break; - case ParamControl::kAttack: play.adsr.attackFrames = normToFrames(value); break; - case ParamControl::kHold: play.adsr.holdFrames = normToFrames(value); break; - case ParamControl::kDecay: play.adsr.decayFrames = normToFrames(value); break; + case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break; + case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break; + case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break; case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; - case ParamControl::kRelease: play.adsr.releaseFrames = normToFrames(value); break; + case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break; case ParamControl::kTrigLength: // lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing. play.trigger.lengthFraction = (std::max)(0.01, clamp01(value)); @@ -386,8 +390,8 @@ void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value, case ParamControl::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break; - case ParamControl::kPitchEnvAttack: play.pitchEnv.attackFrames = normToFrames(value); break; - case ParamControl::kPitchEnvDecay: play.pitchEnv.decayFrames = normToFrames(value); break; + case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break; + case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break; case ParamControl::kPitchEnvDepth: play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; break; @@ -999,7 +1003,7 @@ void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) { // PerformanceZone product defaults when the map is empty but a capture is picked // (S15-F2 lean: the single-capture face shares the same storage site as a one-zone map; // see paintZones for the gate that reaches here). - ZonePlayParams play; + ZonePlaySeconds play; if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { play = map_.zones[static_cast(selectedZone_)].play; } else if (map_.zones.empty() && !selectedId_.empty()) { @@ -1284,7 +1288,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // Synthesize a probe layout with the product defaults to see if the click is in the // panel before committing to creating the zone. const Rect panel = zonesControlPanel(bands); - const ZonePlayParams defaultPlay = PerformanceZone{}.play; + const ZonePlaySeconds defaultPlay = PerformanceZone{}.play; const std::vector probeDescs = controlDescs(defaultPlay); const std::vector probeRows = layoutControls(panel, probeDescs); if (controlAtPoint(probeRows, x, y) >= 0) { @@ -1321,14 +1325,6 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { dragParamPanel_ = panel; dragStartMap_ = map_; applyControl(id, z.play, valueAtPoint(r.control, x), 0); - // An explicit ADSR slider touch commits a rate-resolved value (the slider - // maps 0..1 -> editor-domain frames at kEnvTimeMaxFrames, not nominal 44100-Hz - // counts). Mark the zone as no longer needing rate-resolve so buildZonedKeymap - // does not re-rescale the value at reload time. - if (id >= static_cast(ParamControl::kAttack) && - id <= static_cast(ParamControl::kRelease)) { - z.adsrNeedsRateResolve = false; - } invalidate(); // live feedback; commit on WM_LBUTTONUP } break; diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index acf8bd5..32be032 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -178,22 +178,24 @@ private: // --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------ // - // The control panel edits the SELECTED zone's ZonePlayParams (S15 play mode + AHDSR; S16 - // pitch engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact. + // The control panel edits the SELECTED zone's ZonePlaySeconds (S15 play mode + AHDSR; S16 + // pitch engine + AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap + // build resolves them to frames at the live rate. Instrument-owned (D-B), never a bank fact. // The control descriptors the panel shows for `play`'s CURRENT play mode: the two toggles + // the mode-relevant sliders (AHDSR for Gate, %-length/fades for Trigger) + the pitch-envelope // controls. The pure param_slider lays these out; this only picks the set. Static (a free // choice of set from the mode) — kept a member for the ParamControl enum access. - std::vector controlDescs(const ZonePlayParams& play) const; + std::vector controlDescs(const ZonePlaySeconds& play) const; // The normalized [0,1] display value for control `id` given `play` (the shell's domain - // mapping: frames->0..1 over a fixed max, sustain 0..1 as-is, semitone depth centered at 0.5). - double controlValue(int id, const ZonePlayParams& play) const; + // mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade + // frames->0..1, semitone depth centered at 0.5). + double controlValue(int id, const ZonePlaySeconds& play) const; // Apply a committed control interaction to `play`: a slider's normalized `value` (mapped back - // into the control's engine domain) or a toggle's `segment` (0/1). Mutates `play` in place. - void applyControl(int id, ZonePlayParams& play, double value, int segment) const; + // into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place. + void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const; ReaSamplerProcessor* processor_ = nullptr; diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 92f2448..c9baef0 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -37,10 +37,6 @@ namespace { // 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; // S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is @@ -50,16 +46,6 @@ constexpr std::size_t kMaxVoices = 16; // cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling. constexpr std::size_t kPreserveVoiceCap = 8; -AdsrParams tier0Adsr(double sampleRate) { - const double sr = sampleRate > 0.0 ? sampleRate : 44100.0; - AdsrParams p; - p.attackFrames = static_cast(kAttackSeconds * sr); - p.decayFrames = static_cast(kDecaySeconds * sr); - p.sustainLevel = kSustainLevel; - p.releaseFrames = static_cast(kReleaseSeconds * sr); - return p; -} - // Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on // any failure — the caller treats an unreadable WAV as "nothing to play". std::vector readFileBytes(const std::string& path) { @@ -384,12 +370,13 @@ std::string ReaSamplerProcessor::reloadFromBank() { if (haveKeymap) { // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). // Every voice's shifter is pre-sized to this off-thread here, so process()-time - // note-on never allocates. Floored at 2 so a valid window is always a real ring. + // note-on never allocates. Floored at 2 so a valid window is always a real ring + // (which also covers a pathological host rate <= 0 — no rate literal needed). std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * (sampleRate_ > 0.0 ? sampleRate_ : 44100.0) / 1000.0 + 0.5); + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); if (preserveWindow < 2) preserveWindow = 2; built = std::make_unique( - std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen, kPreserveVoiceCap, + std::move(km), kMaxVoices, gen, kPreserveVoiceCap, preserveWindow); } } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 531aea6..b9c4299 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -50,11 +50,11 @@ struct LoadedInstrument { VoiceEngine engine; std::uint64_t installedAt = 0; // reload generation at which this was installed - LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr, + LoadedInstrument(Keymap km, std::size_t maxVoices, std::uint64_t gen, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0) : keymap(std::move(km)), - engine(maxVoices, keymap, adsr, preserveVoiceCap, preserveWindowFrames), + engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames), installedAt(gen) {} LoadedInstrument(const LoadedInstrument&) = delete; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index d0d44e8..c80fee5 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -133,9 +133,35 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, return out; } +ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) { + // seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R, + // pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry + // through untouched — they are already source frames / fractions. Non-time fields pass as-is. + const double sr = sampleRate > 0 ? static_cast(sampleRate) : 44100.0; + const auto secToFrames = [sr](double sec) { + double f = sec * sr; + if (f < 0.0) f = 0.0; + return static_cast(f + 0.5); + }; + ZonePlayParams out; + out.playMode = stored.playMode; + out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds); + out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds); + out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds); + out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time + out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds); + out.trigger = stored.trigger; // source-frame / fraction, unchanged + out.pitchEngine = stored.pitchEngine; + out.pitchEnv.enabled = stored.pitchEnv.enabled; + out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds); + out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds); + out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time + return out; +} + Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, - std::vector framesR, const ZonePlayParams& play) { + std::vector framesR, const ZonePlaySeconds& play) { SampleData data; data.frames = std::move(frames); // A second channel only counts when it length-matches channel 0 (else the sample stays @@ -146,23 +172,8 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, data.sampleRate = sampleRate > 0 ? sampleRate : 44100; data.rootNote = rootNote; data.loop = loop; - data.play = play; // S15/S16 single-capture play params (product defaults unless overridden) - - // The default `play` arg carries 44100-Hz nominal ADSR frame counts (kTier0Nominal*). - // Rescale A/D/R by (sampleRate / 44100) so the wall-clock ADSR matches tier0Adsr(sampleRate) - // exactly. Sustain (a level, not a frame count) is unchanged. At 44100 the factor is 1.0 — - // bit-identical to the pre-fix build. The tier-0 single-capture path never carries user-edited - // ADSR (users edit ADSR through zones, which go through buildZonedKeymap), so always rescaling - // here is correct and safe. - if (data.sampleRate != 44100) { - const double factor = static_cast(data.sampleRate) / 44100.0; - data.play.adsr.attackFrames = static_cast( - static_cast(data.play.adsr.attackFrames) * factor + 0.5); - data.play.adsr.decayFrames = static_cast( - static_cast(data.play.adsr.decayFrames) * factor + 0.5); - data.play.adsr.releaseFrames = static_cast( - static_cast(data.play.adsr.releaseFrames) * factor + 0.5); - } + // Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate. + data.play = resolvePlay(play, data.sampleRate); return Keymap::singleSampleChromatic(std::move(data)); } @@ -204,11 +215,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // never mutated — this only shapes what the core plays for THIS instance (D-B). rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found); rz.startFrame = z.startPoint ? *z.startPoint : 0; - // S15/S16 per-zone play params carry through unchanged (they are instrument state, not - // resolved against the bank) so the keymap build can stamp them onto the SampleData. + // S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument + // state, not resolved against the bank); buildZonedKeymap resolves them to frames. rz.play = z.play; - // Carry the rate-resolve flag so buildZonedKeymap can rescale 44100-nominal ADSR counts. - rz.adsrNeedsRateResolve = z.adsrNeedsRateResolve; out.zones.push_back(std::move(rz)); } return out; @@ -233,21 +242,9 @@ Keymap buildZonedKeymap(const std::vector& zones, data.rootNote = zones[i].rootNote; data.loop = zones[i].loop; data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) - data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes - - // If the zone's ADSR came from a v3 lift or a new-zone default (adsrNeedsRateResolve), - // its A/D/R frame counts are 44100-Hz nominals. Rescale to the WAV's actual rate so - // wall-clock ADSR durations match tier0Adsr(sampleRate) exactly. v4 zones (user-edited - // frame counts) carry adsrNeedsRateResolve=false and are left unchanged. - if (zones[i].adsrNeedsRateResolve && data.sampleRate != 44100) { - const double factor = static_cast(data.sampleRate) / 44100.0; - data.play.adsr.attackFrames = static_cast( - static_cast(data.play.adsr.attackFrames) * factor + 0.5); - data.play.adsr.decayFrames = static_cast( - static_cast(data.play.adsr.decayFrames) * factor + 0.5); - data.play.adsr.releaseFrames = static_cast( - static_cast(data.play.adsr.releaseFrames) * factor + 0.5); - } + // Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's + // actual rate; source-timeline params (trigger %-length + fades, start) carry through. + data.play = resolvePlay(zones[i].play, data.sampleRate); const std::size_t sampleIndex = km.samples.size(); km.samples.push_back(std::move(data)); KeyZone zone; @@ -376,26 +373,25 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) out.push_back(z.startPoint ? 1 : 0); if (z.startPoint) putU64le(out, asU64(*z.startPoint)); - // S15/S16 extension (PAYLOAD v3): the per-zone play params, always present (every zone - // has a play mode + engine — no flag gate). Order matches the header's v3 record spec. - const ZonePlayParams& pp = z.play; + // S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine). + // Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames / + // fraction. Order matches the header's v5 record spec. + const ZonePlaySeconds& pp = z.play; out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); - putU64le(out, asU64(pp.adsr.holdFrames)); - putU64le(out, doubleToBits(pp.trigger.lengthFraction)); - putU64le(out, asU64(pp.trigger.fadeInFrames)); - putU64le(out, asU64(pp.trigger.fadeOutFrames)); + putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds + putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction + putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames + putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); out.push_back(pp.pitchEnv.enabled ? 1 : 0); - putU64le(out, asU64(pp.pitchEnv.attackFrames)); - putU64le(out, asU64(pp.pitchEnv.decayFrames)); - putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); - // S12 review fix (PAYLOAD v4): the full per-zone AHDSR A/D/S/R tail (always present in v4). - // Voice::start now uses the zone's full ADSR; old v3 blobs lift to tier-0 nominal defaults - // at read time (see readZonesPayload) so the voice sounds bit-identical to the pre-fix build. - putU64le(out, asU64(pp.adsr.attackFrames)); - putU64le(out, asU64(pp.adsr.decayFrames)); + putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds + putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds + putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth + // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level). + putU64le(out, doubleToBits(pp.adsr.attackSeconds)); + putU64le(out, doubleToBits(pp.adsr.decaySeconds)); putU64le(out, doubleToBits(pp.adsr.sustainLevel)); - putU64le(out, asU64(pp.adsr.releaseFrames)); + putU64le(out, doubleToBits(pp.adsr.releaseSeconds)); } } @@ -405,20 +401,19 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) // clean back-compat lift, the overrides simply default absent). A truncated mid-zone read // keeps the zones that parsed cleanly and drops the rest. void readZonesPayload(ByteReader& r, PerformanceMap& map) { - bool extended = false; // v2+: the S11 loop/start tail is present - bool hasPlay = false; // v3+: the S15/S16 play-params tail is present - bool hasAdsr = false; // v4+: the full A/D/S/R per-zone tail is present + bool extended = false; // v2+: the S11 loop/start tail is present + std::uint32_t pv = 0; // payload version (0 = v1, no marker) if (r.peekU32() == kZonesFormatMarker) { r.u32(); // consume the marker - const std::uint32_t pv = r.u32(); // payload version + pv = r.u32(); // payload version extended = (pv >= 2); // v2+ carries the loop/start tail - hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail - hasAdsr = (pv >= 4); // v4+ carries the full A/D/S/R tail } + const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames + const bool secondsPlay = (pv >= 5); // current: full play params, wall-clock in seconds const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { - // z.play defaults to the PRODUCT defaults (Gate + Preserve). A v1/v2 payload (no play - // tail) therefore lifts every zone to those defaults — the deliberate S16-F1 change. + // z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A + // v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1). PerformanceZone z; const std::uint32_t idLen = r.u32(); z.sampleId = r.str(idLen); @@ -438,37 +433,41 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { const std::uint8_t hasStart = r.u8(); if (hasStart) z.startPoint = r.i64(); } - if (hasPlay) { - // S15/S16 play params, always present in a v3+ record (read in the emit order). + if (legacyV3Play) { + // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D) + // were written as 44.1k-nominal frames -> divide by kLegacyV3NominalRate to reach the + // seconds domain. Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are + // ABSENT in v3 -> leave the seconds defaults on z.play.adsr (0.003 / 0 / 1.0 / 0.060). z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; - z.play.adsr.holdFrames = r.i64(); + z.play.adsr.holdSeconds = static_cast(r.i64()) / kLegacyV3NominalRate; z.play.trigger.lengthFraction = bitsToDouble(r.u64()); z.play.trigger.fadeInFrames = r.i64(); z.play.trigger.fadeOutFrames = r.i64(); z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; z.play.pitchEnv.enabled = (r.u8() != 0); - z.play.pitchEnv.attackFrames = r.i64(); - z.play.pitchEnv.decayFrames = r.i64(); + z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / kLegacyV3NominalRate; + z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / kLegacyV3NominalRate; z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); + } else if (secondsPlay) { + // Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source + // frames; read in the emit order. + z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; + z.play.adsr.holdSeconds = bitsToDouble(r.u64()); + z.play.trigger.lengthFraction = bitsToDouble(r.u64()); + z.play.trigger.fadeInFrames = r.i64(); + z.play.trigger.fadeOutFrames = r.i64(); + z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; + z.play.pitchEnv.enabled = (r.u8() != 0); + z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64()); + z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64()); + z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); + z.play.adsr.attackSeconds = bitsToDouble(r.u64()); + z.play.adsr.decaySeconds = bitsToDouble(r.u64()); + z.play.adsr.sustainLevel = bitsToDouble(r.u64()); + z.play.adsr.releaseSeconds = bitsToDouble(r.u64()); } - if (hasAdsr) { - // S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order. - // These values were authored at the DAW's rate; mark them resolved (no rescaling). - z.play.adsr.attackFrames = r.i64(); - z.play.adsr.decayFrames = r.i64(); - z.play.adsr.sustainLevel = bitsToDouble(r.u64()); - z.play.adsr.releaseFrames = r.i64(); - z.adsrNeedsRateResolve = false; // already rate-resolved; do NOT rescale at keymap build - } else if (hasPlay) { - // v3 blob: A/D/S/R fields are absent. Lift to the tier-0 nominal defaults (44100 Hz) - // so a voice playing this zone sounds bit-identical to the pre-v4 build (back-compat). - // Voice::start now uses the zone's full ADSR; a zone with these values reproduces - // the instrument-wide tier0Adsr behavior the pre-fix code applied unconditionally. - z.play.adsr.attackFrames = kTier0NominalAttackFrames; - z.play.adsr.decayFrames = kTier0NominalDecayFrames; - z.play.adsr.sustainLevel = kTier0NominalSustainLevel; - z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; - } + // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the + // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest map.zones.push_back(std::move(z)); } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 76edc4b..810999b 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -107,20 +107,50 @@ std::vector downmixToMono(const std::vector& interleav std::vector extractChannel(const std::vector& interleaved, int channelCount, int which); -// Nominal tier-0 ADSR defaults (frames at 44100 Hz) — used when lifting a pre-v4 zones payload -// blob whose per-zone A/D/S/R fields are absent, and as the initializer for new zones / the -// buildTier0Keymap default play arg. These are 44100-Hz nominal frame counts; buildTier0Keymap -// and buildZonedKeymap both rescale the A/D/R frame counts by (sampleRate / 44100) at build -// time when adsrNeedsRateResolve is set on the zone — so a v3-lifted or default zone plays with -// the same wall-clock ADSR as tier0Adsr(liveRate) did before the fix. Bit-identical at 44100 Hz. -// Zones loaded from a v4 blob (explicitly user-edited) carry adsrNeedsRateResolve=false and are -// never rescaled — their stored frame counts already reflect the rate at which they were authored. -// Must be declared before buildTier0Keymap (default arg) and PerformanceZone / ResolvedZone -// (member initializers) — both of which reference these values. -inline constexpr std::int64_t kTier0NominalAttackFrames = 132; // 0.003 * 44100, rounded -inline constexpr std::int64_t kTier0NominalDecayFrames = 0; -inline constexpr double kTier0NominalSustainLevel = 1.0; -inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 44100 +// --- Stored (wall-clock SECONDS) per-zone play params ------------------------- +// +// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program). +// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the +// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the +// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay) +// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds. +// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length +// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source +// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim). +// +// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time. +struct AdsrSeconds { + double attackSeconds = 0.003; // tier-0 default + double holdSeconds = 0.0; + double decaySeconds = 0.0; + double sustainLevel = 1.0; + double releaseSeconds = 0.060; // tier-0 default +}; + +// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless. +struct PitchEnvSeconds { + bool enabled = false; + double attackSeconds = 0.0; + double decaySeconds = 0.0; + double peakSemitones = 0.0; // signed depth at the peak +}; + +// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in +// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing +// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap +// builders resolve this to a frame-domain ZonePlayParams against the live sample rate. +struct ZonePlaySeconds { + PlayMode playMode = PlayMode::Gate; + AdsrSeconds adsr; // Gate: AHDSR (seconds) + TriggerParams trigger; // Trigger: %-length + fades (source frames) + PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1) + PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default +}; + +// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live +// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode, +// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this). +ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate); // Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole // keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case @@ -129,20 +159,14 @@ inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 441 // which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length // mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad // pair never half-plays. `sampleRate` is the WAV's rate. -// `play` carries the S15/S16 per-zone play params for the single-capture path; it defaults to -// the PRODUCT defaults (Gate + Preserve engine, S16-F1) so a picked single capture plays under -// the same default engine as a zone would. The A/D/R frame counts in the default play arg are -// 44100-Hz nominals; this function always rescales them by (sampleRate / 44100) before stamping -// them on the SampleData so the ADSR wall-clock durations match tier0Adsr(sampleRate) exactly. +// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it +// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a +// picked single capture plays under the same default engine as a zone would. This function +// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData. Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector framesR = {}, - const ZonePlayParams& play = ZonePlayParams{ - PlayMode::Gate, - AdsrParams{kTier0NominalAttackFrames, 0, - kTier0NominalDecayFrames, kTier0NominalSustainLevel, - kTier0NominalReleaseFrames}, - TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}); + const ZonePlaySeconds& play = ZonePlaySeconds{}); // --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // @@ -176,24 +200,12 @@ struct PerformanceZone { // S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch // engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the - // loop/start overrides. Defaults to the PRODUCT defaults for a NEW zone: Gate play mode, - // AHDSR with the tier-0 nominal A/D/S/R (kTier0Nominal* at 44100 Hz — the same values a - // v3 blob lifts to), hold 0, no fades, and the PRESERVE pitch engine (S16-F1), pitch env - // off. An older zone-payload blob (no S15/S16 tail or no v4 A/D/S/R tail) lifts to exactly - // these defaults on read (see the PAYLOAD v3/v4 versioning), so a pre-v4 instrument opens - // with Gate + Preserve + tier-0 ADSR — the deliberate back-compat path. - ZonePlayParams play{PlayMode::Gate, - AdsrParams{kTier0NominalAttackFrames, 0, - kTier0NominalDecayFrames, kTier0NominalSustainLevel, - kTier0NominalReleaseFrames}, - TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; - - // When true, the A/D/R frame counts in play.adsr are 44100-Hz nominals (either lifted from a - // v3 blob or defaulted for a new zone) that must be rescaled by (sampleRate / 44100) at keymap - // build time (buildZonedKeymap). Set to false when a v4 blob explicitly provides A/D/S/R (the - // stored counts already reflect the DAW rate at the time the user edited them) or when the user - // edits an ADSR slider (the committed value is already editor-domain). Never serialized. - bool adsrNeedsRateResolve = true; + // loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build + // resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW + // zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, + // PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail) + // lifts to exactly these defaults on read (see the PAYLOAD versioning). + ZonePlaySeconds play; }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -217,15 +229,7 @@ struct ResolvedZone { int rootNote = 60; // effective: override, else bank intrinsic, else 60 SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) - ZonePlayParams play{PlayMode::Gate, - AdsrParams{kTier0NominalAttackFrames, 0, - kTier0NominalDecayFrames, kTier0NominalSustainLevel, - kTier0NominalReleaseFrames}, - TriggerParams{}, kDefaultPitchEngine, - PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is) - // Carried from PerformanceZone::adsrNeedsRateResolve — buildZonedKeymap rescales A/D/R - // frames by (sampleRate / 44100) when true. False for v4-explicit or user-edited values. - bool adsrNeedsRateResolve = true; + ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build) }; // The result of resolving a performance map against the live bank blob. `zones` are the @@ -305,27 +309,39 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). // The reader detects the marker to know the record shape — a v1 payload (no marker) reads // the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. -// * PAYLOAD v3 (S15/S16): the same marker + payload version (== 3), THEN the v2 body PLUS, -// appended to each zone record after the S11 startPoint tail (the S15/S16 per-zone play -// params — always present, NOT flag-gated, since every zone has a play mode + engine): +// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload +// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint +// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): // 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage; +// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; // 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); // 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); // 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); -// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64); -// 8-byte LE pitchEnv.decayFrames (int64); 8-byte LE pitchEnv.peakSemitones as a double. +// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); +// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double. // A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + // no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved // instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. -// * PAYLOAD v4 (S12 review fix): the same marker + payload version (== 4), THEN the v3 body -// PLUS, appended to each zone record after the v3 pitch-env tail, the full per-zone A/D/S/R: -// 8-byte LE adsr.attackFrames (int64); 8-byte LE adsr.decayFrames (int64); -// 8-byte LE adsr.sustainLevel as an IEEE-754 double (bit-cast to u64 LE); -// 8-byte LE adsr.releaseFrames (int64). -// A v3 payload (no v4 A/D/S/R tail) lifts those fields to the tier-0 nominal defaults at -// 44100 Hz (kTier0Nominal* constants) so a voice using the zone ADSR sounds bit-identical to -// the pre-v4 build. Voice::start now uses the zone's full ADSR for all five AHDSR fields. +// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS +// written by the S15/S16 editor as 44.1k-nominal frames (that build's slider domain was fixed at +// 44100), so they convert to the seconds domain by dividing by that authoring-time nominal rate +// (kLegacyV3NominalRate). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R +// are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060), no rate. +// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), +// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full +// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine; 1 byte pitchEnv.enabled; +// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double); +// 8-byte LE pitchEnv.peakSemitones (double); +// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double); +// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double). +// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4 +// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader +// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds +// to frames at the LIVE sample rate; no rate is baked into storage or the program. // BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is // lifted to a single full-keyboard zone playing that id (no override) — so an instance saved // under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes @@ -345,9 +361,15 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; // (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults. // The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in // practice, always tiny) can never collide with. -inline constexpr std::uint32_t kZonesPayloadVersion = 4; // S15/S16 A/D/S/R per-zone tail +inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; +// The authoring-time nominal rate the LEGACY v3 zone payload's wall-clock frame counts (hold, +// pitchEnv A/D) were always written at (the S15/S16 editor's slider domain was fixed at 44100 Hz). +// Used ONLY at the v3 read boundary to convert those legacy frames to the seconds domain — it is a +// property of the frozen v3 wire format, not a live program rate. No other site may reference it. +inline constexpr double kLegacyV3NominalRate = 44100.0; + // The performance map serialized to bytes for IBStream (getState). std::vector serializePerformance(const PerformanceMap& map); @@ -364,12 +386,13 @@ PerformanceMap deserializePerformance(const std::vector& bytes); // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // state), never auto-playing sample #1. // -// Format (v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono, +// Format (envelope v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono, // 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), -// then a 4-byte LE selection-id length + id bytes, then the v2 zones payload (4-byte LE zone -// count + per-zone records, identical to serializePerformance's body). The 8-byte marker is -// the ONLY v5 addition over v4 — the envelope grew a field, the zones payload is untouched -// (a PARALLEL track owns zone-record extension under the map's own versioning). BACK-COMPAT on +// then a 4-byte LE selection-id length + id bytes, then the CURRENT zones payload (identical to +// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). +// The 8-byte marker is the ONLY envelope-v5 addition over envelope-v4 — the envelope grew a field, +// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own +// versioning; the two version numbers are independent axes). BACK-COMPAT on // read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0, // preserving current behavior for already-saved instances): // * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct. diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 82734fe..afb3361 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -249,8 +249,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) { shiftR_.configure(windowFrames); } -void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, - const AdsrParams& gateAdsr) { +void Voice::start(int note, int velocity, const SampleData& sample, int rootNote) { active_ = true; releasing_ = false; amplitudeDone_ = false; @@ -279,15 +278,13 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote // --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's // play.adsr); Trigger = the time-boxed fade-in/out over the % play length. // - // All five AHDSR fields come from sample.play.adsr, stamped by buildTier0Keymap / - // buildZonedKeymap at reload time (with rate-rescaling for v3-lifted / default zones). - // gateAdsr (the VoiceEngine's instrument-wide ADSR) is accepted for interface compat - // but is NOT read here — it is vestigial since the S12 review fix moved A/D/S/R fully - // onto the per-zone SampleData. + // All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by + // buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against + // the live sample rate. // - // Back-compat invariant: a zone whose adsr fields carry the tier-0 nominal values - // (rescaled to the live sample rate by buildZonedKeymap) sounds bit-identical to the - // pre-fix build at every DAW rate. --- + // Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults + // (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at + // every DAW rate — now trivially true, since the times are wall-clock seconds. --- if (playMode_ == PlayMode::Gate) { env_.configure(p.adsr); env_.noteOn(); @@ -488,9 +485,9 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) { // --------------------------------------------------------------------------- VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, - const AdsrParams& adsr, std::size_t preserveVoiceCap, + std::size_t preserveVoiceCap, std::int64_t preserveWindowFrames) - : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr), + : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), preserveVoiceCap_(preserveVoiceCap) { // maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on; // clamp to a single voice so the engine is always usable (documented degenerate). @@ -561,7 +558,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) { // The voice's Preserve shifters were pre-sized at engine construction (off-thread), so // start() only reset()s + warm()s them — no allocation on this audio-thread path. const std::size_t v = allocateVoice(); - voices_[v].start(note, velocity, sample, zone.rootNote, adsr_); + voices_[v].start(note, velocity, sample, zone.rootNote); voices_[v].setStartOrder(nextStartOrder_++); return v; } diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index e552190..98f826b 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -341,18 +341,15 @@ public: // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference // the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched // from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from - // sample.play.adsr — the per-zone values stamped by buildTier0Keymap / buildZonedKeymap. - // `gateAdsr` is the VoiceEngine's instrument-wide ADSR parameter, accepted for interface - // compatibility but NOT used by start() (vestigial since the S12 review fix moved A/D/S/R - // onto the per-zone SampleData). The S15 play MODE + Trigger params and the S16 pitch - // ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST already - // be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s - // them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm - // silence pass settles the OLA taps before the first output frame (no cold-start click). - // Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no - // pitch env). - void start(int note, int velocity, const SampleData& sample, int rootNote, - const AdsrParams& gateAdsr); + // sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by + // buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE + + // Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`. + // The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) — + // start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio + // thread inside process(). The warm silence pass settles the OLA taps before the first + // output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play + // is default (Gate + Varispeed + no pitch env). + void start(int note, int velocity, const SampleData& sample, int rootNote); // Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in // TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length). @@ -456,18 +453,19 @@ class VoiceEngine { public: // Builds an engine with `maxVoices` voices (the polyphony bound) playing from // `keymap`. The keymap must outlive the engine (the engine holds a reference — it - // reads zones and sample data through it, never copies PCM). `adsr` is the instrument-wide - // Gate AHDSR timing (attack/decay/sustain/release); each zone's HOLD stage + play mode + - // pitch engine ride on its SampleData::play. `preserveVoiceCap` (S16) bounds how many - // Preserve-engine voices may sound at once (the shifter is materially heavier than - // Varispeed) — a Preserve note-on beyond the cap is dropped rather than glitching; 0 means - // "no separate Preserve cap" (bounded only by maxVoices). `preserveWindowFrames` is the OLA - // window (in OUTPUT frames) every voice's Preserve pitch shifters are PRE-SIZED to at - // construction (OFF the audio thread), so note-on (which runs in process()) never allocates; - // 0 leaves them pass-through (a Varispeed-only instrument pays no ring cost). The processor - // derives it from the host sample rate (kPreserveWindowMs). Defaulted so existing callers - // (and the pure-core tests) are unaffected. - VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr, + // reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R) + // + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved + // from the stored seconds at keymap build); the engine holds no instrument-wide ADSR. + // `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the + // shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is + // dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by + // maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's + // Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so + // note-on (which runs in process()) never allocates; 0 leaves them pass-through (a + // Varispeed-only instrument pays no ring cost). The processor derives it from the host + // sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests) + // are unaffected. + VoiceEngine(std::size_t maxVoices, const Keymap& keymap, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0); // MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of @@ -524,7 +522,6 @@ private: std::vector voices_; const Keymap& keymap_; - AdsrParams adsr_; std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap) std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started" }; diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 339972d..73609a2 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1031,37 +1031,157 @@ static void testComponentStateV5TruncatedMarker() { CHECK(back.selectionId.empty() && back.map.zones.empty()); } +// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ---------------- +// +// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and +// ps-w9-t2-modes (payload v3, adds the per-zone play params) makes THREE combinations first +// reachable. Each pre-existing suite covers one axis in isolation; these lock the axes together. + +static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() { + // (a) The full v5 face: channelMode + the S8/S9 consumed marker (envelope) AND zones carrying + // S15/S16 play params (payload) must ALL survive one serialize/deserialize. The two extensions + // sit on orthogonal tracks (envelope vs self-versioned payload); this proves they compose with + // no field cross-talk — neither the marker read nor the play-param read consumes the other's bytes. + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX -> exercises the full 8-byte field + PerformanceZone z = zone("z0", 0, 127, /*override=*/48); + z.play.playMode = PlayMode::Trigger; + z.play.adsr.holdSeconds = 0.093; + z.play.trigger.lengthFraction = 0.625; + z.play.trigger.fadeInFrames = 32; + z.play.trigger.fadeOutFrames = 96; + z.play.pitchEngine = PitchEngine::Varispeed; + z.play.pitchEnv.enabled = true; + z.play.pitchEnv.attackSeconds = 0.00018; + z.play.pitchEnv.decaySeconds = 0.0145; + z.play.pitchEnv.peakSemitones = 12.5; + s.map.zones.push_back(z); + + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode + CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 1); + if (back.map.zones.size() != 1) return; + const ZonePlaySeconds& p = back.map.zones[0].play; // payload: play params + CHECK(p.playMode == PlayMode::Trigger); + CHECK(p.adsr.holdSeconds == 0.093); + CHECK(p.trigger.lengthFraction == 0.625); + CHECK(p.trigger.fadeInFrames == 32 && p.trigger.fadeOutFrames == 96); + CHECK(p.pitchEngine == PitchEngine::Varispeed); + CHECK(p.pitchEnv.enabled && p.pitchEnv.attackSeconds == 0.00018 && + p.pitchEnv.decaySeconds == 0.0145 && p.pitchEnv.peakSemitones == 12.5); +} + +// Hand-build ONE v3 zone record (marker-versioned payload body) for a single-zone map. Emits the +// exact on-wire order the header's PAYLOAD v3 spec + putZonesPayload write: id, lo/hi, no root/loop/ +// start overrides, then the always-present S15/S16 play tail. Used to synthesize the two v4 blobs +// below WITHOUT serializeComponentState (which now emits v5) — so the reader's widened accept-chain +// is exercised against a genuine, older-envelope byte layout rather than a self-produced buffer. +static std::vector handBuildV3PayloadOneZone(const std::string& id) { + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + auto u64 = [&](std::uint64_t v) { + for (int i = 0; i < 8; ++i) b.push_back(static_cast((v >> (i * 8)) & 0xFF)); + }; + auto dbl = [&](double d) { std::uint64_t bits; std::memcpy(&bits, &d, 8); u64(bits); }; + u32(kZonesFormatMarker); + u32(3); // PAYLOAD VERSION 3 (S15/S16 play tail present) + u32(1); // zone count 1 + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(10); // lowNote + u32(70); // highNote + b.push_back(0); // hasRootOverride = 0 + b.push_back(0); // hasLoopOverride = 0 + b.push_back(0); // hasStartPoint = 0 + // Always-present v3 play tail: Trigger, hold, lengthFraction, fades, Varispeed, env off. + b.push_back(1); // playMode = Trigger + u64(static_cast(2048)); // adsr.holdFrames + dbl(0.5); // trigger.lengthFraction + u64(static_cast(16)); // trigger.fadeInFrames + u64(static_cast(48)); // trigger.fadeOutFrames + b.push_back(0); // pitchEngine = Varispeed + b.push_back(0); // pitchEnv.enabled = 0 + u64(0); // pitchEnv.attackFrames + u64(0); // pitchEnv.decayFrames + dbl(0.0); // pitchEnv.peakSemitones + return b; +} + +static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() { + // (b) + (c) unified: a GENUINE v4 ENVELOPE blob (version tag 4: mode byte, id, then the zones + // payload — NO 8-byte marker) whose zones payload is PAYLOAD v3 (the exact shape an S15-test-build + // save produced). Under the widened accept-chain it must (b) lift lastConsumedAssignGeneration to + // 0 AND (c) deserialize its payload-v3 play params intact. This is the precise blob a user who + // saved on the S15 test build (envelope v4 + payload v3) would hold; the v4 lift branch delegates + // zones to readZonesPayload, which self-selects the v3 record shape from the payload marker — so + // the two v4 layouts (S7-era payload-v2, S15-era payload-v3) are UNAMBIGUOUS, distinguished + // inside the payload, not on the envelope. + std::vector v4; + v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // ENVELOPE version 4 + v4.push_back(1); // channel mode = stereo + const std::string id = "s15saved"; + v4.push_back(static_cast(id.size())); + v4.push_back(0); v4.push_back(0); v4.push_back(0); // idLen (LE) + v4.insert(v4.end(), id.begin(), id.end()); + const std::vector payload = handBuildV3PayloadOneZone("zv3"); + v4.insert(v4.end(), payload.begin(), payload.end()); + + const ComponentState back = deserializeComponentState(v4); + CHECK(back.lastConsumedAssignGeneration == 0); // (b) no marker in v4 -> default 0 + CHECK(back.channelMode == ChannelMode::Stereo); // v4 envelope mode honored + CHECK(back.selectionId == "s15saved"); + CHECK(back.map.zones.size() == 1); // (c) payload-v3 zone parsed under widened check + if (back.map.zones.size() != 1) return; + CHECK(back.map.zones[0].sampleId == "zv3"); + CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70); + const ZonePlaySeconds& p = back.map.zones[0].play; + CHECK(p.playMode == PlayMode::Trigger); // (c) play params survive the v4 envelope + // Legacy v3 wall-clock frames (44.1k-nominal) convert to seconds at the v3 authoring rate. + CHECK(approx(p.adsr.holdSeconds, 2048.0 / kLegacyV3NominalRate)); + CHECK(p.trigger.lengthFraction == 0.5); + CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is + CHECK(p.pitchEngine == PitchEngine::Varispeed); + CHECK(p.pitchEnv.enabled == false); +} + // --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift ------------- static void testPlayParamsRoundTrip() { - // A zone carrying explicit S15/S16 play params (Trigger mode, hold, fades, Varispeed engine, - // pitch env on) must round-trip ALL fields losslessly through the payload-v3 tail. + // A zone carrying explicit S15/S16 play params (Trigger mode, hold seconds, source-frame fades, + // Varispeed engine, pitch env on) must round-trip ALL fields losslessly through the v5 tail. PerformanceMap m; PerformanceZone z = zone("lead", 20, 100, /*override=*/55); z.play.playMode = PlayMode::Trigger; - z.play.adsr.holdFrames = 1234; + z.play.adsr.holdSeconds = 0.028; // wall-clock seconds z.play.trigger.lengthFraction = 0.375; - z.play.trigger.fadeInFrames = 64; + z.play.trigger.fadeInFrames = 64; // source frames z.play.trigger.fadeOutFrames = 128; z.play.pitchEngine = PitchEngine::Varispeed; z.play.pitchEnv.enabled = true; - z.play.pitchEnv.attackFrames = 10; - z.play.pitchEnv.decayFrames = 500; + z.play.pitchEnv.attackSeconds = 0.0002; // wall-clock seconds + z.play.pitchEnv.decaySeconds = 0.011; z.play.pitchEnv.peakSemitones = -7.5; m.zones.push_back(z); const PerformanceMap back = deserializePerformance(serializePerformance(m)); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; - const ZonePlayParams& p = back.zones[0].play; + const ZonePlaySeconds& p = back.zones[0].play; CHECK(p.playMode == PlayMode::Trigger); - CHECK(p.adsr.holdFrames == 1234); + CHECK(p.adsr.holdSeconds == 0.028); // exact double round-trip CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip CHECK(p.trigger.fadeInFrames == 64); CHECK(p.trigger.fadeOutFrames == 128); CHECK(p.pitchEngine == PitchEngine::Varispeed); CHECK(p.pitchEnv.enabled == true); - CHECK(p.pitchEnv.attackFrames == 10); - CHECK(p.pitchEnv.decayFrames == 500); + CHECK(p.pitchEnv.attackSeconds == 0.0002); + CHECK(p.pitchEnv.decaySeconds == 0.011); CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip } @@ -1073,7 +1193,7 @@ static void testPlayParamsComposeWithLoopStart() { z.loopOverride = lp; z.startPoint = 333; z.play.playMode = PlayMode::Gate; - z.play.adsr.holdFrames = 999; + z.play.adsr.holdSeconds = 0.0225; z.play.pitchEngine = PitchEngine::Preserve; m.zones.push_back(z); const PerformanceMap back = deserializePerformance(serializePerformance(m)); @@ -1082,7 +1202,7 @@ static void testPlayParamsComposeWithLoopStart() { CHECK(back.zones[0].loopOverride.has_value() && back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222); CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333); - CHECK(back.zones[0].play.adsr.holdFrames == 999); + CHECK(back.zones[0].play.adsr.holdSeconds == 0.0225); CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); } @@ -1115,7 +1235,7 @@ static void testPlayParamsV2BackCompatLiftsToDefaults() { CHECK(back.zones[0].play.playMode == PlayMode::Gate); CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve CHECK(back.zones[0].play.pitchEnv.enabled == false); - CHECK(back.zones[0].play.adsr.holdFrames == 0); + CHECK(back.zones[0].play.adsr.holdSeconds == 0.0); } static void testPlayParamsThroughComponentEnvelope() { @@ -1138,44 +1258,40 @@ static void testPlayParamsThroughComponentEnvelope() { CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed); } -// --- S12 review fix (PAYLOAD v4): full A/D/S/R per-zone round-trip. --------------------- +// --- S12 domain fix: wall-clock ADSR stored as SECONDS, resolved to frames at the live rate. --- // -// Before the fix, per-zone A/D/S/R (attack/decay/sustain/release) was not serialized; -// only holdFrames was written. These two tests assert the corrected v4 path. +// These tests replace the R1/R2 flag/nominal-frame tests. The stored domain is seconds (rate-free); +// the keymap build resolves seconds -> frames against whatever WAV rate is live. The lift -> +// commit -> reload sequence must stay rate-correct at every rate (the R2 blocker). -// All five AHDSR fields (including the four new A/D/S/R) must round-trip through the v4 payload. -static void testFullAdsrV4RoundTrip() { +// All five AHDSR fields round-trip through the v5 payload as SECONDS (exact double round-trip). +static void testFullAdsrSecondsRoundTrip() { PerformanceMap m; PerformanceZone z = zone("pad", 0, 127); z.play.playMode = PlayMode::Gate; - z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (a non-default value) - z.play.adsr.holdFrames = 882; - z.play.adsr.decayFrames = 4410; // 0.1 s - z.play.adsr.sustainLevel = 0.7; - z.play.adsr.releaseFrames = 8820; // 0.2 s + z.play.adsr.attackSeconds = 0.01; + z.play.adsr.holdSeconds = 0.02; + z.play.adsr.decaySeconds = 0.1; + z.play.adsr.sustainLevel = 0.7; + z.play.adsr.releaseSeconds = 0.2; z.play.pitchEngine = PitchEngine::Preserve; m.zones.push_back(z); const PerformanceMap back = deserializePerformance(serializePerformance(m)); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; - const AdsrParams& a = back.zones[0].play.adsr; - CHECK(a.attackFrames == 441); - CHECK(a.holdFrames == 882); - CHECK(a.decayFrames == 4410); - CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast - CHECK(a.releaseFrames == 8820); + const AdsrSeconds& a = back.zones[0].play.adsr; + CHECK(a.attackSeconds == 0.01); + CHECK(a.holdSeconds == 0.02); + CHECK(a.decaySeconds == 0.1); + CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast + CHECK(a.releaseSeconds == 0.2); CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); } -// A genuine PAYLOAD v3 blob (S15/S16 build — has holdFrames but not A/D/S/R) must lift -// attackFrames / decayFrames / sustainLevel / releaseFrames to the tier-0 nominal defaults -// (kTier0Nominal* constants) so the voice sounds bit-identical to the pre-fix behavior. -// We reuse handBuildV3PayloadOneZone which emits a valid marker-versioned v3 payload. -static void testV3BlobLiftsAdsrToNominalDefaults() { - // Build a PERFORMANCE blob: 4-byte kPerformanceStateVersion header + v3 zones payload. - // deserializePerformance strips the 4-byte header and passes the rest to readZonesPayload, - // which self-selects the v3 record shape from the payload marker+version — exercising the - // real production lift path for a user who saved on the S15 build. +// A legacy PAYLOAD v3 blob (Daniel's beta projects — has holdFrames but no A/D/S/R) lifts the +// absent A/D/S/R to the tier-0 SECONDS defaults (0.003 / 0 / 1.0 / 0.060), NO rate involved: they +// were always the seconds constants. holdSeconds converts from the v3 44.1k-nominal frame count. +static void testV3BlobLiftsAdsrToSecondsDefaults() { std::vector blob; auto u32 = [&](std::uint32_t v) { blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); @@ -1187,91 +1303,81 @@ static void testV3BlobLiftsAdsrToNominalDefaults() { const PerformanceMap back = deserializePerformance(blob); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; - const AdsrParams& a = back.zones[0].play.adsr; - // holdFrames comes from the v3 record itself; A/D/S/R must lift to the nominal tier-0 values. - CHECK(a.holdFrames == 2048); // from the hand-built v3 record - CHECK(a.attackFrames == kTier0NominalAttackFrames); // 132 (0.003 s at 44100) - CHECK(a.decayFrames == kTier0NominalDecayFrames); // 0 - CHECK(a.sustainLevel == kTier0NominalSustainLevel); // 1.0 - CHECK(a.releaseFrames == kTier0NominalReleaseFrames); // 2646 (0.060 s at 44100) - // The lifted zone must be flagged for rate-resolve so buildZonedKeymap rescales at the live rate. - CHECK(back.zones[0].adsrNeedsRateResolve == true); + const AdsrSeconds& a = back.zones[0].play.adsr; + // hold converts from the v3 record's 44.1k-nominal frames; A/D/S/R lift to the seconds defaults. + CHECK(approx(a.holdSeconds, 2048.0 / kLegacyV3NominalRate)); // from the hand-built v3 record + CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds) + CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0 + CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0 + CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060 } -// A v4 blob (explicit A/D/S/R tail) must NOT set adsrNeedsRateResolve — those values -// were authored at the DAW's rate and must not be rescaled again at keymap build time. -static void testV4BlobClearsAdsrNeedsRateResolve() { - PerformanceMap m; - PerformanceZone z = zone("pad", 0, 127); - z.play.adsr.attackFrames = 441; - z.play.adsr.releaseFrames = 8820; - m.zones.push_back(z); - // A round-trip through serialize/deserialize writes a v4 payload (current version). - const PerformanceMap back = deserializePerformance(serializePerformance(m)); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - // v4 tail was explicitly read — adsrNeedsRateResolve must be false. - CHECK(back.zones[0].adsrNeedsRateResolve == false); +// The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone +// resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate)); +// an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates. +static void testKeymapBuildResolvesSecondsToFramesAtEachRate() { + const auto rnd = [](double s, int rate) { + return static_cast(s * static_cast(rate) + 0.5); + }; + for (int rate : {44100, 48000, 96000}) { + // (a) DEFAULT zone (round-tripped through serialize/deserialize) -> tier-0 seconds. + { + PerformanceMap m; + m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds) + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) continue; + ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60; + rz.play = back.zones[0].play; + const DecodedZonePcm pcm{{0.5f}, rate}; + const Keymap km = buildZonedKeymap({rz}, {pcm}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) continue; + const AdsrParams& a = km.samples[0].play.adsr; + CHECK(a.attackFrames == rnd(0.003, rate)); // tier-0 attack at this rate + CHECK(a.decayFrames == 0); + CHECK(a.sustainLevel == 1.0); // level, never rate-scaled + CHECK(a.releaseFrames == rnd(0.060, rate)); // tier-0 release at this rate + } + // (b) AUTHORED zone -> round(seconds * rate) at this rate. + { + PerformanceMap m; + PerformanceZone z = zone("auth", 0, 127); + z.play.adsr.attackSeconds = 0.01; + z.play.adsr.decaySeconds = 0.1; + z.play.adsr.sustainLevel = 0.7; + z.play.adsr.releaseSeconds = 0.2; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) continue; + ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60; + rz.play = back.zones[0].play; + const DecodedZonePcm pcm{{0.5f}, rate}; + const Keymap km = buildZonedKeymap({rz}, {pcm}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) continue; + const AdsrParams& a = km.samples[0].play.adsr; + CHECK(a.attackFrames == rnd(0.01, rate)); + CHECK(a.decayFrames == rnd(0.1, rate)); + CHECK(a.sustainLevel == 0.7); // level, never rate-scaled + CHECK(a.releaseFrames == rnd(0.2, rate)); + } + } } -// buildTier0Keymap at 48k must produce ADSR frame counts equal to tier0Adsr(48000): -// attack = round(kTier0NominalAttackFrames * 48000 / 44100) = round(143.67) = 144, -// release = round(kTier0NominalReleaseFrames * 48000 / 44100) = round(2880.0) = 2880. -// This is the "pre-fix, the Gate voice used tier0Adsr(sampleRate_)" invariant restored -// for the single-capture fast path at any DAW rate. -static void testBuildTier0KeymapRescalesAdsrAt48k() { +// buildTier0Keymap resolves the default (seconds) play arg to frames at the WAV's rate — the +// single-capture fast path. At 48k the tier-0 attack is round(0.003*48000)=144, release +// round(0.060*48000)=2880 — identical wall-clock to any rate, no baked constant. +static void testBuildTier0KeymapResolvesSecondsAt48k() { const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{}); CHECK(km.samples.size() == 1); if (km.samples.empty()) return; const AdsrParams& a = km.samples[0].play.adsr; - // Rescaled from 44100-nominal at 48000 Hz: - CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0) - CHECK(a.decayFrames == 0); // 0 * factor = 0 (no change) - CHECK(a.sustainLevel == 1.0); // level, not frames (no rescale) - CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0) -} - -// buildZonedKeymap at 48k with adsrNeedsRateResolve=true must rescale ADSR to match -// tier0Adsr(48000), mirroring what Gate voices saw before the per-zone-ADSR fix. -static void testBuildZonedKeymapRescalesNominalAdsrAt48k() { - // Build a zone with nominal 44100-Hz ADSR and the needs-resolve flag (the default). - ResolvedZone z; - z.lowNote = 0; z.highNote = 127; z.rootNote = 60; - z.adsrNeedsRateResolve = true; // 44100-nominal values, rescale needed - z.play.adsr.attackFrames = kTier0NominalAttackFrames; // 132 - z.play.adsr.decayFrames = kTier0NominalDecayFrames; // 0 - z.play.adsr.sustainLevel = kTier0NominalSustainLevel; // 1.0 - z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; // 2646 - const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV - const Keymap km = buildZonedKeymap({z}, {pcm}); - CHECK(km.samples.size() == 1); - if (km.samples.empty()) return; - const AdsrParams& a = km.samples[0].play.adsr; - CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0) - CHECK(a.decayFrames == 0); // 0 * factor = 0 - CHECK(a.sustainLevel == 1.0); // level, not rescaled - CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0) -} - -// buildZonedKeymap must NOT rescale a zone whose adsrNeedsRateResolve is false — -// those frame counts are explicitly user-authored at the DAW's rate. -static void testBuildZonedKeymapDoesNotRescaleV4Adsr() { - ResolvedZone z; - z.lowNote = 0; z.highNote = 127; z.rootNote = 60; - z.adsrNeedsRateResolve = false; // v4 blob or user-edited — do not rescale - z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (non-nominal) - z.play.adsr.decayFrames = 4410; - z.play.adsr.sustainLevel = 0.7; - z.play.adsr.releaseFrames = 8820; - const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV — rescale would change values - const Keymap km = buildZonedKeymap({z}, {pcm}); - CHECK(km.samples.size() == 1); - if (km.samples.empty()) return; - const AdsrParams& a = km.samples[0].play.adsr; - CHECK(a.attackFrames == 441); // unchanged (not rescaled) - CHECK(a.decayFrames == 4410); - CHECK(a.sustainLevel == 0.7); - CHECK(a.releaseFrames == 8820); + CHECK(a.attackFrames == 144); // round(0.003 * 48000) + CHECK(a.decayFrames == 0); + CHECK(a.sustainLevel == 1.0); // level, not a time + CHECK(a.releaseFrames == 2880); // round(0.060 * 48000) } int main() { @@ -1325,12 +1431,10 @@ int main() { testPlayParamsComposeWithLoopStart(); testPlayParamsV2BackCompatLiftsToDefaults(); testPlayParamsThroughComponentEnvelope(); - testFullAdsrV4RoundTrip(); - testV3BlobLiftsAdsrToNominalDefaults(); - testV4BlobClearsAdsrNeedsRateResolve(); - testBuildTier0KeymapRescalesAdsrAt48k(); - testBuildZonedKeymapRescalesNominalAdsrAt48k(); - testBuildZonedKeymapDoesNotRescaleV4Adsr(); + testFullAdsrSecondsRoundTrip(); + testV3BlobLiftsAdsrToSecondsDefaults(); + testKeymapBuildResolvesSecondsToFramesAtEachRate(); + testBuildTier0KeymapResolvesSecondsAt48k(); testComponentStateRoundTrip(); testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); @@ -1358,6 +1462,8 @@ int main() { testComponentStateDefaultMarkerIsZero(); testComponentStateV4LiftsMarkerToZero(); testComponentStateV5TruncatedMarker(); + testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); + testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 7622d22..66153b2 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -149,7 +149,7 @@ static void testRepitchObservedPeriod() { // Unity: played at root, observed period ~= native. { Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(60, 127); std::vector out; eng.render(out, frames); @@ -159,7 +159,7 @@ static void testRepitchObservedPeriod() { // +1 octave: advances 2x, observed period halves. { Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(72, 127); std::vector out; eng.render(out, frames / 2); // half as many frames covers the whole sample @@ -169,7 +169,7 @@ static void testRepitchObservedPeriod() { // -1 octave: advances 0.5x, observed period doubles. { Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(48, 127); std::vector out; eng.render(out, frames); @@ -285,7 +285,7 @@ static void testAdsrZeroAttackDecay() { static void testPolyphonicAllocation() { Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60)); - VoiceEngine eng(8, km, flatAdsr()); + VoiceEngine eng(8, km); // Four simultaneous notes -> four active voices, each on a distinct voice. std::size_t v60 = eng.noteOn(60, 100); @@ -329,10 +329,11 @@ static void testNoteOffReleasesNewestSameNote() { const double gainOld = velOld / 127.0; // ~0.504 const double gainNew = velNew / 127.0; // 1.0 - Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); - AdsrParams a = flatAdsr(); - a.releaseFrames = 10; // short but non-zero so voice stays active through release - VoiceEngine eng(8, km, a); + SampleData sd = dcSample(100000, 60); + sd.play.adsr = flatAdsr(); + sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release + Keymap km = Keymap::singleSampleChromatic(sd); + VoiceEngine eng(8, km); std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain @@ -371,7 +372,7 @@ static void testOutOfZoneNoteConsumesNoVoice() { Keymap km; km.samples.push_back(dcSample(100, 60)); km.zones.push_back(KeyZone{60, 72, 60, 0}); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); std::size_t v = eng.noteOn(30, 100); // below the only zone CHECK(v == VoiceEngine::kNoVoice); @@ -383,14 +384,13 @@ static void testOutOfZoneNoteConsumesNoVoice() { // --------------------------------------------------------------------------- static void testStealsReleasingVoiceFirst() { - // Long per-zone release so the voice stays active through the release tail. - // Per the S12 fix, Voice::start uses sample.play.adsr — not the engine's gateAdsr — - // so the long release must live on the SampleData, not on the VoiceEngine constructor arg. + // Long per-zone release so the voice stays active through the release tail. Voice::start reads + // sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData. SampleData s = dcSample(100000, 60); s.play.adsr = flatAdsr(); s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active" Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(2, km, flatAdsr()); + VoiceEngine eng(2, km); std::size_t vA = eng.noteOn(60, 100); // startOrder 1 std::size_t vB = eng.noteOn(62, 100); // startOrder 2 @@ -415,7 +415,7 @@ static void testStealsOldestWhenNoneReleasing() { s.play.adsr = flatAdsr(); s.play.adsr.releaseFrames = 100000; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(2, km, flatAdsr()); + VoiceEngine eng(2, km); std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest) std::size_t vB = eng.noteOn(62, 100); // startOrder 2 @@ -453,7 +453,7 @@ static void testLoopSustainSeamless() { s.loop.end = 40; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio, full velocity std::vector out; @@ -475,7 +475,7 @@ static void testZeroLengthLoopGoesSilent() { s.loop.start = 25; s.loop.end = 25; // zero length Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; @@ -499,7 +499,7 @@ static void testSingleFrameLoop() { s.loop.end = 6; // single-frame loop: [5, 6) Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio, full velocity std::vector out; @@ -518,7 +518,7 @@ static void testAbsentLoopGoesSilent() { SampleData s = dcSample(50, 60); // s.loop.hasLoop stays false. Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 100); @@ -539,7 +539,7 @@ static void testStartFrameOffsetsInitialRead() { s.rootNote = 60; s.startFrame = 30; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio, full velocity, flat gain std::vector out; eng.render(out, 3); @@ -555,7 +555,7 @@ static void testStartFrameZeroIsUnchanged() { for (int i = 0; i < 20; ++i) s.frames[i] = static_cast(i) * 0.05f; s.rootNote = 60; // startFrame stays 0 Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 1); @@ -568,7 +568,7 @@ static void testStartFrameOutOfRangeClampsToZero() { SampleData s = dcSample(10, 60); // 10 frames of 1.0 s.startFrame = 10; // == frameCount: out of range Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 5); @@ -589,7 +589,7 @@ static void testStartFrameWithLoop() { s.loop.start = 20; s.loop.end = 40; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 200); @@ -618,7 +618,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() { s.loop.end = 40; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio, full velocity std::vector out; @@ -644,21 +644,21 @@ static void testVelocityToVolume() { // Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the // rendered value is exactly velocity/127 on a DC-1 sample). { - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 1); CHECK(approx(out[0], 1.0, 1e-4)); } { - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 64); std::vector out; eng.render(out, 1); CHECK(approx(out[0], 64.0 / 127.0, 1e-4)); } { - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 1); std::vector out; eng.render(out, 1); @@ -669,7 +669,7 @@ static void testVelocityToVolume() { // Two voices summed: polyphony mixes additively. static void testPolyphonyMixesAdditively() { Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(60, 127); // gain 1.0 eng.noteOn(60, 127); // gain 1.0 (second voice, same note) std::vector out; @@ -705,7 +705,7 @@ static void testStereoRenderKeepsChannelsDistinct() { // A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each // scaled by velocity (full here). If the engine copied L to both channels the R check fails. Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector left(8, 0.f), right(8, 0.f); @@ -720,7 +720,7 @@ static void testMonoSamplePlaysDualMonoInStereo() { // A MONO sample rendered through the stereo path plays dual-mono: both channels equal // (centered), not silent on the right. The cross-mode "mono source in stereo mode" case. Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0 - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector left(8, 0.f), right(8, 0.f); eng.render(left.data(), right.data(), 8); @@ -734,7 +734,7 @@ static void testMonoRenderUnchangedByStereoData() { // Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical // whether or not a second channel is present. A stereo sample rendered mono == its L channel. Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60)); - VoiceEngine engS(1, kmS, flatAdsr()); + VoiceEngine engS(1, kmS); engS.noteOn(60, 127); std::vector mono; engS.render(mono, 8); // the mono overload @@ -759,7 +759,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() { } s.rootNote = 60; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(72, 127); // +1 octave std::vector left(frames / 2, 0.f), right(frames / 2, 0.f); eng.render(left.data(), right.data(), frames / 2); @@ -770,7 +770,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() { static void testStereoRenderSumsVoicesPerChannel() { // Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo). Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(60, 127); eng.noteOn(60, 127); // second voice, same note std::vector left(1, 0.f), right(1, 0.f); @@ -781,7 +781,7 @@ static void testStereoRenderSumsVoicesPerChannel() { static void testStereoRenderNullBufferIsNoOp() { Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector buf(4, 0.f); eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash @@ -810,7 +810,7 @@ static void testStereoStartFrameLoopShareOneReadHead() { s.loop.end = 30; // loop [20,30): frames 20..29 CHECK(s.channelCount() == 2); Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio, full velocity, flat gain std::vector left(200, 0.f), right(200, 0.f); @@ -908,7 +908,7 @@ static SampleData triggerSample(std::size_t frames, double lengthFraction, static void testTriggerLengthFractionFrames() { // 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees. Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio std::vector out; eng.render(out, 200); @@ -922,7 +922,7 @@ static void testTriggerLengthFractionFrames() { static void testTriggerLengthWithStart() { // 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free. Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 200); @@ -936,7 +936,7 @@ static void testTriggerFadeShape() { // 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity // between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1. Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 120); @@ -956,7 +956,7 @@ static void testTriggerEdgeCases() { // %=0: zero play length -> voice frees at once, no sound. { Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 50); @@ -967,7 +967,7 @@ static void testTriggerEdgeCases() { { // 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped. Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 50); @@ -977,7 +977,7 @@ static void testTriggerEdgeCases() { // %=100 plays the full post-start span. { Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 80); @@ -989,7 +989,7 @@ static void testTriggerEdgeCases() { // --- Trigger ignores note-off (S15): the one-shot plays through regardless. --- static void testTriggerIgnoresNoteOff() { Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 10); @@ -1039,7 +1039,7 @@ static void testPreserveDurationInvariance() { auto lengthAt = [&](int note) -> std::size_t { Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0)); - VoiceEngine eng(1, km, flatAdsr(), /*preserveCap=*/0, /*window=*/static_cast(window)); + VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast(window)); eng.noteOn(note, 127); return soundingLength(eng, 4000); }; @@ -1066,7 +1066,7 @@ static void testVarispeedStillCouplesDuration() { s.play.pitchEngine = PitchEngine::Varispeed; s.play.trigger.lengthFraction = 1.0; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(note, 127); return soundingLength(eng, 4000); }; @@ -1091,7 +1091,7 @@ static void testPitchEnvOffBitIdentical() { s.play.pitchEnv.decayFrames = 500; } Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path) std::vector out; eng.render(out, n); @@ -1119,7 +1119,7 @@ static void testPitchEnvOnBendsVarispeed() { s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0 Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend std::vector out; eng.render(out, 4000); @@ -1154,7 +1154,7 @@ static void testPreserveGateStereoLoopComposes() { s.play.pitchEngine = PitchEngine::Preserve; CHECK(s.channelCount() == 2); Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr(), 0, 512); + VoiceEngine eng(1, km, 0, 512); eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held) std::vector left(2000, 0.f), right(2000, 0.f); eng.render(left.data(), right.data(), 2000); @@ -1176,23 +1176,22 @@ static void testPreserveVoiceCap() { s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) Keymap km = Keymap::singleSampleChromatic(std::move(s)); // 8 voices total, Preserve cap of 2. - VoiceEngine eng(8, km, flatAdsr(), /*preserveCap=*/2, /*window=*/256); + VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256); CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap) CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap CHECK(eng.activeVoiceCount() == 2); } -// --- S12 review fix: per-zone A/D/S/R actually reaches the voice envelope. --- +// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). --- // -// Before the fix, Voice::start used the instrument-wide gateAdsr for A/D/S/R and only -// folded the per-zone holdFrames. These two tests assert the corrected path. +// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at +// keymap build); the engine holds no instrument-wide ADSR. These two tests assert that path. -// The zone's attackFrames drives the envelope ramp — NOT the VoiceEngine's gateAdsr. -// Strategy: give the VoiceEngine a FLAT gateAdsr (instant attack) but put an explicit -// 10-frame attack on the SampleData.play.adsr. If Voice::start reads the zone ADSR, the -// DC-1 output will be 0 at frame 0 and 1.0 after the 10-frame ramp. If it instead used -// gateAdsr (flat = instant), frame 0 would already be 1.0. This is the load-bearing proof. +// The zone's attackFrames drives the envelope ramp. Strategy: put an explicit 10-frame attack on +// the SampleData.play.adsr. If Voice::start reads the zone ADSR, the DC-1 output will be 0 at frame +// 0 and 1.0 after the 10-frame ramp; a voice that ignored the zone ADSR (instant) would already be +// 1.0 at frame 0. This is the load-bearing proof. static void testPerZoneAdsrReachesVoiceEnvelope() { SampleData s = dcSample(500, 60); // Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release. @@ -1203,11 +1202,11 @@ static void testPerZoneAdsrReachesVoiceEnvelope() { s.play.adsr.releaseFrames = 0; s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); // instrument-wide gateAdsr = flat (instant attack) + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0 std::vector out; eng.render(out, 20); - // Frame 0: attack start, envelope near 0. If gateAdsr (flat) were used, this would be 1.0. + // Frame 0: attack start, envelope near 0. A voice ignoring the zone ADSR would read 1.0 here. CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp // Frame 9: still ramping (last attack frame, linear ramp reaches 0.9). CHECK(out[9] < 1.0 - 1e-9); @@ -1226,7 +1225,7 @@ static void testZeroAdsrIsInstantSustain() { s.play.adsr = AdsrParams{}; s.play.pitchEngine = PitchEngine::Varispeed; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 5); From 082c9b82c2b17bb69860c09109603d7636dd21e7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 03:07:52 -0400 Subject: [PATCH 45/49] s12: thread project rate through v3 legacy lift; remove 44100 literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kLegacyV3NominalRate removed. readZonesPayload, deserializePerformance, deserializeComponentState now take a projectRate for v3 frames→seconds. Rate fields default 0 (invalid). Four 44100 fallbacks replaced with assert+safe-return. 96k v3-lift test added. --- src/vst/reasampler_processor.cpp | 6 +- src/vst/reasampler_processor.h | 5 +- src/vst/sample_map.cpp | 59 +++++++++----- src/vst/sample_map.h | 30 ++++--- src/vst/sampler_core.h | 6 +- tests/test_sample_map.cpp | 130 ++++++++++++++++++------------- 6 files changed, 144 insertions(+), 92 deletions(-) diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index c9baef0..c14f7ce 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -176,7 +176,11 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 // silent empty state (no first-sample fallback in reloadFromBank). - const ComponentState cs = deserializeComponentState(bytes); + // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at + // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing + // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a + // pre-setup call would assert inside readZonesPayload (a programming error, not a field case). + const ComponentState cs = deserializeComponentState(bytes, sampleRate_); setSelectedSampleId(cs.selectionId); setPerformanceMap(cs.map); // S8: restore the last-consumed assignment generation so a re-open does not re-apply a diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index b9c4299..fa7ff2e 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -251,8 +251,9 @@ private: std::int64_t lastSeenBankGeneration_ = -1; // Latched from setupProcessing so setActive/reload can size against it. Read - // off-thread only. - double sampleRate_ = 44100.0; + // off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate + // before any audio, and reloadFromBank guards on it before use. + double sampleRate_ = 0.0; Steinberg::int32 maxBlockSize_ = 4096; // --- S6 embedded TCP/MCP UI --------------------------------------------- diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index c80fee5..760332e 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -4,6 +4,7 @@ #include "sample_map.h" #include // std::min +#include // assert #include // std::memcpy #include // std::move @@ -118,8 +119,10 @@ std::vector extractChannel(const std::vector& interlea DecodedZonePcm decodeChannels(const std::vector& interleaved, int sourceChannels, ChannelMode mode, int sampleRate) { + assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)"); DecodedZonePcm out; - out.sampleRate = sampleRate > 0 ? sampleRate : 44100; + if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate + out.sampleRate = sampleRate; if (mode == ChannelMode::Mono) { // MONO mode: the existing downmix policy (average all source channels), one channel out. out.monoFrames = downmixToMono(interleaved, sourceChannels); @@ -137,7 +140,8 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) { // seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R, // pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry // through untouched — they are already source frames / fractions. Non-time fields pass as-is. - const double sr = sampleRate > 0 ? static_cast(sampleRate) : 44100.0; + assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); + const double sr = sampleRate > 0 ? static_cast(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first const auto secToFrames = [sr](double sec) { double f = sec * sr; if (f < 0.0) f = 0.0; @@ -162,6 +166,7 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) { Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector framesR, const ZonePlaySeconds& play) { + assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)"); SampleData data; data.frames = std::move(frames); // A second channel only counts when it length-matches channel 0 (else the sample stays @@ -169,7 +174,8 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, if (!framesR.empty() && framesR.size() == data.frames.size()) { data.framesR = std::move(framesR); } - data.sampleRate = sampleRate > 0 ? sampleRate : 44100; + if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first + data.sampleRate = sampleRate; data.rootNote = rootNote; data.loop = loop; // Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate. @@ -238,7 +244,10 @@ Keymap buildZonedKeymap(const std::vector& zones, decoded[i].framesR.size() == data.frames.size()) { data.framesR = decoded[i].framesR; } - data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100; + assert(decoded[i].sampleRate > 0 && + "buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)"); + if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first + data.sampleRate = decoded[i].sampleRate; data.rootNote = zones[i].rootNote; data.loop = zones[i].loop; data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) @@ -400,7 +409,10 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) // loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — // clean back-compat lift, the overrides simply default absent). A truncated mid-zone read // keeps the zones that parsed cleanly and drops the rest. -void readZonesPayload(ByteReader& r, PerformanceMap& map) { +// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame +// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames / +// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed. +void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { bool extended = false; // v2+: the S11 loop/start tail is present std::uint32_t pv = 0; // payload version (0 = v1, no marker) if (r.peekU32() == kZonesFormatMarker) { @@ -435,18 +447,20 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { } if (legacyV3Play) { // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D) - // were written as 44.1k-nominal frames -> divide by kLegacyV3NominalRate to reach the - // seconds domain. Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are - // ABSENT in v3 -> leave the seconds defaults on z.play.adsr (0.003 / 0 / 1.0 / 0.060). + // were written as frames -> divide by the project sample rate (threaded in as `projectRate`) + // to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is. + // A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr. + assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift"); + const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; - z.play.adsr.holdSeconds = static_cast(r.i64()) / kLegacyV3NominalRate; + z.play.adsr.holdSeconds = static_cast(r.i64()) / liftRate; z.play.trigger.lengthFraction = bitsToDouble(r.u64()); z.play.trigger.fadeInFrames = r.i64(); z.play.trigger.fadeOutFrames = r.i64(); z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; z.play.pitchEnv.enabled = (r.u8() != 0); - z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / kLegacyV3NominalRate; - z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / kLegacyV3NominalRate; + z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / liftRate; + z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / liftRate; z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); } else if (secondsPlay) { // Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source @@ -482,7 +496,11 @@ std::vector serializePerformance(const PerformanceMap& map) { return out; } -PerformanceMap deserializePerformance(const std::vector& bytes) { +PerformanceMap deserializePerformance(const std::vector& bytes, + double projectRate) { + // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 + // blob is encountered with an invalid rate — the calller guarantees a real rate before use. PerformanceMap map; ByteReader r(bytes); const std::uint32_t version = r.u32(); @@ -503,7 +521,7 @@ PerformanceMap deserializePerformance(const std::vector& bytes) { } if (version != kPerformanceStateVersion) return map; // unknown -> empty - readZonesPayload(r, map); + readZonesPayload(r, map, projectRate); return map; } @@ -526,7 +544,10 @@ std::vector serializeComponentState(const ComponentState& state) { return out; } -ComponentState deserializeComponentState(const std::vector& bytes) { +ComponentState deserializeComponentState(const std::vector& bytes, + double projectRate) { + // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. See readZonesPayload for the guard. ComponentState out; ByteReader r(bytes); const std::uint32_t version = r.u32(); @@ -549,8 +570,8 @@ ComponentState deserializeComponentState(const std::vector& bytes) return out; } if (version == kPerformanceStateVersion) { - readZonesPayload(r, out.map); // v2 body starts right after the version tag - return out; // channelMode stays Mono (pre-S7) + readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag + return out; // channelMode stays Mono (pre-S7) } // BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO — // the id length + id + zones body starts right after the version tag (no mode byte). @@ -558,7 +579,7 @@ ComponentState deserializeComponentState(const std::vector& bytes) const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map); + readZonesPayload(r, out.map, projectRate); return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9) } // BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker): @@ -571,7 +592,7 @@ ComponentState deserializeComponentState(const std::vector& bytes) const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map); + readZonesPayload(r, out.map, projectRate); return out; // marker stays 0 (pre-S8/S9 reader) } if (version != kComponentStateVersion) return out; // unknown -> empty @@ -587,7 +608,7 @@ ComponentState deserializeComponentState(const std::vector& bytes) const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map); + readZonesPayload(r, out.map, projectRate); return out; } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 810999b..2bd5d79 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -260,7 +260,8 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // map). Empty zones in -> empty Keymap (silence). struct DecodedZonePcm { std::vector monoFrames; // channel 0 (mono, or L of a stereo decode) - int sampleRate = 44100; + int sampleRate = 0; // 0 is explicitly invalid; every consumer must + // receive the WAV's real rate before use. std::vector framesR; // channel 1 (R); EMPTY for a mono decode }; Keymap buildZonedKeymap(const std::vector& zones, @@ -323,10 +324,10 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved // instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. // LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS -// written by the S15/S16 editor as 44.1k-nominal frames (that build's slider domain was fixed at -// 44100), so they convert to the seconds domain by dividing by that authoring-time nominal rate -// (kLegacyV3NominalRate). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R -// are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060), no rate. +// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds +// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed +// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. +// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). // * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), // THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full // per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): @@ -364,18 +365,20 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; -// The authoring-time nominal rate the LEGACY v3 zone payload's wall-clock frame counts (hold, -// pitchEnv A/D) were always written at (the S15/S16 editor's slider domain was fixed at 44100 Hz). -// Used ONLY at the v3 read boundary to convert those legacy frames to the seconds domain — it is a -// property of the frozen v3 wire format, not a live program rate. No other site may reference it. -inline constexpr double kLegacyV3NominalRate = 44100.0; +// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are +// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a +// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build +// already receives, so the seconds domain is consistent across both paths. No constant is baked in. // The performance map serialized to bytes for IBStream (getState). std::vector serializePerformance(const PerformanceMap& map); // The performance map parsed back from IBStream bytes (setState). A v2 blob parses // directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map. -PerformanceMap deserializePerformance(const std::vector& bytes); +// `projectRate` is the live host/project sample rate (must be > 0) used to convert the +// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. +PerformanceMap deserializePerformance(const std::vector& bytes, + double projectRate); // --- Combined component state (VST3 setState/getState, v3 — S10) ------------- // @@ -432,7 +435,10 @@ std::vector serializeComponentState(const ComponentState& state); // The full instance state parsed back from IBStream bytes (setState). Tolerant of // truncation/wrong-version (bounded reads, never throws); older blobs lift per the table // above so already-saved instances restore cleanly. -ComponentState deserializeComponentState(const std::vector& bytes); +// `projectRate` is the live host/project sample rate (must be > 0) used to convert the +// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. +ComponentState deserializeComponentState(const std::vector& bytes, + double projectRate); // --- Instance state (VST3 setState/getState) -------------------------------- // diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 98f826b..3027f78 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -145,8 +145,10 @@ struct SampleLoop { struct SampleData { std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample - int sampleRate = 44100; // frames per second (for reference; ratio is - // note-relative, so rate cancels for repitch) + int sampleRate = 0; // frames per second (for reference; ratio is + // note-relative, so rate cancels for repitch). + // 0 is explicitly invalid — every consumer must + // receive a real rate before use. int rootNote = 60; // MIDI note recorded at (plays at unity here) SampleLoop loop; // sustain loop, if any // Initial read position (frame offset) a voice starts playback at — frame 0 by diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 73609a2..f5e28fb 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -262,12 +262,6 @@ static void testBuildKeymapSingleFullZone() { 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) -------------------------------------- @@ -279,6 +273,7 @@ static void testSelectionStateRoundTrip() { CHECK(deserializeSelection(bytes) == id); } + static void testSelectionStateEmptyId() { const std::vector bytes = serializeSelection(""); CHECK(bytes.size() == 4); // just the version tag @@ -603,7 +598,7 @@ static void testPerformanceStateRoundTrip() { m.zones.push_back(zone("kick", 36, 47)); // no override m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override const std::vector bytes = serializePerformance(m); - const PerformanceMap back = deserializePerformance(bytes); + const PerformanceMap back = deserializePerformance(bytes, 44100.0); CHECK(back.zones.size() == 2); CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick"); CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47); @@ -617,7 +612,7 @@ static void testPerformanceStateEmpty() { const std::vector bytes = serializePerformance(PerformanceMap{}); // Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4). CHECK(bytes.size() == 16); - CHECK(deserializePerformance(bytes).zones.empty()); + CHECK(deserializePerformance(bytes, 44100.0).zones.empty()); } static void testPerformanceStateLoopStartRoundTrip() { @@ -630,7 +625,7 @@ static void testPerformanceStateLoopStartRoundTrip() { m.zones.push_back(z); // A second zone with NO overrides proves the optional tail is per-record. m.zones.push_back(zone("kick", 0, 23)); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 2); CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() && *back.zones[0].rootOverride == 64); @@ -662,7 +657,7 @@ static void testPerformanceStateV1PayloadBackCompat() { u32(10); // lowNote u32(40); // highNote b.push_back(0); // hasRootOverride = 0 (record ends here in v1) - const PerformanceMap back = deserializePerformance(b); + const PerformanceMap back = deserializePerformance(b, 44100.0); CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy"); CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40); CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value()); @@ -679,7 +674,7 @@ static void testComponentStateLoopStartRoundTrip() { z.loopOverride = lp; z.startPoint = 128; s.map.zones.push_back(z); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId == "pick"); CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() && back.map.zones[0].loopOverride->start == 500 && @@ -691,25 +686,25 @@ static void testComponentStateLoopStartRoundTrip() { static void testPerformanceStateV1BackCompat() { // A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone. const std::vector v1 = serializeSelection("legacy-sample-id"); - const PerformanceMap back = deserializePerformance(v1); + const PerformanceMap back = deserializePerformance(v1, 44100.0); CHECK(back.zones.size() == 1); CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id"); CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127); CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value()); // A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection"). - CHECK(deserializePerformance(serializeSelection("")).zones.empty()); + CHECK(deserializePerformance(serializeSelection(""), 44100.0).zones.empty()); } static void testPerformanceStateGarbage() { // Unknown version / truncated / empty -> empty map (never throws). - CHECK(deserializePerformance({}).zones.empty()); - CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}).zones.empty()); // unknown version + CHECK(deserializePerformance({}, 44100.0).zones.empty()); + CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}, 44100.0).zones.empty()); // unknown version // Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty. std::vector t; t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2 t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1 // (no zone payload) - CHECK(deserializePerformance(t).zones.empty()); + CHECK(deserializePerformance(t, 44100.0).zones.empty()); } static void testPerformanceStateNegativeNotesRoundTrip() { @@ -717,7 +712,7 @@ static void testPerformanceStateNegativeNotesRoundTrip() { // a hand-set/legacy value round-trips without corruption (two's-complement on the wire). PerformanceMap m; m.zones.push_back(zone("s", 0, 127, /*override=*/0)); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() && *back.zones[0].rootOverride == 0); } @@ -730,7 +725,7 @@ static void testComponentStateRoundTrip() { s.selectionId = "picked-capture"; s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt)); s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48)); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId == "picked-capture"); CHECK(back.map.zones.size() == 2); CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && @@ -744,7 +739,7 @@ static void testComponentStateSelectionOnlyNoZones() { // (NOT synthesize a zone) — the default face is one capture, zones are opt-in. ComponentState s; s.selectionId = "just-a-pick"; - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId == "just-a-pick"); CHECK(back.map.zones.empty()); } @@ -752,7 +747,7 @@ static void testComponentStateSelectionOnlyNoZones() { static void testComponentStateEmptyIsEmpty() { // No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample. const ComponentState s; // selectionId "", empty map - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId.empty()); CHECK(back.map.zones.empty()); } @@ -761,13 +756,13 @@ static void testComponentStateV1BackCompat() { // A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick // survives as BOTH the selection and a one-zone map. const std::vector v1 = serializeSelection("legacy-id"); - const ComponentState back = deserializeComponentState(v1); + const ComponentState back = deserializeComponentState(v1, 44100.0); CHECK(back.selectionId == "legacy-id"); CHECK(back.map.zones.size() == 1); CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" && back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127); // A v1 blob with an EMPTY id -> empty state (no selection, no zone). - const ComponentState empty = deserializeComponentState(serializeSelection("")); + const ComponentState empty = deserializeComponentState(serializeSelection(""), 44100.0); CHECK(empty.selectionId.empty() && empty.map.zones.empty()); } @@ -777,7 +772,7 @@ static void testComponentStateV2BackCompat() { PerformanceMap m; m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt)); const std::vector v2 = serializePerformance(m); - const ComponentState back = deserializeComponentState(v2); + const ComponentState back = deserializeComponentState(v2, 44100.0); CHECK(back.selectionId.empty()); CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" && back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24); @@ -785,17 +780,17 @@ static void testComponentStateV2BackCompat() { static void testComponentStateGarbage() { // Empty / unknown version -> empty (never throws across the host). - CHECK(deserializeComponentState({}).selectionId.empty()); - CHECK(deserializeComponentState({}).map.zones.empty()); + CHECK(deserializeComponentState({}, 44100.0).selectionId.empty()); + CHECK(deserializeComponentState({}, 44100.0).map.zones.empty()); const std::vector unknown{0xAA, 0xBB, 0xCC, 0xDD}; - CHECK(deserializeComponentState(unknown).map.zones.empty()); - CHECK(deserializeComponentState(unknown).selectionId.empty()); + CHECK(deserializeComponentState(unknown, 44100.0).map.zones.empty()); + CHECK(deserializeComponentState(unknown, 44100.0).selectionId.empty()); // A v3 header claiming a longer id than the blob holds -> empty (bounded read). std::vector t; t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0); // version 3 t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0); // id length 200 (absent) - CHECK(deserializeComponentState(t).selectionId.empty()); - CHECK(deserializeComponentState(t).map.zones.empty()); + CHECK(deserializeComponentState(t, 44100.0).selectionId.empty()); + CHECK(deserializeComponentState(t, 44100.0).map.zones.empty()); } // --- S7: extractChannel / decodeChannels (cross-mode channel policy) ---------- @@ -887,7 +882,7 @@ static void testComponentStateV4RoundTripStereo() { s.selectionId = "pick"; s.channelMode = ChannelMode::Stereo; s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId == "pick"); CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); @@ -897,14 +892,14 @@ static void testComponentStateV4RoundTripMono() { ComponentState s; s.selectionId = "pick"; s.channelMode = ChannelMode::Mono; - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId == "pick"); CHECK(back.channelMode == ChannelMode::Mono); } static void testComponentStateV4DefaultIsMono() { // A default-constructed state serializes with mono and restores mono (preserves behavior). - const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{})); + const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); CHECK(back.channelMode == ChannelMode::Mono); CHECK(back.selectionId.empty() && back.map.zones.empty()); } @@ -918,7 +913,7 @@ static void testComponentStateV3LiftsToMono() { v3.push_back(static_cast(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.insert(v3.end(), id.begin(), id.end()); v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v3); + const ComponentState back = deserializeComponentState(v3, 44100.0); CHECK(back.selectionId == "legacy"); CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default CHECK(back.map.zones.empty()); @@ -926,17 +921,17 @@ static void testComponentStateV3LiftsToMono() { static void testComponentStateV1V2LiftToMono() { // The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read. - const ComponentState v1 = deserializeComponentState(serializeSelection("old")); + const ComponentState v1 = deserializeComponentState(serializeSelection("old"), 44100.0); CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old"); PerformanceMap m; m.zones.push_back(zone("s", 12, 24)); - const ComponentState v2 = deserializeComponentState(serializePerformance(m)); + const ComponentState v2 = deserializeComponentState(serializePerformance(m), 44100.0); CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1); } static void testComponentStateV4TruncatedModeByte() { // A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds. std::vector t{4, 0, 0, 0}; // version 4, nothing after - const ComponentState back = deserializeComponentState(t); + const ComponentState back = deserializeComponentState(t, 44100.0); CHECK(back.channelMode == ChannelMode::Mono); CHECK(back.selectionId.empty() && back.map.zones.empty()); } @@ -959,7 +954,7 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() { s.map.zones.push_back(z0); s.map.zones.push_back(z1); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.channelMode == ChannelMode::Stereo); // envelope field survives CHECK(back.selectionId == "pick"); CHECK(back.map.zones.size() == 2); @@ -989,7 +984,7 @@ static void testComponentStateV5MarkerRoundTrip() { s.channelMode = ChannelMode::Stereo; s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives CHECK(back.channelMode == ChannelMode::Stereo); CHECK(back.selectionId == "pick"); @@ -999,7 +994,7 @@ static void testComponentStateV5MarkerRoundTrip() { static void testComponentStateDefaultMarkerIsZero() { // A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first // assign (generation >= 1) must not be swallowed by a non-zero default. - const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{})); + const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); CHECK(back.lastConsumedAssignGeneration == 0); } @@ -1015,7 +1010,7 @@ static void testComponentStateV4LiftsMarkerToZero() { v4.push_back(static_cast(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.insert(v4.end(), id.begin(), id.end()); v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v4); + const ComponentState back = deserializeComponentState(v4, 44100.0); CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0 CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored CHECK(back.selectionId == "saved"); @@ -1026,7 +1021,7 @@ static void testComponentStateV5TruncatedMarker() { // A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty, // mono + marker 0 default holds (bounded read, never throws across the host). std::vector t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes - const ComponentState back = deserializeComponentState(t); + const ComponentState back = deserializeComponentState(t, 44100.0); CHECK(back.lastConsumedAssignGeneration == 0); CHECK(back.selectionId.empty() && back.map.zones.empty()); } @@ -1059,7 +1054,7 @@ static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() { z.play.pitchEnv.peakSemitones = 12.5; s.map.zones.push_back(z); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker CHECK(back.selectionId == "pick"); @@ -1133,7 +1128,7 @@ static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() { const std::vector payload = handBuildV3PayloadOneZone("zv3"); v4.insert(v4.end(), payload.begin(), payload.end()); - const ComponentState back = deserializeComponentState(v4); + const ComponentState back = deserializeComponentState(v4, 44100.0); CHECK(back.lastConsumedAssignGeneration == 0); // (b) no marker in v4 -> default 0 CHECK(back.channelMode == ChannelMode::Stereo); // v4 envelope mode honored CHECK(back.selectionId == "s15saved"); @@ -1143,8 +1138,8 @@ static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() { CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70); const ZonePlaySeconds& p = back.map.zones[0].play; CHECK(p.playMode == PlayMode::Trigger); // (c) play params survive the v4 envelope - // Legacy v3 wall-clock frames (44.1k-nominal) convert to seconds at the v3 authoring rate. - CHECK(approx(p.adsr.holdSeconds, 2048.0 / kLegacyV3NominalRate)); + // Legacy v3 wall-clock frames convert to seconds at the passed project rate (44100.0 here). + CHECK(approx(p.adsr.holdSeconds, 2048.0 / 44100.0)); CHECK(p.trigger.lengthFraction == 0.5); CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is CHECK(p.pitchEngine == PitchEngine::Varispeed); @@ -1169,7 +1164,7 @@ static void testPlayParamsRoundTrip() { z.play.pitchEnv.decaySeconds = 0.011; z.play.pitchEnv.peakSemitones = -7.5; m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; const ZonePlaySeconds& p = back.zones[0].play; @@ -1196,7 +1191,7 @@ static void testPlayParamsComposeWithLoopStart() { z.play.adsr.holdSeconds = 0.0225; z.play.pitchEngine = PitchEngine::Preserve; m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; CHECK(back.zones[0].loopOverride.has_value() && @@ -1227,7 +1222,7 @@ static void testPlayParamsV2BackCompatLiftsToDefaults() { b.push_back(0); // hasRootOverride = 0 b.push_back(0); // hasLoopOverride = 0 b.push_back(0); // hasStartPoint = 0 (record ends here in v2) - const PerformanceMap back = deserializePerformance(b); + const PerformanceMap back = deserializePerformance(b, 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; CHECK(back.zones[0].sampleId == "old"); @@ -1249,7 +1244,7 @@ static void testPlayParamsThroughComponentEnvelope() { z.play.trigger.lengthFraction = 0.9; z.play.pitchEngine = PitchEngine::Varispeed; s.map.zones.push_back(z); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.channelMode == ChannelMode::Stereo); CHECK(back.map.zones.size() == 1); if (back.map.zones.size() != 1) return; @@ -1276,7 +1271,7 @@ static void testFullAdsrSecondsRoundTrip() { z.play.adsr.releaseSeconds = 0.2; z.play.pitchEngine = PitchEngine::Preserve; m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; const AdsrSeconds& a = back.zones[0].play.adsr; @@ -1300,18 +1295,41 @@ static void testV3BlobLiftsAdsrToSecondsDefaults() { u32(kPerformanceStateVersion); // envelope version 2 header const std::vector payload = handBuildV3PayloadOneZone("old"); blob.insert(blob.end(), payload.begin(), payload.end()); - const PerformanceMap back = deserializePerformance(blob); + const PerformanceMap back = deserializePerformance(blob, 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; const AdsrSeconds& a = back.zones[0].play.adsr; - // hold converts from the v3 record's 44.1k-nominal frames; A/D/S/R lift to the seconds defaults. - CHECK(approx(a.holdSeconds, 2048.0 / kLegacyV3NominalRate)); // from the hand-built v3 record + // hold converts from the v3 record's frames at the passed project rate (44100.0 here). + CHECK(approx(a.holdSeconds, 2048.0 / 44100.0)); // from the hand-built v3 record CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds) CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0 CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0 CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060 } +// A legacy PAYLOAD v3 blob decoded at 96k: the hold frame count (2048) converts using the +// PASSED project rate, not a baked 44100 constant. At 96000 the seconds value is 2048/96000. +static void testV3BlobLiftsAdsrAt96k() { + std::vector blob; + auto u32 = [&](std::uint32_t v) { + blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); + blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF); + }; + u32(kPerformanceStateVersion); // envelope version 2 header + const std::vector payload = handBuildV3PayloadOneZone("old96k"); + blob.insert(blob.end(), payload.begin(), payload.end()); + const PerformanceMap back = deserializePerformance(blob, 96000.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const AdsrSeconds& a = back.zones[0].play.adsr; + // 2048 frames at 96000 Hz -> 2048/96000 seconds (not 2048/44100). + CHECK(approx(a.holdSeconds, 2048.0 / 96000.0)); + CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); + CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); + CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); + CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); +} + // The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone // resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate)); // an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates. @@ -1324,7 +1342,7 @@ static void testKeymapBuildResolvesSecondsToFramesAtEachRate() { { PerformanceMap m; m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds) - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) continue; ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60; @@ -1348,7 +1366,7 @@ static void testKeymapBuildResolvesSecondsToFramesAtEachRate() { z.play.adsr.sustainLevel = 0.7; z.play.adsr.releaseSeconds = 0.2; m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) continue; ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60; @@ -1400,7 +1418,6 @@ int main() { testDownmixThreeChannelAverages(); testDownmixDegenerate(); testBuildKeymapSingleFullZone(); - testBuildKeymapRateDefault(); testSelectionStateRoundTrip(); testSelectionStateEmptyId(); testSelectionStateWrongVersion(); @@ -1433,6 +1450,7 @@ int main() { testPlayParamsThroughComponentEnvelope(); testFullAdsrSecondsRoundTrip(); testV3BlobLiftsAdsrToSecondsDefaults(); + testV3BlobLiftsAdsrAt96k(); testKeymapBuildResolvesSecondsToFramesAtEachRate(); testBuildTier0KeymapResolvesSecondsAt48k(); testComponentStateRoundTrip(); From b2a45335634c367ab502f0600f1632893145684b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 03:18:10 -0400 Subject: [PATCH 46/49] fix(comments): correct stale v3 "CURRENT" payload prose to v5 in sample_map --- src/vst/sample_map.cpp | 12 +++++++----- src/vst/sample_map.h | 15 ++++++++------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 760332e..5f07b99 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -353,11 +353,13 @@ struct ByteReader { } }; -// Append the zones payload — the shared body of the v2 performance blob and the v3 component -// blob, so both write zones identically. Always emits PAYLOAD v2 (the S11 self-describing -// marker + version + EXTENDED records): the marker precedes the zone count so any reader can -// detect the record shape independently of the envelope version (see sample_map.h). The S11 -// loop/start overrides therefore round-trip through EITHER envelope with no envelope bump. +// Append the zones payload — the shared body of the performance blob and the component blob, +// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion +// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail +// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes +// the zone count so any reader can detect the record shape independently of the envelope version +// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip +// through EITHER envelope with no envelope bump. void putZonesPayload(std::vector& out, const PerformanceMap& map) { putU32le(out, kZonesFormatMarker); putU32le(out, kZonesPayloadVersion); diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 2bd5d79..fac8cf4 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -355,13 +355,14 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, inline constexpr std::uint32_t kPerformanceStateVersion = 2; -// The zones-payload format version and its detection marker (S11/S15/S16). serializePerformance -// and serializeComponentState both emit the CURRENT payload version (v3 — marker + version + -// records with the S11 loop/start tail AND the S15/S16 play-params tail) so the overrides -// round-trip through EITHER envelope. Readers accept a v1 payload (no marker) and a v2 payload -// (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults. -// The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in -// practice, always tiny) can never collide with. +// The zones-payload format version and its detection marker (S11/S15/S16/S12). serializePerformance +// and serializeComponentState both emit the CURRENT payload version (v5 — marker + version + +// records with the S11 loop/start tail AND the full play-params tail with wall-clock times in +// SECONDS) so the overrides round-trip through EITHER envelope. Readers accept a v1 payload (no +// marker), a v2 payload (marker + version 2, no play tail), and a v3 payload (legacy S15/S16 +// play tail with wall-clock frame counts) for back-compat, lifting missing fields to defaults. +// v4 was never shipped and is not read. The marker is a high sentinel that a legitimate zone +// count (bounded by 128 MIDI zones in practice, always tiny) can never collide with. inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; From 26e2bf2fc6293f18a24278806b0da76a0375662f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 03:52:26 -0400 Subject: [PATCH 47/49] S17 drop-and-load onto track FX button; S13 editor drop-accept (relay degraded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S17: drag_out InstrumentDrop gesture + instrument_drop blob reusing the instrument's own serializer; bank_panel FX hover-track + add-VST/vst_chunk inject. S13 relay deferred (read-only bridge) — editor shows drop affordance. --- CMakeLists.txt | 24 ++++++- PLAN.md | 73 ++++++++++++++++--- src/bank_panel.cpp | 126 +++++++++++++++++++++++++-------- src/drag_out.cpp | 8 ++- src/drag_out.h | 40 ++++++++--- src/instrument_drop.cpp | 108 ++++++++++++++++++++++++++++ src/instrument_drop.h | 65 +++++++++++++++++ src/instrument_drop_win.cpp | 83 ++++++++++++++++++++++ src/instrument_drop_win.h | 56 +++++++++++++++ src/vst/reasampler_editor.cpp | 59 ++++++++++++++- src/vst/reasampler_editor.h | 18 +++++ tests/test_drag_out.cpp | 54 ++++++++++++++ tests/test_instrument_drop.cpp | 119 +++++++++++++++++++++++++++++++ 13 files changed, 779 insertions(+), 54 deletions(-) create mode 100644 src/instrument_drop.cpp create mode 100644 src/instrument_drop.h create mode 100644 src/instrument_drop_win.cpp create mode 100644 src/instrument_drop_win.h create mode 100644 tests/test_instrument_drop.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c036e60..351fbc8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -346,6 +346,20 @@ target_include_directories(action_buttons PUBLIC src) add_library(drag_out STATIC src/drag_out.cpp) target_include_directories(drag_out PUBLIC src) +# --------------------------------------------------------------------------- +# 2l') Pure instrument_drop library — NO REAPER, NO SWELL, NO VST3 SDK. The S17 +# drop-and-load blob-construction core: turn the dragged capture id into the +# base64 "vst_chunk" the extension injects via TrackFX_SetNamedConfigParm so a +# freshly-added ReaSampler 9000 plays that capture. Reuses the instrument's OWN +# serializer (sample_map::serializeComponentState) — NOT a parallel byte writer — +# so the cross-artifact blob contract cannot drift; links sample_map (which pulls +# bank_book/wav_trim/sampler_core transitively) and NEITHER SDK. The round-trip +# test decodes back through the instrument's own reader. Mirror of assignment_request. +# --------------------------------------------------------------------------- +add_library(instrument_drop STATIC src/instrument_drop.cpp) +target_include_directories(instrument_drop PUBLIC src src/vst) +target_link_libraries(instrument_drop PUBLIC sample_map) + # --------------------------------------------------------------------------- # 2m) Pure theme library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) palette # core of the shared drawing kit: a ROLE-based color model (bg/base..warn), the @@ -590,6 +604,13 @@ add_executable(drag_out_tests tests/test_drag_out.cpp) target_link_libraries(drag_out_tests PRIVATE drag_out) add_test(NAME drag_out_tests COMMAND drag_out_tests) +# instrument_drop (S17): the drop-and-load vst_chunk blob builder. The round-trip test +# decodes the base64 back through the instrument's OWN reader (deserializeComponentState) to +# prove the extension injects exactly what setState accepts — the cross-artifact contract guard. +add_executable(instrument_drop_tests tests/test_instrument_drop.cpp) +target_link_libraries(instrument_drop_tests PRIVATE instrument_drop) +add_test(NAME instrument_drop_tests COMMAND instrument_drop_tests) + add_executable(theme_tests tests/test_theme.cpp) target_link_libraries(theme_tests PRIVATE theme) add_test(NAME theme_tests COMMAND theme_tests) @@ -840,6 +861,7 @@ add_library(reaper_reasampler MODULE src/bank_book.cpp src/owned_manifest.cpp src/drag_out_win.cpp + src/instrument_drop_win.cpp src/action_bar.cpp src/footer_bar.cpp src/overflow_menu.cpp @@ -848,7 +870,7 @@ add_library(reaper_reasampler MODULE src/card_meta.cpp src/card_drag.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/PLAN.md b/PLAN.md index dea473b..c273357 100644 --- a/PLAN.md +++ b/PLAN.md @@ -634,17 +634,31 @@ a clear affordance pointing there. without it via a direct reload). **Spike — do not promise the drop-onto-editor path until the relay is proven.** -- [ ] Editor-window drop target: accept `WM_DROPFILES`/`IDropTarget` on the editor child +- [x] Editor-window drop target: accept `WM_DROPFILES`/`IDropTarget` on the editor child HWND (the same SWELL/Win32 surface `bank_panel` owns), extracting the dropped file path(s). Windows-only (D5). This is the *acceptance* half; the ingest is the extension's. + **Landed:** the editor child window calls `DragAcceptFiles(TRUE)` on attach and handles + `WM_DROPFILES` (`reasampler_editor.cpp`). Windows-only (D5). - [ ] Cross-artifact ingest relay (the S8-flagged spike): the editor hands the dropped path + this instance's identity to the extension as a **bank-ingest request** over the agreed seam (the instrument never writes the bank). **Prove the relay mechanism before promising it**; if gnarly, fall back to the S8 docked-panel drop path and mark - drop-onto-editor deferred. -- [ ] UX degrade path: when the relay is unavailable/unproven, the editor shows a clear + drop-onto-editor deferred. **SPIKE VERDICT (ps-w12, 2026-07-27): DEGRADED — relay + deferred.** The instrument's REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY + (resolves only `GetProjExtState`, never `SetProjExtState`); a relay would need (a) a new + instrument WRITE seam into ext-state and (b) an extension-side timer poller servicing a + drop-ingest inbox key with a claim/clear nonce — the SAME cross-process handshake race the + S17 spec rejected for its own alternative (A). Both the read-only-instrument boundary and + the new poller are load-bearing design calls, so the relay is deferred to a future wave and + surfaced as a decision, not crossed unilaterally. The shipped ingest gesture stays + drop-onto-docked-panel (S8). The drop-onto-editor path degrades cleanly (below). +- [x] UX degrade path: when the relay is unavailable/unproven, the editor shows a clear "drop files on the ReaSampler panel to add" affordance rather than silently swallowing the - drop — the shipped ingest gesture stays discoverable either way. + drop — the shipped ingest gesture stays discoverable either way. **Landed:** the editor + ACCEPTS the drop and flashes a transient banner ("drop files onto the ReaSampler bank panel + to add them") that decays over a few sync ticks, plus a persistent affordance line in the + empty state ("drop a file onto the ReaSampler bank panel"). No file is ingested; NO timeline + item is ever inserted (the hard invariant — the editor only displays guidance). ## S15 — sampling modes: Trigger vs Gate (per-sample play-mode; core + editor) **Goal:** Give each played sample a **play mode** — **Gate** (classic held note) or @@ -1020,22 +1034,41 @@ the editor). S17 is the third integration gesture: drop *onto a track's FX butto instantiate a player. Gated on the rest of Phase S; the instrument seam is a Phase S artifact, not extension-only. -- [ ] Extend the `drag_out` pure module with the third gesture: `decideGesture` (or a +- [x] Extend the `drag_out` pure module with the third gesture: `decideGesture` (or a successor) returns `InstrumentDrop` when a drag armed with a single capture is over REAPER's UI outside the panel client rect, `OsDrag` only when it has left REAPER entirely, `Internal`/`OsDrag`/`None` otherwise unchanged. Pure over (drag state + pointer + panel rect + an "over-own-UI" predicate the shell supplies). Unit-tested — - the existing `drag_out` invariants (M11) must not regress. -- [ ] Shell (extension): hover-track the pointer over REAPER's UI during the drag, + the existing `drag_out` invariants (M11) must not regress. **Landed:** `DragGesture:: + InstrumentDrop` + two defaulted `DragState` fields (`singleCapture`, `overReaperUi`, the + shell-supplied predicate). Defaults false, so an M11 caller filling only + `{dragging, hasArmedSamples}` gets byte-identical M11 behavior — the existing tests are + the non-regression proof. **OPEN QUESTION RESOLVED (multi-capture over FX button): + REJECT** — only `singleCapture` arms InstrumentDrop; a multi payload over REAPER's UI + falls through to `OsDrag` (the natural multi-file drag-out), matching the spec's Tier-0 + reject-or-first lean toward reject. +- [x] Shell (extension): hover-track the pointer over REAPER's UI during the drag, resolve the hovered track + its FX button (verify the TCP/FX-button hit surface against the SDK — see must-verify), highlight it as a drop target, and on release drive the drop. Extends the `bank_panel` drag hook alongside the M11 `drag_out_win` path. -- [ ] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:ReaSampler 9000", + **Landed:** `instrument_drop_win::resolveFxDropTarget` wraps `GetThingFromPoint` + (verified present; its info string reports `"fx_chain"`/`"fx_N"` for the FX region and a + null-track-empty-info for off-REAPER). **OPEN QUESTION RESOLVED (FX hotspot vs. whole + TCP): FX HOTSPOT** — the drop target is the FX region specifically, decided from the SDK's + own hit-test string (info prefix `"fx_"`), not a home-grown geometry guess. The + bank_panel drag hook now hover-tracks outside the client rect (holding internal-drag + capture, NOT the modal OS loop) and only hands to `drag_out_win` (OsDrag) when the pointer + has left REAPER entirely. +- [x] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:ReaSampler 9000", false, /*instantiate*/ negative)` to always add a fresh instance; capture the returned FX index; then invoke the load-capture seam to point the new instance at the dragged capture. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the - whole gesture is one Ctrl-Z (mirrors the bank-verb undo discipline). -- [ ] **ReaSampler 9000 (Phase S artifact):** add the **load-capture seam** — the entry + whole gesture is one Ctrl-Z (mirrors the bank-verb undo discipline). **Landed:** + `instrument_drop_win::performInstrumentDrop` — `TrackFX_AddByName` with the CHANNEL-correct + name (`"VST3:" + app_version::vstPluginName()`, so a beta extension drops the beta VST), + negative instantiate, then `TrackFX_SetNamedConfigParm(..., "vst_chunk", blob)`, all in one + `Undo_BeginBlock2`/`EndBlock2` block. NEVER inserts a timeline item. +- [x] **ReaSampler 9000 (Phase S artifact):** add the **load-capture seam** — the entry point that lets the just-added instance be told which capture to play via **(B) component-state injection**: the instrument's `getChunk`/`setChunk` must round-trip a component-state blob whose byte layout is the **shared cross-artifact contract** (defined @@ -1043,9 +1076,27 @@ artifact, not extension-only. via `TrackFX_SetNamedConfigParm(..., "vst_chunk", ...)`. This is the cross-artifact half; it lands in the instrument, not the extension. **Coordination dependency:** the blob format must be agreed between extension and instrument before either half is final. -- [ ] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across + **Landed (no NEW instrument code needed — the seam already exists):** the instrument's + `setState`/`getState` already round-trip the full `ComponentState` via + `sample_map::serializeComponentState`/`deserializeComponentState` (S10). The extension side + REUSES that exact serializer through the new pure `instrument_drop` module + (`buildInstrumentDropChunk` -> `serializeComponentState` -> base64) — the shared-writer + requirement is met STRUCTURALLY (one serializer, called from both artifacts), so the blob + format cannot drift. Pure round-trip test decodes back through the instrument's OWN reader + (`deserializeComponentState`) and asserts the capture is selected. **DAW-VERIFY (the one + live unknown):** whether REAPER's `vst_chunk` write-parm expects the plugin's raw + IComponent-state bytes as we write them, or wraps them in a REAPER container header — the + load-bearing caveat is only confirmable in the DAW; if REAPER wraps, the fix is to match + its container, the shared serializer stays. +- [x] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across single- and multi-capture payloads; FX-button hit resolution (pure geometry where it can be factored out); M11 OS drag-out and internal bank-to-bank drag both unchanged. + **Landed:** `test_drag_out.cpp` adds the InstrumentDrop cases (single over REAPER UI -> + InstrumentDrop; single inside -> Internal; single off-REAPER -> OsDrag; multi over UI -> + OsDrag; not-dragging short-circuits) with the M11 cases retained as the non-regression + guard; `test_instrument_drop.cpp` is the blob round-trip + base64 codec coverage. FX hit + resolution is REAPER-API-bound (`GetThingFromPoint`), so it is DAW-verified in the shell, + not pure-tested — noted honestly. ## S18 — VST3 channel isolation: a beta ReaSampler 9000 that pairs with the beta extension only **Goal:** Extend Phase V's beta/stable channel split (V4 — the *extension* is fully diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 146389f..3ddbc35 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -62,6 +62,8 @@ #include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4) #include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) #include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest +#include "instrument_drop.h" // pure buildInstrumentDropChunk — the vst_chunk blob (S17) +#include "instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17) #include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) #include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) #include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5) @@ -321,6 +323,15 @@ struct PanelState { CardGesture cardGesture = CardGesture::None; int dragTargetSlot = -1; + // --- S17 drop-and-load (InstrumentDrop) ----------------------------------- + // While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an + // InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX + // hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the + // dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last + // resolved FX-hotspot track (null when the pointer is not over an FX button) — read on + // release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched. + MediaTrack* instrumentDropTrack = nullptr; + // --- Tail-mode toggle ----------------------------------------------------- // The authoritative tail setting now lives in ReaSamplerSession (session->tail()), // NOT in panel state, so it travels inside the .rpp (persist serializes it on save, @@ -2917,16 +2928,52 @@ void onMouseMove(int x, int y) { } } if (g_panel.dragging) { - // M11 gesture boundary (invariant #4): while a drag with samples is under way, the - // moment the pointer LEAVES the panel client area the gesture becomes OS-bound — - // hand the payload to the native OS drag. Inside the client area it stays the - // existing internal bank-to-bank drag, byte-identical. The boundary decision is the - // pure drag_out::decideGesture (drag state + pointer + client rect). + // M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the + // pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant + // #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture + // splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an + // InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a + // pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays + // Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint. RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top}; - const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; - if (decideGesture(x, y, client, st) == DragGesture::OsDrag) { + const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom); + + DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; + st.singleCapture = (g_panel.dragSampleIds.size() == 1); + + // Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can + // only arise there) and only for a single-capture payload — the SDK hit-test is skipped + // on the common internal-drag path so it costs nothing there. The screen conversion is + // Windows-only (D5); resolveFxDropTarget owns the REAPER hit query. + FxDropTarget fx; + if (!inside && st.singleCapture) { + POINT sp{x, y}; + ClientToScreen(g_panel.hwnd, &sp); + fx = resolveFxDropTarget(sp.x, sp.y); + st.overReaperUi = fx.overReaperUi; + } + + const DragGesture gesture = decideGesture(x, y, client, st); + + if (gesture == DragGesture::InstrumentDrop) { + // Track the FX hotspot for the release; the highlight is REAPER's own FX-button + // hover feedback under the pointer (the drop is driven on button-up). We keep the + // internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does + // NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the + // panel does not also paint a bank-drop cue while the drag is out over a track. + g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr; + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + invalidatePanel(); + return; + } + + // Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target. + g_panel.instrumentDropTrack = nullptr; + + if (gesture == DragGesture::OsDrag) { // Resolve the payload to existing on-disk paths BEFORE tearing down internal // drag state (the resolver reads dragSourceBankId / dragSampleIds). const std::vector paths = resolveDragPathsForOs(); @@ -2995,31 +3042,45 @@ void doReplaceDrop(const std::string& newId, const std::string& oldId, // OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove. void onLBtnUp(int x, int y) { if (g_panel.dragging) { - updateDropTarget(x, y); - classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) - const CardGesture g = g_panel.cardGesture; + // S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a + // ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move, + // NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank + // drop (the pointer is out over a track, not over a bank region). Single-capture only (the + // gesture never armed for a multi payload), so dragSampleIds.front() is the capture. + if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) { + const std::string sampleId = g_panel.dragSampleIds.front(); + const std::string chunk = buildInstrumentDropChunk(sampleId); + performInstrumentDrop(g_panel.instrumentDropTrack, chunk); + // Read-only over the bank + arrange: the ONLY mutations are the new FX instance + + // its state (both undoable in performInstrumentDrop). No book change, no ext-state, + // no dirty-mark here. + } else { + updateDropTarget(x, y); + classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) + const CardGesture g = g_panel.cardGesture; - if (g == CardGesture::Reorder) { - doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId, - g_panel.dragTargetSlot); - } else if (g == CardGesture::Replace) { - // Replace targets the OCCUPANT of the target slot with the single grabbed card. - const bool isBanks = g_panel.dragSourceRegion == Region::Banks; - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); - const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot); - // Replace only makes sense for a single grabbed card over a DIFFERENT occupant. - if (!occupant.empty() && occupant != g_panel.dragPrimaryId) - doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId); - } else if (g == CardGesture::Move || g == CardGesture::Copy) { - const std::string destId = dropTargetBankId(); - if (!destId.empty() && destId != g_panel.dragSourceBankId && - !g_panel.dragSampleIds.empty()) { - transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, - /*copy=*/g == CardGesture::Copy); + if (g == CardGesture::Reorder) { + doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId, + g_panel.dragTargetSlot); + } else if (g == CardGesture::Replace) { + // Replace targets the OCCUPANT of the target slot with the single grabbed card. + const bool isBanks = g_panel.dragSourceRegion == Region::Banks; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); + const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot); + // Replace only makes sense for a single grabbed card over a DIFFERENT occupant. + if (!occupant.empty() && occupant != g_panel.dragPrimaryId) + doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId); + } else if (g == CardGesture::Move || g == CardGesture::Copy) { + const std::string destId = dropTargetBankId(); + if (!destId.empty() && destId != g_panel.dragSourceBankId && + !g_panel.dragSampleIds.empty()) { + transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, + /*copy=*/g == CardGesture::Copy); + } } } // CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None). @@ -3041,6 +3102,7 @@ void onLBtnUp(int x, int y) { g_panel.cardGesture = CardGesture::None; g_panel.dragTargetSlot = -1; g_panel.dragPrimaryId.clear(); + g_panel.instrumentDropTrack = nullptr; // S17: clear the FX hotspot after the release invalidatePanel(); } @@ -3169,6 +3231,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { g_panel.cardGesture = CardGesture::None; g_panel.dragTargetSlot = -1; g_panel.dragPrimaryId.clear(); + g_panel.instrumentDropTrack = nullptr; // S17: drop the FX hotspot on teardown g_panel.hovered = Hover{}; g_panel.tooltipShown = false; g_panel.hwnd = nullptr; @@ -3226,6 +3289,7 @@ void closePanel() { stopAudition(); g_panel.selection = Selection{}; g_panel.dragArmed = g_panel.dragging = false; + g_panel.instrumentDropTrack = nullptr; // S17: drop the FX hotspot on close unregisterAccel(); if (g_panel.hwnd) { DockWindowRemove(g_panel.hwnd); diff --git a/src/drag_out.cpp b/src/drag_out.cpp index 158ca32..db58c65 100644 --- a/src/drag_out.cpp +++ b/src/drag_out.cpp @@ -19,7 +19,13 @@ bool insideClient(int px, int py, const PanelClientRect& c) { DragGesture decideGesture(int px, int py, const PanelClientRect& client, const DragState& state) { if (!state.dragging || !state.hasArmedSamples) return DragGesture::None; - return insideClient(px, py, client) ? DragGesture::Internal : DragGesture::OsDrag; + if (insideClient(px, py, client)) return DragGesture::Internal; + // Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is + // still over REAPER's own UI is an instrument drop (heading for a track's FX button); + // anything else (a multi-capture payload, or the pointer off REAPER entirely) is the + // unchanged M11 OS drag-out. + if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop; + return DragGesture::OsDrag; } PathList assemblePathList(const std::vector& resolved) { diff --git a/src/drag_out.h b/src/drag_out.h index 3e50c1f..312b47e 100644 --- a/src/drag_out.h +++ b/src/drag_out.h @@ -52,28 +52,50 @@ struct PanelClientRect { // whether a drag is currently active (threshold crossed) and whether the armed payload // carries at least one sample. (Pre-threshold "armed but not yet dragging" is NOT a drag // for this decision — the shell only asks once a drag is under way.) +// +// S17 (drop-and-load) adds two inputs that refine the OUTSIDE-the-panel decision without +// touching the INSIDE decision (the internal bank-to-bank drag stays byte-identical): +// * singleCapture — the payload holds EXACTLY ONE sample id. Only a single-capture drag +// arms the InstrumentDrop gesture (per the S17 open-question lean: a multi-capture drag +// over an FX button is NOT an instrument drop — it falls through to OsDrag, the natural +// multi-file drag-out to Explorer/another DAW). REJECT, not load-first: the whole gesture +// is "make ONE capture a playable instrument", so a multi payload is out of contract here. +// * overReaperUi — a SHELL-SUPPLIED predicate: true when the pointer, though outside the +// panel client rect, is still over REAPER's OWN window/UI (the shell owns the REAPER +// hit query, e.g. GetThingFromPoint; the pure layer owns only the set/boundary algebra). +// Both default false, so an M11-era caller that fills only {dragging, hasArmedSamples} gets +// EXACTLY the M11 behavior: outside the client rect with overReaperUi=false -> OsDrag. struct DragState { bool dragging = false; // threshold crossed; a drag is in progress bool hasArmedSamples = false; // the drag payload holds >= 1 sample id + bool singleCapture = false; // S17: payload holds EXACTLY one sample (arms InstrumentDrop) + bool overReaperUi = false; // S17: pointer is over REAPER's own UI (shell-supplied) }; // What the shell should do with the drag given the current pointer position. enum class DragGesture { - None, // no drag under way, or an empty payload — do nothing - Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag - OsDrag, // dragging with samples, pointer left the client area — hand off to the OS + None, // no drag under way, or an empty payload — do nothing + Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag + InstrumentDrop, // S17: single-capture drag left the panel but is over REAPER's UI — + // the shell hover-tracks the TCP FX button and, on release, adds a + // ReaSampler 9000 instance preloaded with the dragged capture. + OsDrag, // dragging with samples, pointer left REAPER entirely — hand off to the OS }; // Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. // * Not dragging (or no armed samples): None — the shell ignores the move. // * Dragging with samples, pointer INSIDE the client rect: Internal — unchanged // bank-to-bank behavior (invariant #4: the internal drag stays byte-identical). -// * Dragging with samples, pointer OUTSIDE the client rect: OsDrag — the samples are -// leaving the panel; the shell initiates the native OS drag with the resolved paths. -// The boundary is the client rect edge: the internal drag never targets outside it, so -// crossing it is an unambiguous, discoverable OS-drag trigger. Re-entry is the shell's -// concern (the OS drag loop is modal once begun); this function reports OsDrag purely from -// position, so a shell that has already handed off simply will not ask again. +// * Dragging OUTSIDE the client rect, SINGLE capture, over REAPER's UI: InstrumentDrop — +// the drag is heading for a track's FX button (S17); the shell hover-tracks + highlights. +// * Dragging OUTSIDE the client rect otherwise (multi-capture, OR the pointer has left +// REAPER entirely): OsDrag — the samples are leaving to the OS; the shell initiates the +// native OS drag with the resolved paths. +// The INSIDE decision is untouched (M11 internal drag is byte-identical). The M11 boundary +// (left the client rect -> OsDrag) is REFINED, not replaced: leaving the rect now asks +// "single-capture and over REAPER's UI -> InstrumentDrop, else -> OsDrag" — so the M11 +// OS-drag-out (multi payload, or pointer off REAPER) keeps its exact behavior. Position-only +// + state-only (no hidden state), so re-entry back inside returns Internal. DragGesture decideGesture(int px, int py, const PanelClientRect& client, const DragState& state); diff --git a/src/instrument_drop.cpp b/src/instrument_drop.cpp new file mode 100644 index 0000000..bc28657 --- /dev/null +++ b/src/instrument_drop.cpp @@ -0,0 +1,108 @@ +// instrument_drop — pure implementation. See instrument_drop.h. +// NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer. + +#include "instrument_drop.h" + +#include "vst/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer) + +namespace reasampler { + +namespace { + +constexpr char kB64Alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// -1 = not a base64 char; index by unsigned byte. Built once. +int b64Value(unsigned char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} + +} // namespace + +std::vector instrumentDropStateBytes(const std::string& sampleId) { + // The ONE fact the drop carries: this capture is the instance's selection. Everything + // else stays at the fresh-instance defaults (no zones, mono, generation 0) — the same + // ComponentState a browser click would produce. serializeComponentState is the + // instrument's own writer (the single source of truth for the byte layout), so this is + // NOT a parallel encoder — it IS the instrument's encoder. + ComponentState cs; + cs.selectionId = sampleId; + return serializeComponentState(cs); +} + +std::string buildInstrumentDropChunk(const std::string& sampleId) { + return encodeBase64(instrumentDropStateBytes(sampleId)); +} + +std::string encodeBase64(const std::vector& bytes) { + std::string out; + out.reserve(((bytes.size() + 2) / 3) * 4); + std::size_t i = 0; + const std::size_t n = bytes.size(); + while (i + 3 <= n) { + const std::uint32_t triple = (static_cast(bytes[i]) << 16) | + (static_cast(bytes[i + 1]) << 8) | + static_cast(bytes[i + 2]); + out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]); + out.push_back(kB64Alphabet[triple & 0x3F]); + i += 3; + } + const std::size_t rem = n - i; + if (rem == 1) { + const std::uint32_t triple = static_cast(bytes[i]) << 16; + out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]); + out.push_back('='); + out.push_back('='); + } else if (rem == 2) { + const std::uint32_t triple = (static_cast(bytes[i]) << 16) | + (static_cast(bytes[i + 1]) << 8); + out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]); + out.push_back('='); + } + return out; +} + +std::vector decodeBase64(const std::string& b64) { + std::vector out; + if (b64.size() % 4 != 0) return out; // malformed length -> empty (never throws) + out.reserve((b64.size() / 4) * 3); + for (std::size_t i = 0; i < b64.size(); i += 4) { + const char c0 = b64[i], c1 = b64[i + 1], c2 = b64[i + 2], c3 = b64[i + 3]; + const int v0 = b64Value(static_cast(c0)); + const int v1 = b64Value(static_cast(c1)); + if (v0 < 0 || v1 < 0) return {}; // illegal char in a non-pad position -> empty + // Padding is only legal in the last two positions of the last quad. + const bool pad2 = (c2 == '='); + const bool pad3 = (c3 == '='); + if ((pad2 || pad3) && i + 4 != b64.size()) return {}; // pad before the final quad + if (pad2 && !pad3) return {}; // "=X" is malformed + std::uint32_t triple = (static_cast(v0) << 18) | + (static_cast(v1) << 12); + out.push_back(static_cast((triple >> 16) & 0xFF)); + if (!pad2) { + const int v2 = b64Value(static_cast(c2)); + if (v2 < 0) return {}; + triple |= static_cast(v2) << 6; + out.push_back(static_cast((triple >> 8) & 0xFF)); + if (!pad3) { + const int v3 = b64Value(static_cast(c3)); + if (v3 < 0) return {}; + triple |= static_cast(v3); + out.push_back(static_cast(triple & 0xFF)); + } + } + } + return out; +} + +} // namespace reasampler diff --git a/src/instrument_drop.h b/src/instrument_drop.h new file mode 100644 index 0000000..3fbddbf --- /dev/null +++ b/src/instrument_drop.h @@ -0,0 +1,65 @@ +#pragma once +// instrument_drop — the PURE blob-construction core of S17 drop-and-load. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK, +// NO vendor/ includes. Standard library only (+ the pure sample_map it reuses). Unit-tested +// outside the DAW — the same "small pure builder + round-trip proof" pattern as +// assignment_request / provenance. +// +// -- What it is (the S17 seam, extension side) -------------------------------- +// +// S17 drops a bank capture onto a track's FX button, which instantiates ReaSampler 9000 on +// that track ALREADY PLAYING that capture. The SETTLED mechanism (PLAN.md §S17, mechanism +// (B) — VST3 component-state injection) is: after TrackFX_AddByName creates the instance, the +// extension writes the instance's component state directly via +// TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", ) +// with the dragged capture PRE-SELECTED. +// +// LOAD-BEARING CAVEAT (PLAN.md §S17): "vst_chunk" is the plugin's OWN base64-encoded +// serialized chunk — the exact bytes ReaSampler 9000's setState/getState round-trips — NOT a +// neutral representation REAPER re-marshals. So the extension must construct EXACTLY the +// instrument's own state-blob bytes. This module does that WITHOUT hand-rolling a parallel +// byte writer: it calls the instrument's OWN serializer, sample_map::serializeComponentState +// (the single source of truth for the byte layout — the same function the processor's +// getState calls), then base64-encodes the result. The shared-writer requirement (both +// artifacts live in this repo → reuse the exact same code) is satisfied structurally: if the +// instrument's format changes, this module changes with it because it CALLS it. +// +// The base64 encoding is what REAPER's vst_chunk write-parm documents it accepts (see +// reaper_plugin_functions.h: "vst_chunk[_program] : base64-encoded VST-specific chunk"). + +#include +#include +#include + +namespace reasampler { + +// Build the base64 blob the extension writes to TrackFX_SetNamedConfigParm(..., "vst_chunk"). +// `sampleId` is the dragged capture's stable bank id — the ONLY thing the drop pre-selects. +// The resulting ComponentState is the instrument's default face with just this one capture +// picked: {selectionId = sampleId, no zones, mono, lastConsumedAssignGeneration = 0} — exactly +// what a fresh instance would hold after the user clicked that capture in the browser. The +// keymap builds under the product defaults (Gate + Preserve) from the bank's own S2 intrinsics, +// so the sample plays MIDI-triggered immediately (the S17 "loaded, selected, playable" verify). +// +// An EMPTY sampleId yields the empty-state blob ({"", no zones}) — a drop of nothing selects +// nothing (the S10 silent empty state); the shell guards against this upstream, but the pure +// contract is defined. +// +// Deterministic: the same sampleId always yields the same blob (base64 of the same bytes). +std::string buildInstrumentDropChunk(const std::string& sampleId); + +// The raw (pre-base64) component-state bytes — exposed so the round-trip test can decode them +// back through the instrument's OWN reader (sample_map::deserializeComponentState) and assert +// the capture is selected, proving buildInstrumentDropChunk feeds the instrument exactly what +// its setState expects. Not called by the shell (which uses the base64 form). +std::vector instrumentDropStateBytes(const std::string& sampleId); + +// Standard base64 encode/decode (RFC 4648, '+' '/' alphabet, '=' padding). Exposed so the +// round-trip test can decode buildInstrumentDropChunk's output. decodeBase64 returns the +// decoded bytes; on malformed input (bad length / illegal char) it returns an EMPTY vector +// (never throws) — the test asserts a clean decode, and the shell never decodes. +std::string encodeBase64(const std::vector& bytes); +std::vector decodeBase64(const std::string& b64); + +} // namespace reasampler diff --git a/src/instrument_drop_win.cpp b/src/instrument_drop_win.cpp new file mode 100644 index 0000000..63793e6 --- /dev/null +++ b/src/instrument_drop_win.cpp @@ -0,0 +1,83 @@ +// instrument_drop_win — the REAPER shell for S17 drop-and-load. See instrument_drop_win.h. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the pointers; here they are extern via the WANT list). + +#include "instrument_drop_win.h" + +#include +#include + +#include "app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing) + +#include "reaper_plugin.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetThingFromPoint +#define REAPERAPI_WANT_TrackFX_AddByName +#define REAPERAPI_WANT_TrackFX_SetNamedConfigParm +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// GetThingFromPoint's info string prefixes (verified against reaper_plugin_functions.h: +// "Updates infoOut with information such as 'arrange', 'fx_chain', 'fx_0' ... If a track +// panel is hit, string will begin with 'tcp' or 'mcp' or 'tcp.mute' etc"). The FX region +// reports "fx_chain" (the FX list area) or "fx_N" (a specific FX button). We treat either +// as the FX hotspot — the S17 drop target. +bool infoNamesFxHotspot(const char* info) { + return std::strncmp(info, "fx_", 3) == 0; +} + +} // namespace + +FxDropTarget resolveFxDropTarget(int screenX, int screenY) { + FxDropTarget out; + char info[256] = {0}; + // GetThingFromPoint returns the track under the point (may be null for a non-track thing) + // and fills `info` with what was hit. A non-empty info OR a non-null track means the point + // is over REAPER's own UI; a null track with an empty info means the pointer has left + // REAPER entirely (over another app / the desktop) — the OsDrag boundary. + MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info)); + out.track = track; + out.overReaperUi = (track != nullptr) || (info[0] != '\0'); + out.overFxHotspot = (track != nullptr) && infoNamesFxHotspot(info); + return out; +} + +bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64) { + if (!track || chunkBase64.empty()) return false; + + // The CHANNEL-correct FX name: "VST3:ReaSampler 9000" on stable, "VST3:ReaSampler 9000 + // beta" on beta. Sourcing it from app_version::vstPluginName() (the same accessor the VST + // factory display name derives from) keeps the pairing invariant intact — a beta extension + // drops the beta VST, a stable extension the stable VST — with no literal to drift. + const std::string fxName = "VST3:" + vstPluginName(); + + // One undo point for the whole gesture (mirrors the bank-verb undo discipline). Both the + // FX add and the state write are REAPER-undoable, so Ctrl-Z removes the instance cleanly. + Undo_BeginBlock2(nullptr); + + // Negative `instantiate` => always create a NEW instance (verified in the header). recFX + // = false: a normal track FX chain instance, not a record/monitoring FX. + const int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false, + /*instantiate=*/-1); + bool ok = false; + if (fxIndex >= 0) { + // Inject the instrument's OWN component-state blob (the dragged capture pre-selected) + // via the documented vst_chunk write-parm. The blob was built by the shared writer + // (instrument_drop::buildInstrumentDropChunk -> sample_map::serializeComponentState), + // so these bytes are exactly what ReaSampler 9000's setState accepts. + ok = TrackFX_SetNamedConfigParm(track, fxIndex, "vst_chunk", chunkBase64.c_str()); + } + + // The undo label reflects the placement-of-the-player framing (not a capture, not an insert). + Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1); + return ok; +} + +} // namespace reasampler diff --git a/src/instrument_drop_win.h b/src/instrument_drop_win.h new file mode 100644 index 0000000..ee40975 --- /dev/null +++ b/src/instrument_drop_win.h @@ -0,0 +1,56 @@ +#pragma once +// instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture +// decision lives in drag_out (DragGesture::InstrumentDrop) and the pure blob construction in +// instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track + +// its TCP FX-button hotspot via REAPER's hit-test API, and (b) on release adds a ReaSampler +// 9000 instance to that track and injects the dragged capture as its component state. +// +// Compiled into the reaper_reasampler MODULE. REAPER-facing (GetThingFromPoint, TrackFX_*, +// Undo_*), so DAW-verified, not unit-tested; the pure decision + blob it drives are CTest'd. +// +// LOAD-BEARING (CONTEXT.md §Drop-and-load): this is an EXPLICIT user placement-of-the-player +// gesture — it adds a READER of the bank on a track and points it at one already-captured +// sample. It NEVER captures, NEVER writes the bank, and NEVER inserts a timeline item. The +// only writes are: a new FX instance on the target track + that instance's own component +// state — both REAPER-undoable, wrapped in one undo block so the whole gesture is one Ctrl-Z. + +#include + +// Opaque REAPER track handle at the boundary so includers don't need the SDK. The SDK +// declares it as a class (reaper_plugin.h) — match that spelling so the mangled name agrees. +class MediaTrack; + +namespace reasampler { + +// The result of hit-testing a screen point during a live InstrumentDrop drag. +struct FxDropTarget { + MediaTrack* track = nullptr; // the track under the pointer (null if none / not a track) + bool overReaperUi = false; // the point is over REAPER's own window/UI at all + bool overFxHotspot = false; // specifically over this track's TCP FX-button/-chain region + + // A valid drop target: a resolved track whose FX hotspot is under the pointer. + bool valid() const { return track != nullptr && overFxHotspot; } +}; + +// Hit-test a screen point (REAPER screen coords) to an FX drop target. Wraps +// GetThingFromPoint, whose info string tells us what was hit ("tcp"/"mcp" for a track panel, +// "fx_chain"/"fx_N" for the FX area/button). `overReaperUi` is the shell-supplied predicate +// the pure drag_out::decideGesture consumes (true when the point is over REAPER's own UI — +// i.e. GetThingFromPoint returned a track OR a recognizable non-track thing, false when the +// pointer has left REAPER entirely). `overFxHotspot` is true when the info string names the +// FX region specifically — the S17 "FX-button hotspot vs. whole TCP" question is resolved to +// the FX hotspot (the discoverable, unambiguous target), decided here from the SDK's own +// hit-test string rather than a home-grown geometry guess. +FxDropTarget resolveFxDropTarget(int screenX, int screenY); + +// Perform the drop on `track`: add a fresh ReaSampler 9000 instance and inject `chunkBase64` +// (the instrument_drop::buildInstrumentDropChunk output) as its component state so it plays +// the dragged capture. `chunkBase64` is the base64 vst_chunk. Wraps the add + inject in one +// REAPER undo block (mirrors the bank-verb undo discipline). Returns true on success (the FX +// was added and the chunk written), false on any failure (add returned -1, or the chunk write +// was rejected). A false return leaves at most the added FX (no partial-state confusion — the +// caller surfaces nothing; a failed add is visible by nothing happening). NEVER inserts a +// timeline item; the ONLY mutations are the FX instance + its state, both undoable. +bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64); + +} // namespace reasampler diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index ffd20c2..34ff11a 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -28,6 +28,7 @@ #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM +#include // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept #include "wdltypes.h" #include "lice/lice.h" @@ -218,6 +219,12 @@ void ReaSamplerEditor::onSyncTimer() { refreshFromBank(); invalidate(); } + + // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. + if (dropHintTicks_ > 0) { + --dropHintTicks_; + invalidate(); + } } #endif // _WIN32 @@ -522,6 +529,10 @@ void ReaSamplerEditor::attachedToParent() { r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); if (childHwnd_) { SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); + // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT + // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show + // the "drop on the panel" affordance instead of the OS bouncing the drop silently. + DragAcceptFiles(childHwnd_, TRUE); // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's // lifetime — created here, killed in removedFromParent — so an instance whose editor // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). @@ -697,6 +708,19 @@ void ReaSamplerEditor::paint(HDC hdc) { paintBrowser(&bmp, w, h); } + // S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window. + // It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn + // LAST so it overlays the mode content; decays via onSyncTimer (dropHintTicks_). + if (dropHintTicks_ > 0) { + const int bannerH = (std::min)(kTitleHeight + 8, h); + Rect banner{0, bands.toggleZones.bottom, w, bands.toggleZones.bottom + bannerH}; + LICE_FillRect(&bmp, banner.left, banner.top, banner.width(), banner.height(), + kColTabActiveBg, 1.0f, 0); + drawTextCentered(&bmp, banner, + "Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.", + kRgbText); + } + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } @@ -706,7 +730,16 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { const char* msg = samples_.empty() ? "No captures in this project yet - capture audio into the bank to play it here." : "No captures in this bank filter. Choose another bank tab above."; - drawTextCentered(bmp, area, msg, kRgbDim); + // Split the area so the primary line sits centered and the S13 ingest affordance sits just + // below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept + // discoverable here regardless of whether a drop ever lands on THIS window. + Rect primary{area.left, area.top, area.right, area.top + area.height() / 2}; + Rect hint{area.left, primary.bottom, area.right, area.bottom}; + drawTextCentered(bmp, primary, msg, kRgbDim); + drawTextCentered(bmp, + hint, + "To add a sample: drop a file onto the ReaSampler bank panel (the docked window).", + kRgbDim); } void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { @@ -1559,6 +1592,20 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) { invalidate(); } +void ReaSamplerEditor::onFilesDropped(int droppedCount) { + // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact + // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff + // decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a + // timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing + // at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs + // each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0. + (void)droppedCount; // count is informational; the banner text is drop-count-agnostic + dropHintTicks_ = 6; +#ifdef _WIN32 + invalidate(); +#endif +} + LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { auto* self = @@ -1612,6 +1659,16 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, self->invalidate(); } return 0; + case WM_DROPFILES: { + // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT + // read/ingest the paths (the instrument never ingests — the relay to the extension is + // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner. + HDROP drop = reinterpret_cast(wParam); + const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); + DragFinish(drop); + if (self) self->onFilesDropped(static_cast(count)); + return 0; + } case WM_TIMER: if (self && wParam == kSyncTimerId) self->onSyncTimer(); return 0; diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 32be032..68b57b4 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -114,6 +114,13 @@ private: void onMouseWheel(int delta); // S12 browser scroll (wheel) void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke + // S13 (relay degraded): an OS file drop landed on the editor window. We do NOT ingest (the + // instrument is a read-only bank consumer and the relay is unshipped) — we flash the "drop + // on the ReaSampler panel to add" affordance so the drop is never silently swallowed and the + // shipped ingest gesture stays discoverable. `droppedCount` is how many files were dropped + // (drawn into the banner). NEVER inserts a timeline item / never touches the bank. + void onFilesDropped(int droppedCount); + // The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the // audio thread). Polls the processor's bank-sync (generation change -> hands-free reload; // a new assignment request -> apply as this instance's selection) and, when anything @@ -212,6 +219,17 @@ private: std::string activeFilterBankId_; // "" = All; else a bank id from banks_ int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none + // --- S13 drop-to-load affordance (relay DEGRADED — transient, never persisted) ---- + // S13's cross-artifact ingest relay (editor drop -> extension ingest) is NOT shipped: the + // instrument's REAPER bridge is deliberately READ-ONLY (it never writes the bank / ext + // state), so an editor drop cannot relay a bank-ingest request without a new write seam + + // an extension-side poller (surfaced as a decision, not crossed here). The DEGRADE path per + // the spec: the editor ACCEPTS the drop (WM_DROPFILES) and, rather than silently swallowing + // it, flashes a clear affordance pointing at the shipped ingest gesture (drop onto the + // docked ReaSampler panel). When > 0, the affordance banner is shown; each sync tick decays + // it so it auto-dismisses. No file is ingested, no timeline item is ever inserted. + int dropHintTicks_ = 0; // remaining sync ticks to show the drop affordance + // --- S12 browser scroll + search (transient UI state, never persisted) -------- int scrollOffset_ = 0; // vertical px offset into the card grid (clamped) std::string searchQuery_; // type-to-filter narrow; "" = no search diff --git a/tests/test_drag_out.cpp b/tests/test_drag_out.cpp index a02af47..ac8420a 100644 --- a/tests/test_drag_out.cpp +++ b/tests/test_drag_out.cpp @@ -84,6 +84,54 @@ static void testOffsetPanelRect() { CHECK(decideGesture(100, 19, p, s) == DragGesture::OsDrag); // just above origin } +// --- S17 InstrumentDrop gesture (single-capture over REAPER UI) --------------- +// +// M11 REGRESSION GUARD (load-bearing): every M11 case above uses DragState{true, true}, +// which leaves singleCapture=overReaperUi=false — so an M11-era payload outside the client +// rect still decides OsDrag exactly as before. The tests above ARE the M11 non-regression +// proof; these add the new middle case. + +// A SINGLE-capture drag that has left the panel but is still over REAPER's own UI is an +// instrument drop (heading for a track's FX button), NOT an OS drag. +static void testSingleCaptureOverReaperUiIsInstrumentDrop() { + DragState s{/*dragging=*/true, /*hasArmedSamples=*/true, + /*singleCapture=*/true, /*overReaperUi=*/true}; + CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::InstrumentDrop); // right of panel + CHECK(decideGesture(-5, 150, kPanel, s) == DragGesture::InstrumentDrop); // left of panel + CHECK(decideGesture(200, 400, kPanel, s) == DragGesture::InstrumentDrop); // below +} + +// InstrumentDrop is an OUTSIDE-only refinement: the same single-capture state INSIDE the +// client rect is still the unchanged Internal bank-to-bank drag (invariant #4). +static void testSingleCaptureInsidePanelStaysInternal() { + DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/true}; + CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal); +} + +// A single-capture drag that has left REAPER ENTIRELY (overReaperUi=false) falls through to +// OsDrag — the M11 OS drag-out to Explorer/another DAW, unchanged. This is the boundary +// refinement's other half: leaving the client rect no longer immediately means OS-bound. +static void testSingleCaptureOffReaperIsOsDrag() { + DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/false}; + CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag); +} + +// A MULTI-capture drag over REAPER's UI is REJECTED for InstrumentDrop (the S17 open-question +// lean): it is NOT a single instrument placement, so it falls through to OsDrag even while +// over REAPER's UI — the multi-file drag-out is the natural gesture for a multi payload. +static void testMultiCaptureOverReaperUiIsOsDrag() { + DragState s{true, true, /*singleCapture=*/false, /*overReaperUi=*/true}; + CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag); +} + +// Not-dragging / no-armed-samples still short-circuits to None regardless of the S17 fields. +static void testS17FieldsIgnoredWhenNotDragging() { + CHECK(decideGesture(500, 150, kPanel, + DragState{false, true, true, true}) == DragGesture::None); + CHECK(decideGesture(500, 150, kPanel, + DragState{true, false, true, true}) == DragGesture::None); +} + // --- Path-list assembly ------------------------------------------------------- static ResolvedSample ok(const std::string& p) { return ResolvedSample{p, true}; } @@ -180,6 +228,12 @@ int main() { testReentryReturnsInternal(); testOffsetPanelRect(); + testSingleCaptureOverReaperUiIsInstrumentDrop(); + testSingleCaptureInsidePanelStaysInternal(); + testSingleCaptureOffReaperIsOsDrag(); + testMultiCaptureOverReaperUiIsOsDrag(); + testS17FieldsIgnoredWhenNotDragging(); + testSinglePath(); testMultiPreservesOrder(); testDedupeSamePath(); diff --git a/tests/test_instrument_drop.cpp b/tests/test_instrument_drop.cpp new file mode 100644 index 0000000..79bbcab --- /dev/null +++ b/tests/test_instrument_drop.cpp @@ -0,0 +1,119 @@ +// Standalone tests for reasampler::instrument_drop — no REAPER, no VST3 SDK, no framework. +// The S17 drop-and-load blob-construction contract: the extension builds a vst_chunk blob +// whose bytes are EXACTLY what ReaSampler 9000's own setState (deserializeComponentState) +// accepts, with the dragged capture pre-selected. The round-trip proof (build -> base64 +// decode -> the instrument's OWN reader -> assert the capture selected) IS the cross-artifact +// contract guard — the same pattern assignment_request_tests uses for its wire format. + +#include "../src/instrument_drop.h" +#include "../src/vst/sample_map.h" // deserializeComponentState — the instrument's OWN reader + +#include +#include +#include + +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) + +// A representative project rate for the reader (the legacy-v3 conversion parameter; our v5 +// blob never consumes it, but the reader signature requires a positive rate). +static constexpr double kRate = 48000.0; + +// THE contract test: a blob built for a capture id decodes — through the instrument's OWN +// reader — to a ComponentState with THAT id selected, no zones, default mono. If this fails, +// the extension would inject bytes the instrument's setState rejects and the drop would load +// a silent/wrong instance. +static void testBlobRoundTripsThroughInstrumentReader() { + const std::string id = "cap-7f3a-guid"; + const std::string b64 = buildInstrumentDropChunk(id); + CHECK(!b64.empty()); + + const std::vector bytes = decodeBase64(b64); + CHECK(!bytes.empty()); + // The base64 must decode to EXACTLY the pre-encode state bytes (no corruption). + CHECK(bytes == instrumentDropStateBytes(id)); + + const ComponentState cs = deserializeComponentState(bytes, kRate); + CHECK(cs.selectionId == id); // the capture IS selected — the whole point + CHECK(cs.map.zones.empty()); // a drop selects one capture, authors no zones + CHECK(cs.channelMode == ChannelMode::Mono); // fresh-instance default + CHECK(cs.lastConsumedAssignGeneration == 0); // fresh instance, no consumed assign +} + +// A GUID-shaped id with bytes that would trip a naive delimiter-based encoder round-trips +// whole (the length-prefixed component-state framing + base64 carry arbitrary bytes). +static void testGuidLikeIdRoundTrips() { + const std::string id = "{9A2F0C11-4B6E-4D01-8F3A-0011223344FF}"; + const std::vector bytes = decodeBase64(buildInstrumentDropChunk(id)); + const ComponentState cs = deserializeComponentState(bytes, kRate); + CHECK(cs.selectionId == id); +} + +// An empty id yields the empty-state blob: it still decodes cleanly to {"", no zones} — the +// S10 silent empty state. (The shell guards against dropping nothing; the pure contract holds.) +static void testEmptyIdYieldsEmptyState() { + const std::vector bytes = decodeBase64(buildInstrumentDropChunk("")); + CHECK(!bytes.empty()); // still a versioned envelope, just an empty selection + const ComponentState cs = deserializeComponentState(bytes, kRate); + CHECK(cs.selectionId.empty()); + CHECK(cs.map.zones.empty()); +} + +// Deterministic: the same id always produces the same blob (no time/random in the path). +static void testDeterministic() { + CHECK(buildInstrumentDropChunk("abc") == buildInstrumentDropChunk("abc")); + CHECK(buildInstrumentDropChunk("abc") != buildInstrumentDropChunk("abd")); +} + +// --- base64 codec unit coverage (the encode side the shell actually ships) ----- + +static std::vector b(std::initializer_list v) { + std::vector out; + for (int x : v) out.push_back(static_cast(x)); + return out; +} + +// Known RFC-4648 vectors, incl. every padding case (0/1/2 trailing bytes). +static void testBase64KnownVectors() { + CHECK(encodeBase64(b({})) == ""); + CHECK(encodeBase64(b({'f'})) == "Zg=="); + CHECK(encodeBase64(b({'f', 'o'})) == "Zm8="); + CHECK(encodeBase64(b({'f', 'o', 'o'})) == "Zm9v"); + CHECK(encodeBase64(b({'f', 'o', 'o', 'b'})) == "Zm9vYg=="); + CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a'})) == "Zm9vYmE="); + CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a', 'r'})) == "Zm9vYmFy"); +} + +// encode -> decode is identity across every residue class + all-byte values. +static void testBase64RoundTripAllBytes() { + for (int len = 0; len <= 300; ++len) { + std::vector in; + for (int i = 0; i < len; ++i) in.push_back(static_cast((i * 37 + 11) & 0xFF)); + CHECK(decodeBase64(encodeBase64(in)) == in); + } +} + +// Malformed decode inputs return empty (never throw / never UB): bad length, illegal char, +// misplaced padding. +static void testBase64DecodeRejectsMalformed() { + CHECK(decodeBase64("Zg=").empty()); // length not a multiple of 4 + CHECK(decodeBase64("Zm9v!ba=").empty()); // illegal char '!' + CHECK(decodeBase64("Z===").empty()); // illegal char in v1 position + CHECK(decodeBase64("Zg==Zg==").empty()); // interior padding (pad before the final quad) +} + +int main() { + testBlobRoundTripsThroughInstrumentReader(); + testGuidLikeIdRoundTrips(); + testEmptyIdYieldsEmptyState(); + testDeterministic(); + testBase64KnownVectors(); + testBase64RoundTripAllBytes(); + testBase64DecodeRejectsMalformed(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} From 3440ddb2687dff7c5a46ca744e02d5e423508ba4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 03:58:05 -0400 Subject: [PATCH 48/49] fix(instrument_drop_win): make performInstrumentDrop all-or-nothing On chunk-write failure, call TrackFX_Delete(track, fxIndex) to remove the just-added empty FX instance before returning false. Signature verified at reaper_plugin_functions.h:7236. Update the header comment from "leaves at most the added FX" to the new all-or-nothing contract. --- src/instrument_drop_win.cpp | 7 +++++++ src/instrument_drop_win.h | 9 +++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/instrument_drop_win.cpp b/src/instrument_drop_win.cpp index 63793e6..a0245c1 100644 --- a/src/instrument_drop_win.cpp +++ b/src/instrument_drop_win.cpp @@ -15,6 +15,7 @@ #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_GetThingFromPoint #define REAPERAPI_WANT_TrackFX_AddByName +#define REAPERAPI_WANT_TrackFX_Delete #define REAPERAPI_WANT_TrackFX_SetNamedConfigParm #define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_EndBlock2 @@ -73,6 +74,12 @@ bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64) { // (instrument_drop::buildInstrumentDropChunk -> sample_map::serializeComponentState), // so these bytes are exactly what ReaSampler 9000's setState accepts. ok = TrackFX_SetNamedConfigParm(track, fxIndex, "vst_chunk", chunkBase64.c_str()); + if (!ok) { + // All-or-nothing: if the chunk write fails, remove the empty FX instance we just + // added so the track is left exactly as it was. TrackFX_Delete signature (verified + // in reaper_plugin_functions.h:7236): bool TrackFX_Delete(MediaTrack*, int fx). + TrackFX_Delete(track, fxIndex); + } } // The undo label reflects the placement-of-the-player framing (not a capture, not an insert). diff --git a/src/instrument_drop_win.h b/src/instrument_drop_win.h index ee40975..fba0cce 100644 --- a/src/instrument_drop_win.h +++ b/src/instrument_drop_win.h @@ -47,10 +47,11 @@ FxDropTarget resolveFxDropTarget(int screenX, int screenY); // (the instrument_drop::buildInstrumentDropChunk output) as its component state so it plays // the dragged capture. `chunkBase64` is the base64 vst_chunk. Wraps the add + inject in one // REAPER undo block (mirrors the bank-verb undo discipline). Returns true on success (the FX -// was added and the chunk written), false on any failure (add returned -1, or the chunk write -// was rejected). A false return leaves at most the added FX (no partial-state confusion — the -// caller surfaces nothing; a failed add is visible by nothing happening). NEVER inserts a -// timeline item; the ONLY mutations are the FX instance + its state, both undoable. +// was added and the chunk written), false on any failure. All-or-nothing: if the chunk write +// fails after a successful add, the freshly-added FX instance is removed via TrackFX_Delete +// before returning false, leaving the track exactly as it was (no orphaned empty-state FX). +// NEVER inserts a timeline item; the ONLY mutations are the FX instance + its state, both +// undoable. bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64); } // namespace reasampler From 6a014114c2bb4eb94f1e8c0109ed4698434f629e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 04:46:30 -0400 Subject: [PATCH 49/49] refactor(bank_panel): factor resetDragState() helper; fix WM_CAPTURECHANGED missing instrumentDropTrack reset All eight drag-state fields cleared through one canonical helper. WM_CAPTURECHANGED now resets instrumentDropTrack (was missing per doc-contract). WM_DESTROY and closePanel gain dropKind/dropBankId resets (inert on teardown; correct for symmetry). --- src/bank_panel.cpp | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 3ddbc35..dc39592 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -3035,6 +3035,20 @@ void doReplaceDrop(const std::string& newId, const std::string& oldId, invalidatePanel(); } +// Clears all drag-state fields to their resting values. Called from every exit path +// (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields +// stays consistent across all four sites. +void resetDragState() { + g_panel.dragArmed = false; + g_panel.dragging = false; + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + g_panel.dragPrimaryId.clear(); + g_panel.instrumentDropTrack = nullptr; +} + // Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides: // * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each. // * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy). @@ -3095,14 +3109,7 @@ void onLBtnUp(int x, int y) { if (focus >= 0) g_panel.selection = applyClick(g_panel.selection, focus, false, false, count); } - g_panel.dragArmed = false; - g_panel.dragging = false; - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - g_panel.cardGesture = CardGesture::None; - g_panel.dragTargetSlot = -1; - g_panel.dragPrimaryId.clear(); - g_panel.instrumentDropTrack = nullptr; // S17: clear the FX hotspot after the release + resetDragState(); invalidatePanel(); } @@ -3199,13 +3206,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { // state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is // mutated on a cancel; the cursor is restored to the arrow. if (g_panel.dragArmed || g_panel.dragging) { - g_panel.dragArmed = false; - g_panel.dragging = false; - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - g_panel.cardGesture = CardGesture::None; - g_panel.dragTargetSlot = -1; - g_panel.dragPrimaryId.clear(); + resetDragState(); SetCursor(LoadCursor(nullptr, IDC_ARROW)); invalidatePanel(); } @@ -3227,11 +3228,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (GetCapture() == hwnd) ReleaseCapture(); stopAudition(); g_panel.selection = Selection{}; - g_panel.dragArmed = g_panel.dragging = false; - g_panel.cardGesture = CardGesture::None; - g_panel.dragTargetSlot = -1; - g_panel.dragPrimaryId.clear(); - g_panel.instrumentDropTrack = nullptr; // S17: drop the FX hotspot on teardown + resetDragState(); g_panel.hovered = Hover{}; g_panel.tooltipShown = false; g_panel.hwnd = nullptr; @@ -3288,8 +3285,7 @@ void closePanel() { if (GetCapture() == g_panel.hwnd) ReleaseCapture(); stopAudition(); g_panel.selection = Selection{}; - g_panel.dragArmed = g_panel.dragging = false; - g_panel.instrumentDropTrack = nullptr; // S17: drop the FX hotspot on close + resetDragState(); unregisterAccel(); if (g_panel.hwnd) { DockWindowRemove(g_panel.hwnd);