Merge phase-s: Phase S complete — ReaSampler 9000 VST3 sampler (S1-S18), editor scale + control surfaces (S12), drop-and-load (S17), editor drop-accept (S13 relay degraded per spec)

This commit is contained in:
2026-07-27 04:46:45 -04:00
86 changed files with 17909 additions and 188 deletions
+3
View File
@@ -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
+1
View File
@@ -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)
+355 -1
View File
@@ -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}')")
@@ -301,6 +305,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 ->
@@ -329,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
@@ -446,6 +477,31 @@ 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.
# ---------------------------------------------------------------------------
# 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 <windows.h> (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 pitch_shift)
# ---------------------------------------------------------------------------
# 3) Standalone tests for the pure modules (run without launching REAPER).
# ---------------------------------------------------------------------------
@@ -548,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)
@@ -584,6 +647,176 @@ 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).
# 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 <windows.h> 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)
# ---------------------------------------------------------------------------
# 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)
# 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
# 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)
# 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)
# 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)
# 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)
# 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)
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.
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)
# 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)
# 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)
# 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).
# ---------------------------------------------------------------------------
@@ -624,9 +857,11 @@ 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
src/instrument_drop_win.cpp
src/action_bar.cpp
src/footer_bar.cpp
src/overflow_menu.cpp
@@ -635,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)
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'
@@ -671,3 +906,122 @@ 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
# ===========================================================================
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
# 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 $<IF:$<CONFIG:Debug>,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/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).
${VST3_SDK}/public.sdk/source/main/dllmain.cpp
${VST3_SDK}/public.sdk/source/main/moduleinit.cpp
${LICE_SRC}
)
# 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, ...).
# 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.
# 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).
# 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 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})
# A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk
# 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_VST_OUTPUT_NAME}")
endif()
+792 -1
View File
@@ -1289,6 +1289,524 @@ 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`.
## 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.
**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**
(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 engine modes — Varispeed vs Preserve (S16)
**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^((noteroot)/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.
- **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^((noteroot + 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 — corrected finding (feeds S16, not a committed point)
**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 **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 310 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 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
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
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.
## Design-system foundation — moved to Phase L (2026-07-26)
> **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 **S10S13 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 (S10S13; DAW-tested S1S6, "the UX is awful")
**The bar is set: better than ReaSamplOMatic5000.** Daniel DAW-tested the S1S6
instrument and the verdict was that it *works* but the UX is unacceptable — "this is
supposed to be better than ReaSamplOMatic5000." The S1S6 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 **S10S13**, 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
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 **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
`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 — 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
**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.** **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
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 — 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
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 S1S6 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 **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).
- **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)
- **VST3 SDK (a new vendored dependency — vendor it at the spike).** `FUnknown` and the
@@ -1307,9 +1825,238 @@ 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.
- **WDL pitch/resample (S15/S16).** **Verified this pass:** `vendor/WDL/WDL/resample.h`
(`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.
- **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 (S10S13) 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)
**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*/ <negative>)`. **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.
**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", <base64 blob>)`.
**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
"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).**
- **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).
**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 (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", <base64 blob>)`. 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.
## 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
@@ -1319,6 +2066,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 +2083,44 @@ 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 01's build shape.
optional-forever. Do not let their feature lists drive Tier 01's build shape. **Note:**
S7 stereo is *not* a Tier-2 feature — it is a channel-count dimension on the existing
Tier 01 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.) **Likewise S15/S16** (Trigger/Gate modes + pitch
envelope) are Daniel-directed engine features on the Tier 01 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 → 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
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.
+1045 -38
View File
File diff suppressed because it is too large Load Diff
+413 -6
View File
@@ -7,7 +7,28 @@ 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 (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. 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
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
S1S6 instrument: the product name **ReaSampler 9000** and the **"better than RS5K" UX
overhaul** (Phase S points S10S13) — 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
@@ -609,6 +630,339 @@ 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 (S7S9), 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.
### Addendum — product name + UX overhaul (Daniel, 2026-07-26, post-S1S6 DAW test)
Daniel DAW-tested the S1S6 instrument and set two directives. These are **settled
directions**, specced as new Phase S points (S10S13) 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 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 S1S6 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
(S10S13) 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.
### 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.
### 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
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.
### 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.
### 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
@@ -626,11 +980,64 @@ 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 (S1S6, 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
framing/decision record they point back to. The "no PLAN.md footprint" era is over.
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.
Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Addendum in §4):
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"** (**S10S13**; 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
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`). **S10S13 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-S1S6 DAW test).
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 (S1S6 the original
dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10S13** the ReaSampler
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-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.
---
+18 -5
View File
@@ -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);
+9 -1
View File
@@ -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
+18
View File
@@ -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;
}
+27
View File
@@ -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)
+147
View File
@@ -0,0 +1,147 @@
// assignment_request.cpp — see assignment_request.h. Pure: standard library only.
#include "assignment_request.h"
#include <cstddef>
#include <limits>
namespace reasampler {
namespace {
constexpr const char* kMagic = "rsassign1";
// Append one length-prefixed field: <decimal-len> ':' <bytes>. 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, 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();
const std::size_t digit = static_cast<std::size_t>(c - '0');
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
if (len > (std::numeric_limits<std::size_t>::max() - digit) / 10u)
return fail();
len = len * 10u + digit;
}
const std::size_t start = colon + 1;
// 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, 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;
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 "-"
}
// 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();
const std::int64_t digit = static_cast<std::int64_t>(c - '0');
// Overflow guard: v * 10 + digit must not exceed INT64_MAX.
if (v > (std::numeric_limits<std::int64_t>::max() - digit) / 10)
return fail();
v = v * 10 + digit;
}
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<AssignmentRequest> 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
+89
View File
@@ -0,0 +1,89 @@
#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 <cstdint>
#include <optional>
#include <string>
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" <len>':'<bankId> <len>':'<sampleId> <len>':'<generation-decimal>
// where each <len> 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.
//
// 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<AssignmentRequest> decodeAssignmentRequest(const std::string& wire);
} // namespace reasampler
+56 -1
View File
@@ -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 {
+28
View File
@@ -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<std::string> 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<int> rootNote;
std::optional<LoopPoints> loop;
Levels levels;
bool clipped = false;
+157 -54
View File
@@ -61,6 +61,9 @@
#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 "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)
@@ -82,6 +85,7 @@
#ifdef _WIN32
#include <windows.h>
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest
#else
#include <pthread.h>
#endif
@@ -319,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,
@@ -2057,7 +2070,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;
@@ -2069,7 +2085,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();
}
@@ -2113,7 +2129,7 @@ void transferSamples(const std::vector<std::string>& 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{};
@@ -2138,7 +2154,7 @@ void removeSamples(const std::vector<std::string>& 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{};
@@ -2912,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<std::string> paths = resolveDragPathsForOs();
@@ -2983,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).
@@ -2990,31 +3056,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).
@@ -3029,13 +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();
resetDragState();
invalidatePanel();
}
@@ -3075,8 +3149,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<std::string> 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<char> buf(static_cast<std::size_t>(len) + 1, '\0');
DragQueryFile(hDrop, i, buf.data(), static_cast<UINT>(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<HDROP>(wParam));
return 0;
case WM_PAINT: {
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
@@ -3105,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();
}
@@ -3133,10 +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();
resetDragState();
g_panel.hovered = Hover{};
g_panel.tooltipShown = false;
g_panel.hwnd = nullptr;
@@ -3172,6 +3264,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();
@@ -3182,7 +3285,7 @@ void closePanel() {
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
stopAudition();
g_panel.selection = Selection{};
g_panel.dragArmed = g_panel.dragging = false;
resetDragState();
unregisterAccel();
if (g_panel.hwnd) {
DockWindowRemove(g_panel.hwnd);
+5
View File
@@ -530,6 +530,11 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
}
}
s.createdTimestamp = static_cast<std::int64_t>(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;
+10
View File
@@ -5,6 +5,7 @@
#include <cstdint>
#include <cstdio>
#include <cstring> // std::memcmp
#include <filesystem>
#include <vector>
namespace reasampler {
@@ -213,6 +214,15 @@ std::string resolveBankFile(const std::string& projectDir,
return dir + "/" + rel;
}
std::string projectDirOfRpp(const std::string& rppPath) {
// An unsaved project reports an empty .rpp path; keep it empty so downstream
// resolution refuses (no default-location fallback). Mirrors persist.cpp's prior
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
if (rppPath.empty()) return {};
std::string dir = std::filesystem::path(rppPath).parent_path().string();
return normalizeSlashes(dir);
}
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir) {
BankRelocation r;
+8
View File
@@ -121,6 +121,14 @@ std::string bankRelativeForName(const std::string& fileName);
std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath);
// The project directory that holds a .rpp: its parent directory, forward-slashed,
// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty
// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the
// no-default-location invariant). This is the M4 convention persist uses to place
// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves
// audio paths the SAME way persist does rather than re-implementing the derivation.
std::string projectDirOfRpp(const std::string& rppPath);
// A relocation plan for the physical bank folder on Save-As to a new project
// location. The index's relative paths do NOT change (they are relative to the
// project dir, which is what moved with the .rpp), so relocation is purely a
+7 -1
View File
@@ -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<ResolvedSample>& resolved) {
+31 -9
View File
@@ -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);
+72
View File
@@ -0,0 +1,72 @@
#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. 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. 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
// 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";
// 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
// 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
+596
View File
@@ -0,0 +1,596 @@
// 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 <cstdint>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#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 "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
#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
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#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. 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;
// --- 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<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(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 read the source and validate/hash the bank copy.
std::vector<std::uint8_t> 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<std::uint8_t> bytes(static_cast<std::size_t>(n));
f.seekg(0);
f.read(reinterpret_cast<char*>(bytes.data()), n);
if (!f) return {};
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<std::uint8_t>& bytes) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(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<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
std::size_t frameCount,
const std::vector<ReaSample>& interleaved) {
const std::size_t sampleCount = frameCount * static_cast<std::size_t>(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<std::uint32_t>(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data
std::vector<std::uint8_t> out;
out.reserve(44u + dataBytesCount);
auto putU16 = [&](std::uint16_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
};
auto putU32 = [&](std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
};
auto putTag = [&](const char* t) {
for (int i = 0; i < 4; ++i)
out.push_back(static_cast<std::uint8_t>(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<std::uint16_t>(nch));
putU32(rate);
putU32(rate * static_cast<std::uint32_t>(nch) * 4u); // avgBytesPerSec
putU16(static_cast<std::uint16_t>(nch * 4)); // blockAlign
putU16(32u); // bitsPerSample
// data chunk
putTag("data");
putU32(static_cast<std::uint32_t>(dataBytesCount));
for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
putF32(static_cast<float>(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<ReaSample> 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<std::size_t>(lengthSeconds * sampleRate + 0.5);
if (totalFrames == 0) return {};
std::vector<ReaSample> out;
out.reserve(totalFrames * static_cast<std::size_t>(nch));
// Pull samples in blocks of ~4096 frames; loop until source is exhausted.
constexpr int kBlockFrames = 4096;
std::vector<ReaSample> block(static_cast<std::size_t>(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<std::size_t>(t.samples_out) *
static_cast<std::size_t>(nch);
out.insert(out.end(), block.data(), block.data() + got);
t.time_s += static_cast<double>(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
};
// 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.
//
// 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.
//
// 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) {
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;
}
// Read source bytes; needed to check whether it is already a 32f WAV.
const std::vector<std::uint8_t> srcBytes = readFileBytes(absoluteSourcePath);
if (srcBytes.empty()) {
out.message = "file is empty or unreadable: " + absoluteSourcePath;
return out;
}
// 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<std::uint8_t> 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<ReaSample> decoded;
if (srcHandle && channelCount > 0 && sampleRate > 0 && lengthSeconds > 0.0) {
decoded = decodePcmSource(srcHandle, channelCount,
static_cast<double>(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<std::size_t>(channelCount > 0 ? channelCount : 1);
bankBytes = buildFloat32Wav(channelCount,
static_cast<std::uint32_t>(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();
// 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;
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. 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::int64_t>(std::time(nullptr));
const std::string uniqueTag = std::to_string(nowSec);
const BankPaths paths = deriveBankPaths(projectDir, sourceStem, uniqueTag);
// 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;
if (!writeFileBytes(destPath, bankBytes)) {
out.message = "could not write converted file to the bank folder";
return out;
}
// 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: 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 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 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 = (isFloat32Wav ? "imported -> " : "converted + imported -> ") +
paths.relativePath;
break;
case AddResult::Collapsed: {
// 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{};
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<char> 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<char> extra(256, '\0'); // documented unused; sized generously to be safe
const bool ok = MediaExplorerGetLastPlayedFileInfo(
nameBuf.data(), static_cast<int>(nameBuf.size()), &filemode, &selStart, &selEnd,
&pitch, &vol, &rate, &srcbpm, extra.data(), static_cast<int>(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 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);
// 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);
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());
}
} // 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::int64_t>(std::time(nullptr));
g_session->writeAssignmentRequest(encodeAssignmentRequest(req));
}
// --- Drop-onto-panel ingest --------------------------------------------------
void ingestDroppedFiles(const std::vector<std::string>& 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). 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()) {
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);
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) +
(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
+77
View File
@@ -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 <string>
#include <vector>
// 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<std::string>& absolutePaths);
} // namespace reasampler
+108
View File
@@ -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<std::uint8_t> 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<std::uint8_t>& 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<std::uint32_t>(bytes[i]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
static_cast<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(bytes[i]) << 16) |
(static_cast<std::uint32_t>(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<std::uint8_t> decodeBase64(const std::string& b64) {
std::vector<std::uint8_t> 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<unsigned char>(c0));
const int v1 = b64Value(static_cast<unsigned char>(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<std::uint32_t>(v0) << 18) |
(static_cast<std::uint32_t>(v1) << 12);
out.push_back(static_cast<std::uint8_t>((triple >> 16) & 0xFF));
if (!pad2) {
const int v2 = b64Value(static_cast<unsigned char>(c2));
if (v2 < 0) return {};
triple |= static_cast<std::uint32_t>(v2) << 6;
out.push_back(static_cast<std::uint8_t>((triple >> 8) & 0xFF));
if (!pad3) {
const int v3 = b64Value(static_cast<unsigned char>(c3));
if (v3 < 0) return {};
triple |= static_cast<std::uint32_t>(v3);
out.push_back(static_cast<std::uint8_t>(triple & 0xFF));
}
}
}
return out;
}
} // namespace reasampler
+65
View File
@@ -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", <base64 blob>)
// 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 <cstdint>
#include <string>
#include <vector>
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<std::uint8_t> 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<std::uint8_t>& bytes);
std::vector<std::uint8_t> decodeBase64(const std::string& b64);
} // namespace reasampler
+90
View File
@@ -0,0 +1,90 @@
// 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 <cstring>
#include <string>
#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_Delete
#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());
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).
Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1);
return ok;
}
} // namespace reasampler
+57
View File
@@ -0,0 +1,57 @@
#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 <string>
// 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. 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
+142 -9
View File
@@ -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
@@ -205,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
@@ -738,6 +752,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 +799,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 +826,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 +846,64 @@ 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.
// 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
// 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).
//
// 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())
{
// 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");
}
// --- M11: batch capture (per selected item / per razor area) ----------------
@@ -961,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());
}
@@ -1078,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());
}
@@ -1263,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);
}
@@ -1422,6 +1521,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 +1540,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 +1557,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 +1571,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 +1584,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 +1624,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 +1644,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 +1736,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 +1872,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);
+42 -4
View File
@@ -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
@@ -128,11 +129,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
@@ -252,6 +253,33 @@ bool ReaSamplerSession::saveToActiveProject() {
SetProjExtState(static_cast<ReaProject*>(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<ReaProject*>(proj), projExtNamespace(),
kProjExtBankGenKey,
vst::formatBankGeneration(bankGeneration_).c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
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<ReaProject*>(proj), projExtNamespace(),
kProjExtAssignKey, wire.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
return true;
}
@@ -563,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<ReaProject*>(proj), projExtNamespace(),
kProjExtBankGenKey)
: std::string{});
if (!proj) {
book_ = BankBook{};
return;
+80 -58
View File
@@ -17,11 +17,13 @@
// 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 <cstdint>
#include <string>
#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 +31,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
@@ -169,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.
@@ -227,6 +226,21 @@ public:
// shown the confirm; this method does NOT prompt.
PruneDeletionResult pruneReclaim(const std::vector<std::string>& 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.
@@ -287,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
+3
View File
@@ -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;
}
+70
View File
@@ -0,0 +1,70 @@
// bank_sync.cpp — see bank_sync.h. Pure; standard library only.
#include "bank_sync.h"
#include <cstdint>
#include <limits>
#include <string>
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<std::int64_t>::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<AssignmentRequest>& 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
+105
View File
@@ -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 <cstdint>
#include <optional>
#include <string>
#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<AssignmentRequest>& request,
std::int64_t lastConsumed, bool resolves,
bool isFocusedTarget);
} // namespace reasampler::vst
+16
View File
@@ -0,0 +1,16 @@
// bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types.
#include "bridge_marshal.h"
namespace reasampler::vst {
std::optional<std::string> 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;
}
} // namespace reasampler::vst
+37
View File
@@ -0,0 +1,37 @@
// 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 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).
#pragma once
#include <optional>
#include <string>
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<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer);
} // namespace reasampler::vst
+158
View File
@@ -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 <algorithm>
#include <cctype>
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<char>(std::tolower(static_cast<unsigned char>(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<int>(static_cast<long long>(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<int>(
static_cast<long long>(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<int>(static_cast<long long>(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<long long>(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) /
trackSpan;
const long long proposed = static_cast<long long>(startOffset) + deltaOffset;
if (proposed < 0) return 0;
if (proposed > maxOff) return maxOff;
return static_cast<int>(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<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query) {
std::vector<int> out;
out.reserve(names.size());
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
if (nameMatchesQuery(names[static_cast<std::size_t>(i)], query))
out.push_back(i);
}
return out;
}
} // namespace reasampler::vst
+107
View File
@@ -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 <string>
#include <vector>
#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<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query);
} // namespace reasampler::vst
+96
View File
@@ -0,0 +1,96 @@
// capture_browser.cpp — see capture_browser.h. Pure math; no host types.
#include "capture_browser.h"
#include <algorithm>
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
+92
View File
@@ -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
+164
View File
@@ -0,0 +1,164 @@
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
#include "editor_geometry.h"
#include <algorithm>
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;
}
Rect sampleRowRect(const EditorLayout& layout, int index) {
if (index < 0) return Rect{};
const int top = layout.canvas.top + index * kSampleRowHeight;
return Rect{layout.canvas.left, top, layout.canvas.right, top + kSampleRowHeight};
}
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
if (rowCount <= 0) return -1;
// Must be within the canvas horizontally and at/below its top.
if (x < layout.canvas.left || x >= layout.canvas.right) return -1;
if (y < layout.canvas.top) return -1;
// Clip at the canvas bottom: clicks in the canvas's dead-zone below the last
// visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom.
if (y >= layout.canvas.bottom) return -1;
const int index = (y - layout.canvas.top) / kSampleRowHeight;
if (index < 0 || index >= rowCount) return -1;
// Guard the bottom edge: a click below the last row's bottom is outside.
const Rect r = sampleRowRect(layout, index);
if (y >= r.bottom) return -1;
return index;
}
// --- 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
+149
View File
@@ -0,0 +1,149 @@
// 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);
// --- 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);
// --- 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 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,
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 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);
// True if (x, y) lands on the "Add Zone" button. Pure.
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
} // namespace reasampler::vst
+86
View File
@@ -0,0 +1,86 @@
// embed_strip.cpp — see embed_strip.h. Pure math; no host types.
#include "embed_strip.h"
#include <algorithm>
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<int>(l * band.width());
if (fillW <= 0) return Rect{};
return Rect{band.left, band.top, band.left + fillW, band.bottom};
}
} // namespace reasampler::vst
+76
View File
@@ -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
+126
View File
@@ -0,0 +1,126 @@
// keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types.
#include "keyboard_strip.h"
#include <algorithm>
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
// 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);
}
} // namespace reasampler::vst
+121
View File
@@ -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
+113
View File
@@ -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 <algorithm>
#include <cctype>
namespace reasampler::vst {
namespace {
char asciiUpper(char c) {
return static_cast<char>(std::toupper(static_cast<unsigned char>(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<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(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<int>(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<int> 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<unsigned char>(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<long long>(octave + 1) * 12 + semitone;
return clampNote(note);
}
std::optional<int> 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<unsigned char>(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<int> 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<unsigned char>(s[0])) || s[0] == '+' ||
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
if (auto n = parseInteger(s)) return n;
}
return parseNoteName(s);
}
} // namespace reasampler::vst
+33
View File
@@ -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 <optional>
#include <string>
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<int> parseNoteEntry(const std::string& text);
} // namespace reasampler::vst
+92
View File
@@ -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 <algorithm>
namespace reasampler::vst {
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls) {
std::vector<ControlRow> 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<int>(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<double>(x - track.left) / static_cast<double>(span);
}
int controlAtPoint(const std::vector<ControlRow>& 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
+104
View File
@@ -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 <vector>
#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<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& 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<ControlRow>& rows, int x, int y);
} // namespace reasampler::vst
+126
View File
@@ -0,0 +1,126 @@
// pitch_shift — pure implementation. See pitch_shift.h for the contract and the S16-F2
// route-(b) rationale (WDL drags <windows.h>, 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 <algorithm>
#include <cmath>
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<std::size_t>(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<double>(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<std::size_t>(writePos_)] = in;
const double w = static_cast<double>(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<std::int64_t>(p);
const double frac = p - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (i1 >= window_) i1 = 0;
const double s0 = static_cast<double>(ring_[static_cast<std::size_t>(i0)]);
const double s1 = static_cast<double>(ring_[static_cast<std::size_t>(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<double>(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<AudioSample>(out);
}
} // namespace reasampler
+88
View File
@@ -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 <windows.h>` 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 <cstddef>
#include <cstdint>
#include <vector>
#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<AudioSample> 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
+112
View File
@@ -0,0 +1,112 @@
// reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin.
#include "reaper_bridge.h"
#include <vector>
#include "bridge_marshal.h"
#include "capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation)
#include "ext_keys.h" // kProjExtNamespace (shared wire contract)
// The VST3 base types must be included before REAPER's VST3 interface header, which
// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from
// 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 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 {
bool ReaperBridge::connect(Steinberg::FUnknown* context) {
getProjExtState_ = nullptr;
enumProjExtState_ = nullptr;
enumProjects_ = 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<Steinberg::IReaperHostApplication> 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<GetProjExtStateFn>(
reaper->getReaperApi("GetProjExtState"));
enumProjExtState_ = reinterpret_cast<EnumProjExtStateFn>(
reaper->getReaperApi("EnumProjExtState"));
// EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call
// persist.cpp uses, so the instrument derives the project directory identically.
enumProjects_ = reinterpret_cast<EnumProjectsFn>(
reaper->getReaperApi("EnumProjects"));
return getProjExtState_ != nullptr;
}
std::optional<std::string> 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<Steinberg::IReaperHostApplication*>(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; the bank blob can be large (many
// samples), so grow the buffer until the value fits rather than risk a silent
// truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The
// return value is the value length; if it fits strictly inside the buffer it is
// complete, else grow and retry up to a 16 MB ceiling.
for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) {
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
const int rv = getProjExtState_(proj, kProjExtNamespace(), key.c_str(),
buf.data(), cap);
if (rv <= 0) return std::nullopt; // absent / empty key
std::string s(buf.data());
if (static_cast<int>(s.size()) + 1 < cap) {
return decodeGetProjExtState(rv, s);
}
// else: possibly truncated -> grow and retry.
}
return std::nullopt; // pathologically large (>16 MB) — give up rather than loop
}
std::string ReaperBridge::activeProjectDir() {
if (!enumProjects_) return {};
// idx=-1 is the current project tab; the out-buffer receives the full .rpp path,
// EMPTY for a never-saved project. Same call + convention as persist.cpp; the pure
// projectDirOfRpp turns the .rpp path into the project directory (parent, forward-
// slashed) and keeps an unsaved project's empty path empty (no default-location
// fallback — the tool's invariant).
std::vector<char> buf(4096, '\0');
enumProjects_(-1, buf.data(), static_cast<int>(buf.size()));
return projectDirOfRpp(std::string(buf.data()));
}
} // namespace reasampler::vst
+79
View File
@@ -0,0 +1,79 @@
// 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 <optional>
#include <string>
#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.
//
// NOT REAL-TIME SAFE (it allocates a read buffer and calls into REAPER): callers on
// the audio thread MUST NOT invoke it. The S4 instrument reads on the main/UI thread
// and hands a snapshot to the process path (see reasampler_processor.cpp).
std::optional<std::string> readReasamplerExtState(const std::string& key);
// The active project's directory (the folder holding its .rpp), forward-slashed,
// no trailing slash — the M4 convention persist uses to place the bank alongside
// the .rpp. Empty for an unsaved project or when unconnected. The instrument
// resolves relative sample paths against this the SAME way persist does
// (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe.
std::string activeProjectDir();
private:
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
// verified against reaper_plugin_functions.h.
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);
// 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
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
// 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).
//
// 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.
//
// 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 <optional>
#include <string>
#include <unordered_map>
#include <vector>
#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)
#ifdef _WIN32
#include <windows.h>
#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 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;
Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
Steinberg::FIDString type) override;
Steinberg::tresult PLUGIN_API canResize() override;
protected:
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; 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,
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
// 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);
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 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
// 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
// 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();
HWND childHwnd_ = nullptr;
#endif
// 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();
// 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);
// 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<AudioSample>& 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);
// 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. 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_) ------
//
// 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<ControlDesc> controlDescs(const ZonePlaySeconds& play) const;
// The normalized [0,1] display value for control `id` given `play` (the shell's domain
// 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 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;
// --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
std::vector<SampleChoice> samples_; // every bank sample, bank order
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
std::vector<SampleChoice> 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
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
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;
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
// clamps use the sibling markers).
WaveMarker waveMarker_ = WaveMarker::kStart;
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.
std::unordered_map<std::string, Envelope> 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<std::string, std::vector<AudioSample>> pcmCache_;
};
} // namespace reasampler::vst
+264
View File
@@ -0,0 +1,264 @@
// 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 <string>
#include <vector>
#include "app_version.h" // vstPluginName (channel-derived embed label, S18)
#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 / kProjExtBankGenKey
#include "reaper_bridge.h"
#include "reasampler_processor.h"
// wdltypes.h first: it defines INT_PTR portably (and pulls <windows.h> 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<SampleChoice>& 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<EmbedZone> toEmbedZones(const PerformanceMap& map) {
std::vector<EmbedZone> 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();
selectedZone_ = -1;
return;
}
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
map_ = processor_->performanceMap();
if (selectedZone_ >= static_cast<int>(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<int>(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
}
// 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 =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
lastSeenBankGeneration_ = currentGen;
}
}
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;
case REAPER_FXEMBED_WM_GETMINMAXINFO: {
auto* hints = reinterpret_cast<REAPER_FXEMBED_SizeHints*>(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;
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) fall through
}
}
#ifdef _WIN32
bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
auto* bmp = reinterpret_cast<LICE_IBitmap*>(bitmap);
auto* di = reinterpret_cast<const REAPER_FXEMBED_DrawInfo*>(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. 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).
LICE_FillRect(bmp, 0, 0, w, h, kColBackground, 1.0f, 0);
const EmbedLayout layout = layoutEmbed(w, h);
if (map_.zones.empty()) {
// 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();
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 = 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 {
// 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<int>(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<const REAPER_FXEMBED_DrawInfo*>(drawInfo);
if (!di || di->width <= 0 || di->height <= 0) return false;
refresh();
const EmbedLayout layout = layoutEmbed(di->width, di->height);
const std::vector<EmbedZone> zones = toEmbedZones(map_);
const int hit = zoneAtPoint(layout, zones.data(), static_cast<int>(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
+112
View File
@@ -0,0 +1,112 @@
// 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 <cstdint>
#include <string>
#include <vector>
#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();
// 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<SampleChoice> samples_;
PerformanceMap map_;
// 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;
};
} // namespace reasampler::vst
+610
View File
@@ -0,0 +1,610 @@
// reasampler_processor.cpp — see reasampler_processor.h.
#include "reasampler_processor.h"
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <optional>
#include <utility>
#include <vector>
#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 "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 / 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
#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 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;
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
// any failure — the caller treats an unreadable WAV as "nothing to play".
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
std::vector<std::uint8_t> bytes;
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) return bytes;
const std::streamoff size = f.tellg();
if (size <= 0) return bytes;
f.seekg(0, std::ios::beg);
bytes.resize(static_cast<std::size_t>(size));
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
return bytes;
}
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
// 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<DecodedZonePcm> decodeRelative(const std::string& projectDir,
const std::string& relativePath,
ChannelMode mode) {
const std::string abs = resolveBankFile(projectDir, relativePath);
if (abs.empty()) return std::nullopt;
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
const WavLayout layout = parseWavLayout(bytes);
if (!layout.valid) return std::nullopt;
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
static_cast<int>(layout.sampleRate));
if (out.monoFrames.empty()) return std::nullopt;
return out;
}
} // 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.
return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
}
// Out-of-line so unique_ptr<ReaSamplerEmbed> 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<ReaSamplerEmbed>(this);
embed_->addRef();
*obj = static_cast<IReaperUIEmbedInterface*>(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;
// Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the
// 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 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);
const ChannelMode mode = channelMode();
addAudioOutput(STR16("Audio Out"),
mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono);
return kResultOk;
}
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
// process() is not running at terminate. Free the live instrument and drain the
// graveyard. Take the pointer out of the atomic first so nothing else races it.
std::lock_guard<std::mutex> lock(reloadMutex_);
delete live_.exchange(nullptr);
graveyard_.clear();
return SingleComponentEffect::terminate();
}
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// Activating: build the instrument from the currently-selected sample so the first
// block after activation can play. Deactivating: process is now GUARANTEED stopped by
// the host, so this is the safe point to reclaim the graveyard (the displaced engines
// no reload could free while active). The build/drain are off the audio thread —
// setActive is a main/UI-thread call.
if (state) {
reloadFromBank();
} else {
std::lock_guard<std::mutex> lock(reloadMutex_);
graveyard_.clear();
}
return kResultOk;
}
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
sampleRate_ = setup.sampleRate;
maxBlockSize_ = setup.maxSamplesPerBlock;
return SingleComponentEffect::setupProcessing(setup);
}
tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
if (!state) return kResultFalse;
// Read the whole component-state blob (the performance map, versioned). The blob is
// small; read in one shot into a growable buffer.
std::vector<std::uint8_t> bytes;
std::uint8_t chunk[256];
int32 got = 0;
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
bytes.insert(bytes.end(), chunk, chunk + got);
}
// 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).
// 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
// stale assign_request (the user may have manually changed the selection after the assign).
{
std::lock_guard<std::mutex> 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.
{
std::lock_guard<std::mutex> lock(channelModeMutex_);
channelMode_ = cs.channelMode;
}
applyOutputArrangement(cs.channelMode);
// 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;
// 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();
state_out.channelMode = channelMode(); // S7: persist the per-instance mono/stereo mode
{
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
}
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
if (!bytes.empty()) {
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
static_cast<int32>(bytes.size()), nullptr);
if (wr != kResultOk) return wr;
}
return kResultOk;
}
std::string ReaSamplerProcessor::selectedSampleId() {
std::lock_guard<std::mutex> lock(selectionMutex_);
return selectedSampleId_;
}
void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
std::lock_guard<std::mutex> lock(selectionMutex_);
selectedSampleId_ = id;
}
PerformanceMap ReaSamplerProcessor::performanceMap() {
std::lock_guard<std::mutex> lock(performanceMutex_);
return performanceMap_;
}
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
std::lock_guard<std::mutex> lock(performanceMutex_);
performanceMap_ = map;
}
ChannelMode ReaSamplerProcessor::channelMode() {
std::lock_guard<std::mutex> 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<AudioBus>(outs->at(0))) {
bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo
: SpeakerArr::kMono);
}
}
void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
{
std::lock_guard<std::mutex> 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
// thread — process() only touches the atomic.
std::lock_guard<std::mutex> lock(reloadMutex_);
// Mint this reload's generation number first so we can stamp the built instrument
// with it before publishing. Under reloadMutex_ no other reload races here.
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
// 1. Read the live bank + resolve the project dir over the bridge (allocates,
// calls REAPER — fine here, off-thread).
std::optional<std::string> banksJson =
bridge_.readReasamplerExtState(kProjExtBanksKey);
const std::string projectDir = bridge_.activeProjectDir();
// 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<LoadedInstrument> built;
if (banksJson) {
// 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<DecodedZonePcm> decoded;
std::vector<ResolvedZone> kept;
decoded.reserve(resolved.zones.size());
kept.reserve(resolved.zones.size());
for (const ResolvedZone& rz : resolved.zones) {
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, rz.relativePath, mode);
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. 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<SelectedSample> sel =
selectSample(*banksJson, selectedSampleId());
if (sel) {
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
sel->rootNote, sel->loop,
std::move(pcm->framesR));
haveKeymap = true;
resolvedId = selectedSampleId(); // the concrete pick that resolved
}
}
}
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
// (which also covers a pathological host rate <= 0 — no rate literal needed).
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>(
std::move(km), kMaxVoices, gen, kPreserveVoiceCap,
preserveWindow);
}
}
// 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the
// graveyard tagged with this generation (process may still be mid-block reading
// it). A null `built` (no bank / unreadable WAV) installs silence.
// `built` is heap-owned; release() hands ownership to the atomic, and the
// exchanged pointer is re-owned by the graveyard.
//
// Bounded reclaim: prune graveyard entries where displacedAt <= seen, where seen
// is the last generation process() published. process() publishes inst->installedAt
// (not a re-read of reloadGeneration_), so seen == D means process holds the
// instrument installed at gen D. An entry with displacedAt == D was displaced by
// reload D, which installed that very successor — process cannot be holding the
// displaced entry. The pruning condition is therefore <= (see header for the full
// proof). Remaining entries drain at setActive(false) / terminate() when process
// is guaranteed stopped.
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
graveyard_.erase(
std::remove_if(graveyard_.begin(), graveyard_.end(),
[seen](const GraveyardEntry& e) { return e.displacedAt <= seen; }),
graveyard_.end());
LoadedInstrument* prev = live_.exchange(built.release());
if (prev) graveyard_.push_back({gen, std::unique_ptr<LoadedInstrument>(prev)});
return resolvedId;
}
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<AssignmentRequest> 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();
}
}
// 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<std::mutex> lock(assignMarkerMutex_);
lastConsumed = lastConsumedAssignGeneration_;
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
// 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-
// thread graveyard pruner knows exactly which generation this block is holding.
//
// We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an
// ordering race: reading reloadGeneration_ after live_ could observe a generation
// newer than the pointer we actually hold, causing the pruner to free an instrument
// process is still reading. installedAt was set on the reload path before the atomic
// exchange that made the instrument visible, so it is always <= the generation of any
// instrument that could have been loaded after our acquire above.
LoadedInstrument* inst = live_.load(std::memory_order_acquire);
const std::uint64_t heldGen = inst ? inst->installedAt : 0;
processGeneration_.store(heldGen, std::memory_order_release);
// Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps
// events at block granularity (no per-event sample-offset split) — audible timing is
// within one block, adequate for Tier 0; sample-accurate scheduling is a later tier.
if (inst && data.inputEvents) {
const int32 count = data.inputEvents->getEventCount();
for (int32 i = 0; i < count; ++i) {
Event e;
if (data.inputEvents->getEvent(i, e) != kResultOk) continue;
if (e.type == Event::kNoteOnEvent) {
// A note-on with velocity 0 is a note-off by MIDI convention.
const int vel = static_cast<int>(e.noteOn.velocity * 127.0f + 0.5f);
if (vel <= 0) {
inst->engine.noteOff(e.noteOn.pitch);
} else {
inst->engine.noteOn(e.noteOn.pitch, vel);
}
} else if (e.type == Event::kNoteOffEvent) {
inst->engine.noteOff(e.noteOff.pitch);
}
}
}
if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) {
embedPeak_.store(0.f, std::memory_order_relaxed);
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) {
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;
}
}
out.silenceFlags = (out.numChannels >= 64)
? ~0ULL
: ((1ULL << out.numChannels) - 1);
return kResultOk;
}
// 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;
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<std::size_t>(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<std::size_t>(frames));
}
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);
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(this);
}
return nullptr;
}
} // namespace reasampler::vst
+272
View File
@@ -0,0 +1,272 @@
// 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. It gives us
// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own
// state (the selected sample), and the IEditController seat so createView() can hand the
// host our IPlugView LICE editor.
//
// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — bridge ext-state
// read, WAV decode, path resolve, keymap build, VoiceEngine construction — all happens
// OFF the audio thread (reloadFromBank, driven from the main/UI thread) and is handed to
// process via a single atomic pointer swap. See the LoadedInstrument handoff below.
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
#include "reaper_bridge.h"
#include "sample_map.h" // PerformanceMap (the instrument's owned zoned keymap)
#include "sampler_core.h"
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
// 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,
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0)
: keymap(std::move(km)),
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames),
installedAt(gen) {}
LoadedInstrument(const LoadedInstrument&) = delete;
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
};
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*/);
//--- 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;
// 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;
// Marshals MIDI -> VoiceEngine -> audio output. Real-time safe (no alloc/IO/lock).
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;
// 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<double>(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
// 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 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_; }
// 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);
// 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);
// 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) -----------------
// process() atomically loads `live_` at block start and marshals/renders against it —
// a single atomic acquire, no lock, no free on the audio thread.
//
// reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
// NOT freed on the reload path: process() may still be mid-block reading it, and two
// rapid reloads could otherwise free a pointer process is using. Instead it is parked
// in `graveyard_` tagged with the reload generation at which it was displaced.
//
// Bounded reclaim: process() publishes inst->installedAt (the generation at which the
// held instrument was installed) via processGeneration_ — a single atomic store, RT-
// safe. The reload path prunes graveyard entries where displacedAt <= seen (where seen
// is the last published processGeneration_).
//
// Safety argument: an entry with displacedAt == D was displaced by reload D, which
// simultaneously installed its successor with installedAt == D. process() publishing
// seen == D means it holds that successor (or a later one). In either case, the
// displaced entry is not the pointer process is using, so freeing it is safe. The
// pruning condition is therefore <= (not strict <): an entry displaced at exactly the
// published generation is also provably unreachable.
//
// The graveyard's upper bound is the number of reloads since process last ran
// (typically 01 in normal use). Remaining entries drain at setActive(false) /
// terminate(), when the host guarantees process is stopped.
std::atomic<LoadedInstrument*> live_{nullptr};
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
std::atomic<std::uint64_t> processGeneration_{0}; // generation last seen by process (written on audio thread, read off-thread)
struct GraveyardEntry {
std::uint64_t displacedAt = 0; // reloadGeneration_ value when this was displaced
std::unique_ptr<LoadedInstrument> instrument;
};
std::vector<GraveyardEntry> graveyard_; // drained on reclaim + setActive(false) + terminate
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
// The 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_;
// 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_;
// 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;
// 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. 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 ---------------------------------------------
// 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<ReaSamplerEmbed> 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<float> embedPeak_{0.f};
};
} // namespace reasampler::vst
+79
View File
@@ -0,0 +1,79 @@
// 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.
//
// 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 {
// 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 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
// 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
+640
View File
@@ -0,0 +1,640 @@
// 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 <algorithm> // std::min
#include <cassert> // assert
#include <cstring> // std::memcpy
#include <utility> // std::move
namespace reasampler {
namespace {
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
// SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) {
SampleLoop out;
if (s.loop) {
out.hasLoop = true;
out.start = s.loop->start;
out.end = s.loop->end;
}
return out;
}
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
SelectedSample distill(const Sample& s) {
SelectedSample out;
out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s);
return out;
}
} // namespace
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) {
// 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<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
// Search every bank (pool first, then named — banks() is ordinal order) for the
// stored id. A sample lives in exactly one bank, so first hit wins.
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(sampleId)) {
return distill(*s);
}
}
// 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<SampleChoice> listSamples(const std::string& banksJson) {
std::vector<SampleChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out;
for (const Bank& b : book->banks()) {
for (const Sample& s : b.index.all()) {
out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id});
}
}
return out;
}
std::vector<BankChoice> listBanks(const std::string& banksJson) {
std::vector<BankChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> 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<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
const double inv = 1.0 / static_cast<double>(channelCount);
for (std::size_t f = 0; f < frames; ++f) {
double acc = 0.0;
const std::size_t base = f * stride;
for (std::size_t c = 0; c < stride; ++c) {
acc += static_cast<double>(interleaved[base + c]);
}
out[f] = static_cast<AudioSample>(acc * inv);
}
return out;
}
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(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<std::size_t>(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<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate) {
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
DecodedZonePcm out;
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);
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;
}
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.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(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;
return static_cast<std::int64_t>(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<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> 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
// 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);
}
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.
data.play = resolvePlay(play, data.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<BankBook> 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);
// 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;
// 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;
out.zones.push_back(std::move(rz));
}
return out;
}
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& 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;
// 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;
}
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)
// 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;
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<std::uint8_t>& out, std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
out.push_back(static_cast<std::uint8_t>((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<std::uint8_t>& out, std::uint64_t v) {
for (int b = 0; b < 8; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFF));
}
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(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.
struct ByteReader {
const std::vector<std::uint8_t>& bytes;
std::size_t pos = 0;
bool ok = true;
explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}
std::uint32_t u32() {
if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; }
const std::uint32_t v = static_cast<std::uint32_t>(bytes[pos]) |
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
(static_cast<std::uint32_t>(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<const char*>(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<int>(static_cast<std::int32_t>(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<std::uint64_t>(bytes[pos + static_cast<std::size_t>(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<std::int64_t>(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<std::uint32_t>(bytes[pos]) |
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
}
};
// 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<std::uint8_t>& out, const PerformanceMap& map) {
putU32le(out, kZonesFormatMarker);
putU32le(out, kZonesPayloadVersion);
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
for (const PerformanceZone& z : map.zones) {
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
out.push_back(z.rootOverride ? 1 : 0);
if (z.rootOverride) {
putU32le(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(*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));
// 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, 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, 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, doubleToBits(pp.adsr.releaseSeconds));
}
}
// 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.
// `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) {
r.u32(); // consume the marker
pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start 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 + 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);
z.lowNote = r.i32();
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 (legacyV3Play) {
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
// 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<double>(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<double>(r.i64()) / liftRate;
z.play.pitchEnv.decaySeconds = static_cast<double>(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
// 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());
}
// 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));
}
}
} // namespace
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
std::vector<std::uint8_t> out;
putU32le(out, kPerformanceStateVersion);
putZonesPayload(out, map);
return out;
}
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& 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();
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
readZonesPayload(r, map, projectRate);
return map;
}
// --- Combined component state (v3, S10) --------------------------------------
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
std::vector<std::uint8_t> 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);
// 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<std::uint32_t>(state.selectionId.size()));
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
putZonesPayload(out, state.map);
return out;
}
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& 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();
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, 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).
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, 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):
// 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, projectRate);
return out; // marker stays 0 (pre-S8/S9 reader)
}
if (version != kComponentStateVersion) return out; // unknown -> empty
// 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
readZonesPayload(r, out.map, projectRate);
return out;
}
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
std::vector<std::uint8_t> out;
out.resize(4 + sampleId.size());
const std::uint32_t v = kSelectionStateVersion;
out[0] = static_cast<std::uint8_t>(v & 0xFF);
out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
return out;
}
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
if (bytes.size() < 4) return {}; // no version tag -> no selection
const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
(static_cast<std::uint32_t>(bytes[1]) << 8) |
(static_cast<std::uint32_t>(bytes[2]) << 16) |
(static_cast<std::uint32_t>(bytes[3]) << 24);
if (v != kSelectionStateVersion) return {}; // unknown version -> ignore
return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
bytes.size() - 4);
}
} // namespace reasampler
+468
View File
@@ -0,0 +1,468 @@
#pragma once
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core
// plays, and (de)serialize the instance's selected-sample choice for VST3 component
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the
// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable
// arithmetic out of a host-facing shell.
//
// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam
// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV).
// Both of those raw inputs cross the bridge/file boundary in the shell; everything
// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second
// parser; the S1 spike's string-scan reader is retired), pick the selected sample,
// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic
// Keymap — is pure and unit-tested here.
//
// It links bank_book (the shared BankBook::deserialize) and wav_trim (the shared
// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap /
// SampleData it produces). All three are pure; this stays pure.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "sampler_core.h" // Keymap, SampleData, SampleLoop
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler {
// The bank sample this instance is bound to, distilled from the live "banks" blob:
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet.
struct SelectedSample {
std::string relativePath; // project-relative; the shell resolves it (M4 convention)
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
};
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank
// project). `sampleId` is this instance's stored selection.
//
// Precedence, all pure:
// * empty / malformed banksJson -> nullopt (nothing to play)
// * sampleId empty -> nullopt (NO selection -> silence)
// * sampleId names a sample in ANY bank -> that sample (searched pool + named)
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved;
// the editor returns to the empty state)
//
// 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<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId);
// 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<int> rootNote;
std::optional<std::string> key;
std::string bankId;
};
std::vector<SampleChoice> 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<BankChoice> 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,
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve
// their source channel count, so a stereo (or N-channel) capture is folded to a single
// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is
// the least-surprising, no-clip default — a centered mono source stays unity, and a
// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride
// in -> empty out. Pure.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount);
// 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<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which);
// --- 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
// (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.
// `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<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {},
const ZonePlaySeconds& play = ZonePlaySeconds{});
// --- 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).
//
// 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<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// 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. 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
// 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<PerformanceZone> 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; // effective: loopOverride, else bank S2 intrinsic (S11)
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
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
// 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<ResolvedZone> zones;
std::vector<std::string> 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<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
// receive the WAV's real rate before use.
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
};
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& 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<AudioSample>& 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
// 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: 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.
// * 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, 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, 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.
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
// 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):
// 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
// 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 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/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;
// (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<std::uint8_t> 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.
// `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<std::uint8_t>& bytes,
double projectRate);
// --- 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 (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 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.
// * 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 = 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}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> 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.
// `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<std::uint8_t>& bytes,
double projectRate);
// --- 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 — 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;
// The selected-sample id serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
// too-short, or empty -> "" (graceful no-selection).
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler
+633
View File
@@ -0,0 +1,633 @@
// 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 <cmath>
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<double>(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<double>(framesInStage_) /
static_cast<double>(params_.attackFrames);
if (level_ > 1.0) level_ = 1.0;
}
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;
// Single re-dispatch into Decay (bounded: Hold→Decay only; not a general recursion).
return tick();
}
level_ = 1.0;
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.holdFrames) {
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<double>(framesInStage_) /
static_cast<double>(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<double>(framesInStage_) /
static_cast<double>(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.
}
// ---------------------------------------------------------------------------
// 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<double>(playLength_)) {
// At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd.
if (sourceOffset >= static_cast<double>(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<double>(playLength_ - fadeOut_);
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
const double phase = sourceOffset / static_cast<double>(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<double>(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<double>(pos_) / static_cast<double>(a));
} else if (pos_ < a + d) {
// Decay: peak -> 0 over decayFrames (settle to base pitch).
const double t = static_cast<double>(pos_ - a) / static_cast<double>(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) {
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<double>(v) / 127.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<std::int64_t>(sample.frames.size());
std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start);
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
// --- 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 (in FRAMES), resolved by
// buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
// the live sample 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();
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<std::int64_t>(
static_cast<double>(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<double>(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 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;
}
const std::vector<AudioSample>& pcm = sample_->frames;
const std::int64_t frameCount = static_cast<std::int64_t>(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<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
// 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 = playMode_ == PlayMode::Gate && loop.hasLoop &&
loop.end > loop.start && loop.start >= 0 && loop.end <= frameCount;
if (loopUsable) {
const double loopLen = static_cast<double>(loop.end - loop.start);
while (readPos_ >= static_cast<double>(loop.end)) {
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
}
}
// 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<double>(playEnd_);
// Ran off the sample end with no usable loop -> voice is done.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
active_ = false;
if (stereo) outR = 0.0f;
return 0.0f;
}
// 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<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (loopUsable && i1 >= loop.end) {
i1 = loop.start; // seamless wrap for the interpolation partner.
}
const bool i0ok = (i0 >= 0 && i0 < frameCount);
const bool i1ok = (i1 >= 0 && i1 < frameCount);
// 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();
// 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<double>(pcm[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
double srcR = 0.0;
if (stereo) {
srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
(i0ok ? static_cast<double>(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<double>(shiftL_.process(static_cast<AudioSample>(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<double>(shiftR_.process(static_cast<AudioSample>(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<AudioSample>(outRlocal);
readPos_ += ratio_;
if (amplitudeDone_) {
active_ = false;
}
return static_cast<AudioSample>(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);
}
// ---------------------------------------------------------------------------
// VoiceEngine
// ---------------------------------------------------------------------------
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
std::size_t preserveVoiceCap,
std::int64_t preserveWindowFrames)
: 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).
//
// 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() {
// 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];
// 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);
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(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[f] += voice.renderFrame();
}
}
}
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<AudioSample>& out, std::size_t frameCount) {
// Off-thread / test path: grow the buffer (this allocates — never call under
// process), zero-fill the appended span, then delegate to the RT mix loop so both
// overloads share exactly one summation path.
const std::size_t base = out.size();
out.resize(base + frameCount, 0.0f);
render(out.data() + base, frameCount);
}
std::size_t VoiceEngine::activeVoiceCount() const {
std::size_t n = 0;
for (const Voice& v : voices_) {
if (v.active()) ++n;
}
return n;
}
} // namespace reasampler
+531
View File
@@ -0,0 +1,531 @@
#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 <cstddef>
#include <cstdint>
#include <vector>
#include "peaks.h" // AudioSample (float)
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
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 };
// ---------------------------------------------------------------------------
// 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
// 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. 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<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample
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
// 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;
// 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 {
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
}
};
// ---------------------------------------------------------------------------
// 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<SampleData> samples;
std::vector<KeyZone> 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);
// ---------------------------------------------------------------------------
// 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; 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.
// ---------------------------------------------------------------------------
class AdsrEnvelope {
public:
enum class Stage { Idle, Attack, Hold, 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
};
// ---------------------------------------------------------------------------
// 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
// 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`. All five AHDSR fields (A/H/D/S/R) are read directly from
// 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).
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_; }
// 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
// sample runs out with no loop. The value is already velocity- and
// 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 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 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;
};
// ---------------------------------------------------------------------------
// 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). 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
// 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);
// 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);
// 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.
// Does not clear existing contents — appends, matching the pre-S4 contract the
// unit tests rely on.
void render(std::vector<AudioSample>& out, std::size_t frameCount);
// Count of currently active voices (for tests / diagnostics).
std::size_t activeVoiceCount() const;
std::size_t maxVoices() const { return voices_.size(); }
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-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();
// 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<Voice> voices_;
const Keymap& keymap_;
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"
};
} // namespace reasampler
+82
View File
@@ -0,0 +1,82 @@
// 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 "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" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*)
// 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 &&
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 &&
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
BEGIN_FACTORY(reasampler::vst::kVendorName, reasampler::vst::kVendorUrl,
reasampler::vst::kVendorEmail, Steinberg::PFactoryInfo::kNoFlags)
// 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::vstPluginName().c_str(), // plug-in display name (channel-derived)
0, // single-component => 0
Steinberg::Vst::PlugType::kInstrumentSynthSampler, // subcategory
reasampler::appVersion().c_str(), // plug-in version (channel: -beta render)
kVstVersionString, // VST3 SDK version (fixed)
reasampler::vst::ReaSamplerProcessor::createInstance)
END_FACTORY
+99
View File
@@ -0,0 +1,99 @@
// waveform_view.cpp — see waveform_view.h. Pure math; no host types.
#include "waveform_view.h"
#include <algorithm>
#include <cstdlib> // 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<std::int64_t>(w) + frameCount / 2;
return area.left + static_cast<int>(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<std::int64_t>(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<std::int64_t>(w) / 2;
return clampFrame(num / static_cast<std::int64_t>(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::int64_t>(std::abs(dxPixels)) * frameCount +
static_cast<std::int64_t>(w) / 2) /
static_cast<std::int64_t>(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
+83
View File
@@ -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 <cstdint>
#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
+36
View File
@@ -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();
+185
View File
@@ -0,0 +1,185 @@
// 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 <cstdio>
#include <string>
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());
}
// --- 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();
testRoundTripAdversarialIds();
testRoundTripEmptyFields();
testLargeGeneration();
testMalformedParse();
testTrailingGarbageRejected();
testOverflowFieldLength();
testOverflowFieldInt64();
testOverflowExactSizeMax();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+134
View File
@@ -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 == "<FXCHAIN\n BYPASS 0 0 0\n>");
// 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;
+203
View File
@@ -0,0 +1,203 @@
// 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 <cstdint>
#include <cstdio>
#include <optional>
#include <string>
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);
}
// 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();
testParseOverflow();
testFormatRoundTrip();
testGenerationChanged();
testNoRequest();
testNotNewerNotReapplied();
testNonTargetStaysEligible();
testUnresolvableDroppedSilently();
testAppliedWhenNewTargetResolvable();
testSameIdNewGenerationReapplies();
testFreshInstanceAppliesFirst();
testDedupCollapseAssignAppliesWhenBankGenerationUnchanged();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+56
View File
@@ -0,0 +1,56 @@
// 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) directly, so the DAW-facing
// shell only has to invoke the API.
//
// Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer
// 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"
#include <cstdio>
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());
}
int main() {
testDecodeHit();
testDecodeAbsentKey();
testDecodeNegativeReturn();
testDecodeEmptyBuffer();
if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n");
return g_fail != 0;
}
+179
View File
@@ -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 <cstdio>
#include <string>
#include <vector>
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<std::string> names{"Kick", "Snare", "Kick Sub", "Hat"};
// Empty query -> every index, in order.
const std::vector<int> all = filterNameIndices(names, "");
CHECK(all.size() == 4 && all[0] == 0 && all[3] == 3);
// "kick" -> indices 0 and 2, order preserved.
const std::vector<int> 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;
}
+203
View File
@@ -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 <cstdio>
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;
}
+54
View File
@@ -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();
+337
View File
@@ -0,0 +1,337 @@
// 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 <cstdio>
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);
}
}
}
// --- sample list (S4) ---------------------------------------------------------
static void testSampleRowRectStacks() {
const EditorLayout L = layoutEditor(400, 260);
const Rect r0 = sampleRowRect(L, 0);
const Rect r1 = sampleRowRect(L, 1);
// Row 0 starts at the canvas top and spans its full width.
CHECK(r0.top == L.canvas.top);
CHECK(r0.left == L.canvas.left && r0.right == L.canvas.right);
CHECK(r0.height() == kSampleRowHeight);
// Row 1 sits directly below row 0 (no gap, no overlap).
CHECK(r1.top == r0.bottom);
CHECK(r1.height() == kSampleRowHeight);
// A negative index is an empty rect.
CHECK(sampleRowRect(L, -1).width() == 0 && sampleRowRect(L, -1).height() == 0);
}
static void testSampleRowHitTestMapsClickToRow() {
const EditorLayout L = layoutEditor(400, 260);
const int rows = 5;
// A click in the vertical middle of row 2 resolves to index 2.
const Rect r2 = sampleRowRect(L, 2);
const int midY = (r2.top + r2.bottom) / 2;
CHECK(sampleRowHitTest(L, rows, 200, midY) == 2);
// Row 0's top-left corner hits row 0.
const Rect r0 = sampleRowRect(L, 0);
CHECK(sampleRowHitTest(L, rows, r0.left, r0.top) == 0);
}
static void testSampleRowHitTestMisses() {
const EditorLayout L = layoutEditor(400, 260);
const int rows = 3;
// Above the first row (in the title bar) -> no row.
CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.top) == -1);
// Below the last row -> no row.
const Rect last = sampleRowRect(L, rows - 1);
CHECK(sampleRowHitTest(L, rows, 200, last.bottom + 1) == -1);
// Left of the canvas -> no row.
CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1);
// Zero rows -> always -1.
CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1);
// At or below canvas.bottom -> always -1, even if rowCount would cover that y.
// This guards paint<->hit-test agreement: sampleRowRect does not clamp to canvas,
// so without this clip a row that extends past canvas.bottom would hit-test but
// never be drawn (or vice versa).
CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom) == -1);
// Use a large rowCount so index arithmetic would return a valid row without the
// canvas.bottom guard — proving the guard fires independently of rowCount.
const int bigRows = 1000;
CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom) == -1);
CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom + 5) == -1);
}
// The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to
// that row's index (the same load-bearing invariant as the button).
static void testSampleRowHitTestMatchesDrawnRows() {
const EditorLayout L = layoutEditor(320, 200);
const int rows = 4;
for (int i = 0; i < rows; ++i) {
const Rect r = sampleRowRect(L, i);
if (r.top >= L.canvas.bottom) break; // clipped rows aren't clickable targets
const int y = (r.top + r.bottom) / 2;
if (y >= L.canvas.bottom) continue;
CHECK(sampleRowHitTest(L, rows, r.left + 1, y) == i);
}
}
// --- 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();
testLayoutNormalView();
testLayoutTinyViewClampsButton();
testLayoutZeroView();
testHitTestButton();
testHitTestMissesNonButton();
testHitTestButtonBoundary();
testHitTestMatchesDrawnButton();
testSampleRowRectStacks();
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;
}
+157
View File
@@ -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 <cstdio>
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;
}
+119
View File
@@ -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 <cstdio>
#include <string>
#include <vector>
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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> b(std::initializer_list<int> v) {
std::vector<std::uint8_t> out;
for (int x : v) out.push_back(static_cast<std::uint8_t>(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<std::uint8_t> in;
for (int i = 0; i < len; ++i) in.push_back(static_cast<std::uint8_t>((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;
}
+227
View File
@@ -0,0 +1,227 @@
// 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 <cstdio>
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);
}
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();
testKeyLeftMonotonicAndBounds();
testKeyRectHalfOpen();
testRootMarkerEqualsKeyRect();
testKeyAtPointInverts();
testKeyAtPointOffBand();
testZoneBarSpansInclusive();
testZoneBarMalformedCollapses();
testZoneGrabEdgesAndBody();
testZoneGrabNarrowBarSplitsAtMidpointLowWins();
testZoneBarAtPointFirstMatch();
testZoneBarAtPointNullList();
testResolveDragRoundsToNearestKey();
testResolveDragClampsAndNoOps();
testResolveDragProportionalNonDivisibleWidth();
if (g_fail == 0) std::printf("keyboard_strip: all tests passed\n");
return g_fail != 0;
}
+73
View File
@@ -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 <cstdio>
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;
}
+204
View File
@@ -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 <cstdio>
#include <vector>
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<ControlDesc> ctl{
{1, ControlKind::Toggle},
{2, ControlKind::Slider},
{3, ControlKind::Slider},
};
const std::vector<ControlRow> 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<ControlDesc> 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<ControlRow> 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<ControlDesc> ctl{
{10, ControlKind::Toggle},
{20, ControlKind::Slider},
};
const std::vector<ControlRow> 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<ControlRow> 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;
}
+187
View File
@@ -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
// <windows.h> 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 <cmath>
#include <cstdio>
#include <vector>
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<AudioSample> sine(std::size_t frames, double cycles) {
std::vector<AudioSample> s(frames);
for (std::size_t i = 0; i < frames; ++i) {
s[i] = static_cast<float>(std::sin(2.0 * kPi * cycles *
static_cast<double>(i) / static_cast<double>(frames)));
}
return s;
}
// Average spacing between positive-going zero crossings (the observed period).
static double observedPeriod(const std::vector<AudioSample>& out, std::size_t from) {
std::vector<std::size_t> 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<double>(up[i] - up[i - 1]);
return sum / static_cast<double>(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<AudioSample> 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<double>(n) / cycles; // 200
const std::vector<AudioSample> in = sine(n, cycles);
PitchShifter ps;
ps.configure(2205);
ps.warm();
ps.setShiftRatio(1.0);
std::vector<AudioSample> 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<double>(n) / cycles; // 200
const std::vector<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+223
View File
@@ -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 <cstdio>
#include <vector>
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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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;
}
Vendored Submodule
+1
Submodule vendor/vst3sdk added at dfff2e399c