diff --git a/.gitmodules b/.gitmodules index 97b3864..25ffe5c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "vendor/WDL"] path = vendor/WDL url = https://github.com/justinfrankel/WDL +[submodule "vendor/vst3sdk"] + path = vendor/vst3sdk + url = https://github.com/steinbergmedia/vst3sdk diff --git a/CLAUDE.md b/CLAUDE.md index 12c14a6..a4c8887 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ Key targets (see CMakeLists.txt for the full list): | `tooltip_tests` | executable | Pure unit tests for `tooltip` — no REAPER, no DAW. | | `card_drag_tests` | executable | Pure unit tests for `card_drag` — no REAPER, no DAW. | | `card_meta_tests` | executable | Pure unit tests for `card_meta` — no REAPER, no DAW. | +| `pitch_shift_tests` | executable | Pure unit tests for `pitch_shift` (S16 Preserve engine) — no REAPER, no DAW. | | `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). | ### Beta channel build (Phase V, V4) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8392ba8..351fbc8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,9 +34,13 @@ set(REASAMPLER_CHANNEL "stable" CACHE STRING "Build channel: stable (default) or if(REASAMPLER_CHANNEL STREQUAL "beta") set(REASAMPLER_CHANNEL_IS_BETA 1) set(REASAMPLER_OUTPUT_NAME "reaper_reasampler_beta") + # The VST3 instrument's on-disk name forks the same way (S18) — must match + # app_version::vstOutputName() so the artifact name and the in-binary self-id agree. + set(REASAMPLER_VST_OUTPUT_NAME "reasampler_9000_beta") elseif(REASAMPLER_CHANNEL STREQUAL "stable") set(REASAMPLER_CHANNEL_IS_BETA 0) set(REASAMPLER_OUTPUT_NAME "reaper_reasampler") + set(REASAMPLER_VST_OUTPUT_NAME "reasampler_9000") else() message(FATAL_ERROR "REASAMPLER_CHANNEL must be 'stable' or 'beta' (got '${REASAMPLER_CHANNEL}')") @@ -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 (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 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 $,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() diff --git a/CONTEXT.md b/CONTEXT.md index 66d1a57..900a191 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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^((note−root)/12)`. + Musically right for **tempo-locked loops and phrases** — a transposed loop still lines up to + the bar. Since captured banks are project slices (loop/phrase-heavy), this is the default + lean (fork S16-F1). + +Mode is **per-`PerformanceZone` performance state (D-B)** — instrument-owned, never a bank +fact — additive/version-bumped (absent/older blob → the S16-F1 default). A per-zone +**Varispeed/Preserve toggle** surfaces in the S10 guided setup / S10-Z Zones panel. + +**Preserve engine implementation (fork S16-F2).** Two RT-disciplined routes behind the +`PitchEngine::Preserve` seam (identical contract either way): +- **(a) `WDL_SimplePitchShifter`** (`vendor/WDL/WDL/simple_pitchshift.h`) — a per-voice + time-domain OLA shifter. Under the duration-preserving directive this is **the right + category** (see the corrected WDL finding below). `set_shift(2^(semi/12))` for pitch, + `set_tempo(1.0)` to hold duration — pitch and duration are separately controllable. **Lean: + route (a) first** (low-cost proof), with two costs owned in the build: an inherent + **onset latency** (~half-window, ~25 ms @ the 50 ms quality-0 window; pre-warm at voice- + allocation, and it lands on sustained/loop material where least harmful) and a **queue-growth + allocation** hazard in `BufferDone` (`WDL_Queue::Add`) that is settled by a silence pre-warm + at voice-allocation so no `process`-thread allocation occurs in steady state. +- **(b) hand-rolled pure `pitch_shift` OLA/granular module** (house pattern — CTest-testable, + no REAPER/VST3/WDL type at the boundary) — **held** as the quality/latency upgrade if the + SimpleWindowed warble or onset lag proves musically unacceptable. + +**`WDL_Resampler` is not a Preserve engine** — it is a *resampler* (couples duration); it +remains a held **Varispeed-quality** upgrade only. **elastique is NOT available** (licensed +zplane, not vendored — restated). JUCE / rubberband / signalsmith are **new-dependency forks +carrying D-A weight** (bare-VST3-no-framework is the locked D-A) — **not proposed**. + +**S15 × S16 interaction (Preserve consumes S15's source-frame read).** S15's amplitude +semantics are defined over the voice's **source-frame** timeline; the Preserve engine wraps +that read and transposes the output, so: +- **Trigger %-length** stays a source-frame fact (`playEnd = start + round(lengthFraction· + (frames − start))`); under Preserve its **wall-clock is stable under transpose** — *cleaner* + than Varispeed, where transposing a Trigger also scales its audible length. +- **Gate sustain loop** — under Preserve, **loop the source read** (the `[loopStart, loopEnd)` + source-frame region) and feed the looped stream into the shifter, which transposes the + **output**. Contract: *loop the source, shift the output*; loop points stay source-frame + facts (S11 markers unchanged). Under Varispeed the loop read itself carries the pitch. +- **Start point** is a source-frame offset in both engines (engine-independent). + +### Pitch envelope — AD, off by default, engine-aware (S16) + +A per-voice **pitch modulation curve** riding on top of whichever engine — a short **AD** +(attack-decay) envelope that biases pitch over time. **Off by default** (so existing playback +is bit-identical under the same engine). The classic use is a percussive **pitch drop**. + +- **Shape (lean, build-time residual): two-segment AD** — at note-on the pitch offset rises + to `peakSemitones` over `attackFrames`, then falls to 0 (base pitch) over `decayFrames`. + A **zero attack** gives the pure "start high, drop to base" percussive drop. +- **Range: semitones (±).** `peakSemitones` is signed; default depth range noted at build. +- **Applied per engine.** Under **Varispeed** the offset is a **per-frame multiply of + `ratio_`** by `2^(pitchEnvSemitones(frame)/12)` (the effective read increment varies frame- + by-frame at no structural cost — the same per-frame `tick()` idiom as the amp envelope, + RT-safe, no `process` allocation). Under **Preserve** the offset is **added to the shifter's + shift amount** — `set_shift(2^((note−root + pitchEnvSemitones(frame))/12))` — bending pitch + without touching duration. Per-voice (polyphonic notes each run their own). +- **Ownership + editor.** Per-zone instrument performance-map state (D-B), additive/version- + bumped (absent → disabled). Editor exposure folds into the S12 ADSR-editor tier: attack + + decay + a ±semitone depth control, default-off (discoverable but inert until enabled). + +### WDL pitch/resample surface — 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 3–10 ms) trade latency for more warble. + - **Quality:** basic — this is REAPER's "SimpleWindowed" mode. Audible warble on large + transpositions; **`set_formant_shift` is an explicit empty stub** → no formant preservation. + Usable for loop/phrase Preserve; replaceable by route (b) if not. + - **CPU / polyphony:** `PitchShiftBlock` is O(length) per block — a few mults + one OLA + crossfade branch per frame, **no FFT**. Per-voice cost is modest; **N polyphonic voices + each running one is feasible** within RT discipline. If the aggregate cost is material, a + **Preserve-mode-specific voice cap** (below the Varispeed cap) is the pressure valve — + flagged in Verify, set from measured per-voice budget at build. +- **Formant-preserving / studio-grade time-stretch (elastique-class): NOT in WDL, confirmed.** + REAPER's elastique is **licensed (zplane)**, not in the vendored tree (grep found only + unrelated libpng/giflib string matches). Formant-correct duration-preserving repitch is + **unavailable without a new third-party dependency** (JUCE / rubberband / signalsmith each a + new-dependency fork with D-A weight — not proposed). Stated, not worked around. +- **Recommendation:** the **Preserve** engine (S16-F2) is `WDL_SimplePitchShifter` (route a, + low-cost proof) or a hand-rolled pure `pitch_shift` module (route b, held quality upgrade). + The **pitch-envelope** modulation stays hand-rolled over whichever engine (a per-frame + `ratio_` multiply under Varispeed, a per-frame shift-amount add under Preserve). + `WDL_Resampler` (sinc) is a held **Varispeed-quality** upgrade only. + +### Sequencing (S15/S16 against S7 stereo, S10 editor) + +S15 and S16 are **S3-core extensions** — they touch the engine Daniel smoke-tests, like S7. +They are **channel-count-agnostic by construction**: the play-mode envelope is a per-frame +**amplitude** function, and 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 **S10–S13 build their +> interaction UX with the current drawing and adopt the Phase L kit when it lands — they +> are not gated on Phase L.** LICE/SWELL-only, the pure-geometry-module discipline, RT +> discipline, and the read-only-over-bank / VST3-class-UID-unchanged guardrails all hold +> exactly as before — a visual refresh is not a data-ownership or compat event. + +## ReaSampler 9000 — the UX overhaul (S10–S13; DAW-tested S1–S6, "the UX is awful") + +**The bar is set: better than ReaSamplOMatic5000.** Daniel DAW-tested the S1–S6 +instrument and the verdict was that it *works* but the UX is unacceptable — "this is +supposed to be better than ReaSamplOMatic5000." The S1–S6 editor was a spike-grade LICE +panel: a clickable sample list, zone rows each carrying **seven tiny ±1 nudge/delete +mini-buttons** (low-/low+/high-/high+/root-/root+/delete), text-only labels, **no keyboard +visualization, no waveform, no drag interaction of any kind, no scrolling** for long lists. +Setting a zone from C1 to C4 by clicking "+" thirty-six times is the catastrophe; the rest +(no way to *see* a sample, no loop editing by eye, unreachable rows past the panel bottom, +a fixed envelope) compound it. The overhaul is scoped as **S10–S13**, sequenced so the +friction Daniel feels every test pass is removed first. + +### 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 S1–S6 instrument, alongside the UX-overhaul directive. + +- **Propagate the display name** across user-visible surfaces: the VST3 class **display + name** string in the factory registration, the **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 (S10–S13) adopt that kit when it lands; they are not gated on it. + +## Drop-and-load — drag a capture onto a track's FX button (S17 spec) + +**The gesture.** While a capture is dragged out of the `bank_panel`, a track's TCP **FX +button** becomes a drop zone. Dropping the capture there **instantiates a ReaSampler 9000 +on that track with the dragged capture already loaded and selected for playback** — one +gesture from bank to playable instrument. This is the *third* integration gesture: capture +(extension), placement-into-arrange (extension), and now **placement-of-the-player** +(this wave). It is drop-and-load, not drop-to-arrange — no media item touches the timeline. + +**Why it needs a new drag mode (the CF_HDROP path can't carry it).** Today's drag-out +(M11) becomes an **OS file drag** (`CF_HDROP` via `drag_out` + `drag_out_win`) the moment +the pointer leaves the panel client rect. REAPER's TCP FX button is **not** a native drop +target that instantiates a plugin-with-a-file, so this feature cannot ride the OS-drag +path: an OS drop of a WAV onto the FX area does not create "an instrument preloaded with +that WAV." It requires an **internal drag** where the extension itself tracks the pointer +over REAPER's own UI, detects the FX-button hover, and on release **drives the insert +itself**. The extension is the actor for the whole gesture. + +**The two-part mechanism.** + +1. **Internal-drag hover detection (extension-side, pure + shell).** The `drag_out` pure + module gains a **third `DragGesture`** beyond `Internal` (bank-to-bank) and `OsDrag` + (M11) — `InstrumentDrop`. The gesture decision is refined: leaving the panel client + rect no longer *immediately* means OS-bound. Instead: + - Pointer **inside** the panel client rect → `Internal` (unchanged bank-to-bank drag). + - Pointer **outside the panel but still over REAPER's own window/UI** → + `InstrumentDrop` (new — the shell hover-tracks the TCP FX button and highlights it). + - Pointer **left REAPER entirely** (Explorer / another app) → `OsDrag` (unchanged M11). + + The pure module stays REAPER-free: it decides `InstrumentDrop` vs. `OsDrag` from + position **plus an "over-REAPER's-own-UI" predicate the shell supplies** (the shell owns + the REAPER window/hit query; the pure layer owns the set/boundary algebra). Mirror of how + M11 kept `decideGesture` pure over a rect the shell supplied. The shell then resolves the + pointer to a track + FX-button hotspot, highlights it, and on release drives the drop. + +2. **FX-button drop → add-VST + load-capture (extension-side shell, then instrument + seam).** On release over an FX button the shell: + - Adds a fresh instance: `TrackFX_AddByName(track, "VST3:ReaSampler 9000", /*recFX*/ + false, /*instantiate*/ )`. **Verified present** in + `reaper_plugin_functions.h`: + `int TrackFX_AddByName(MediaTrack* track, const char* fxname, bool recFX, int + instantiate)` — a **negative** `instantiate` always creates a new effect (per the + header comment); the `"VST3:"` prefix selects the format. Captures the returned FX + index (or `-1` on failure). + - **Loads the dragged capture into that instance via the load-capture seam** (below). + - Wraps the whole thing in one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the + gesture is one Ctrl-Z — the same discipline the bank verbs use. + +**The ReaSampler 9000 load-capture seam (the hard coupling — MUST be added; does not yet +exist).** The Phase S spec today gives the instrument a **live-state *read* seam** (it +reads bank index + mapping from `"reasampler"` ext-state via the bridge — §The two seams) +but **no entry point for an external actor to say "this fresh instance should play *this +specific* capture."** Reading the bank is not the same as being *pointed at one sample*. +This wave is the reason to add that seam, and the seam lands **inside the instrument** +(the `phase-s` artifact), not the extension. + +**Mechanism (SETTLED — (B) VST3 component-state injection).** Right after +`TrackFX_AddByName` returns the new FX index, the extension writes the instance's component +state directly — the same blob the instrument's `getChunk`/`setChunk` round-trips — with +the target capture pre-selected. Deterministic, no shared-state race, no cross-process +handshake; it uses the instrument's own persistence format. The state-set path is +`TrackFX_SetNamedConfigParm` — **verified present** in `reaper_plugin_functions.h`: `bool +TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char* +value)`, and the header documents the write-parms `vst_chunk` / `vst_chunk_program` as the +base64-encoded VST-specific chunk. So the injection call is +`TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", )`. + +**Load-bearing caveat — `vst_chunk` is the plugin's own serialized chunk.** `vst_chunk` is +ReaSampler 9000's **own** base64-encoded serialized state (its `getChunk`/`setChunk` +FXP/FXB-style blob), **not** a raw VST3 `IComponent::setState` stream that REAPER +re-marshals into the plugin. The extension therefore has to construct **exactly the +instrument's own state-blob bytes** with the capture pre-selected — REAPER does not +translate a neutral state representation on its behalf. This makes the **component-state +blob format a shared cross-artifact contract** — one that is **still being defined in Phase +S** — and a **coordination dependency between the extension and the instrument:** both must +agree on the exact byte layout that ReaSampler 9000's `setChunk` accepts before either half +is final. The load-capture seam and the component-state persistence work (§Where the toggle +lives / component-state version bumps) share this one blob format. + +**Rejected alternative — (A) fresh-instance ext-state handshake.** The extension writes a +small "pending load" hint into `"reasampler"` ext-state keyed to the target track/FX (a +capture id + a target GUID); a freshly-instantiated ReaSampler 9000 reads it on init via +the bridge it already uses, claims + clears the hint, and self-selects that capture. It +would keep the artifacts loosely coupled through the one ext-state seam they already share +and avoid the extension hard-coding the instrument's state format — but it **loses on the +claim/clear race:** "which instance claims which hint" needs a stable key and a +cross-process handshake to get right, and (B) sidesteps that entirely by writing the state +directly and deterministically. + +**Coexistence with the OS drag-out (disambiguation contract).** The two OS-vs-internal +modes are disambiguated **by pointer location, not a mode toggle** — the user never picks +"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", )`. 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 0–1's build shape. + optional-forever. Do not let their feature lists drive Tier 0–1's build shape. **Note:** + S7 stereo is *not* a Tier-2 feature — it is a channel-count dimension on the existing + Tier 0–1 engine, orthogonal to Tier 2's velocity-layers / round-robin / per-sample trim. + (S7's stereo loop read is the same loop the core already has, extended per-channel — not + the Tier-2 "sustain loops" feature.) **Likewise S15/S16** (Trigger/Gate modes + pitch + envelope) are Daniel-directed engine features on the Tier 0–1 core, *not* Tier 2/3 — the + AHDSR hold, Trigger one-shot, start point, and AD pitch envelope are orthogonal amplitude- + shape / read-rate dimensions, not the held velocity-layers / round-robin / filter work. +- **S15/S16 params are performance choices, not bank facts.** Play mode, start point, + %-length, fades, AHDSR, and the pitch envelope are per-instance performance-map state + (component state), never written to `Sample` or the bank (D-B). The bank carries file + facts (root note, loop intrinsic, channel count); the instrument owns how they are played. +- **S15/S16 stay channel-count-agnostic (S7 interplay).** The mode/envelope logic is + per-frame amplitude and read-rate, independent of the S7 channel dimension. Any S15/S16 + code that assumes a fixed channel count (mono) — rather than operating per-frame pre-mix — + is a bug that would collide with S7. Spec and build them channel-agnostic. +- **Trigger ignores note-off; choke is out of scope (S15).** In Trigger mode note-off is a + no-op and the one-shot plays to `playEnd`. Choke-on-note-off / choke-groups are held + (fork S15-F1, Tier-3-adjacent) — do not add a choke path in S15. +- **Pitch envelope is off by default (S16).** Default-disabled → offset always 0 → 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. diff --git a/PLAN.md b/PLAN.md index 3afc89e..c273357 100644 --- a/PLAN.md +++ b/PLAN.md @@ -86,6 +86,17 @@ landed milestone. > held, Tier 3 optional-forever); D-D embedded TCP/MCP UI **scheduled** as a later > in-phase point (after the main editor exists). > +> **Visual design moved to Phase L (2026-07-26).** The look-and-feel work originally +> drafted here as **S0-DS** (shared LICE drawing kit) and **S14** (dock-panel refresh) has +> been lifted out of Phase S into its own **Phase L** (Look-and-feel), taken up by a +> parallel team on `dev` so Phase S feature work proceeds ungated. See **PLAN.md §Phase L +> + CONTEXT.md §Phase L on `dev`** and `docs/product/visual-design-language.md` (on `dev`). +> S10–S13 build their interaction UX with the **current** drawing and **adopt the Phase L +> kit when it lands — they are not gated on Phase L.** +> +> **Numbering note:** S0-DS and S14 are removed (moved to Phase L); S7–S13 keep their +> numbers. +> > **Second build artifact (load-bearing, flagged up front):** Phase S produces a > *separate* VST3 binary alongside `reaper_reasampler`. The Steinberg VST3 SDK is a > **new vendored dependency** (vendor at the spike — an implementation-time @@ -110,22 +121,21 @@ working hit-test; the VST-host bridge resolves `GetProjExtState` by name and rea known `"reasampler"` value. Nothing plays yet — this is the loading/drawing/bridge proof. -- [ ] CMake second target: a separate VST3 module artifact built alongside +- [x] CMake second target: a separate VST3 module artifact built alongside `reaper_reasampler` (Windows VST3 export/bundle wiring; `GetPluginFactory` + `InitDll`/`ExitDll` — **verify exact export names against the vendored SDK**). -- [ ] `SingleComponentEffect` skeleton: factory + class registration, `initialize` +- [x] `SingleComponentEffect` skeleton: factory + class registration, `initialize` declaring an event-input bus + an audio-output bus (no audio input), `setupProcessing`, `setActive`, empty `process`. Loads silently in REAPER. -- [ ] `IPlugView`↔LICE bridge spike: open a plugin editor window hosting a LICE-drawn +- [x] `IPlugView`↔LICE bridge spike: open a plugin editor window hosting a LICE-drawn surface (window creation/sizing, host→draw/hit-test event routing), reusing the `bank_panel` LICE/SWELL competence. **The decision's one real unknown — prove it here.** (VSTGUI is the noted fallback only if this proves gnarlier than the panel work suggests.) -- [ ] Bridge read spike: resolve `GetProjExtState`/`EnumProjExtState` by name over the - host callback (`hostcb` opcode `0xdeadf00d`), fetch host project context - (`0xdeadf00e`), and read a known `"reasampler"` ext-state value. **Verify opcodes + - marshalling against `reaper_plugin.h` / `video_processor.h` / - `reaper_plugin_functions.h`.** +- [x] Bridge read spike: resolve `GetProjExtState`/`EnumProjExtState` by name via + `IReaperHostApplication::getReaperApi` (host project context via `getReaperParent(3)`) + from `reaper_vst3_interfaces.h`, and read a known `"reasampler"` ext-state value. + (The `0xdeadf00d`/`0xdeadf00e` opcodes are the VST2 path — verified not applicable here.) ## S2 — `Sample` intrinsic fields (root note + loop points; in the *extension*) **Goal:** Add the two bank-intrinsic seam fields to `Sample` — **root note** (MIDI @@ -143,13 +153,13 @@ additive). **Depends on:** nothing in Phase S (extension-only; can land before or in parallel with S1). -- [ ] Add `rootNote` (optional MIDI note) + `loopStart`/`loopEnd` (optional +- [x] Add `rootNote` (optional MIDI note) + `loopStart`/`loopEnd` (optional sample-accurate loop points) to `Sample`; JSON serialize/deserialize with clean defaults for samples lacking them (additive, backward-compatible — mirror of how `provenance` was added). -- [ ] Populate the fields on capture where derivable (root note) / settable (loop +- [x] Populate the fields on capture where derivable (root note) / settable (loop points); leave them cleanly empty otherwise. No existing `Sample` field changes. -- [ ] Tests: full round-trip lossless including the new fields; a legacy `Sample` +- [x] Tests: full round-trip lossless including the new fields; a legacy `Sample` JSON (no new fields) parses with defaults and re-serializes without loss; additive invariant (no change to existing fields, dedup, tier, or `BankIndex` behavior). @@ -168,15 +178,15 @@ maps a (note, velocity) to the correct sample/zone; the core takes and returns o plain data (no VST3/REAPER types) — enforced by the test target linking neither SDK. **Depends on:** S2 (consumes `rootNote` / loop points as core inputs). -- [ ] Voice engine: polyphonic voice allocation (note-on/off, bounded voice stealing), +- [x] Voice engine: polyphonic voice allocation (note-on/off, bounded voice stealing), per-voice state, mono-and-basic-polyphony sufficient for Tier 0. -- [ ] Amplitude envelope (ADSR) math — asserted against a known signal. -- [ ] Repitch/interpolation from root note (chromatic pitch ratio across the +- [x] Amplitude envelope (ADSR) math — asserted against a known signal. +- [x] Repitch/interpolation from root note (chromatic pitch ratio across the keyboard); loop-point-aware sustain for held notes. -- [ ] Keymap model + resolution: key ranges/zones (Tier-1 shape) and the +- [x] Keymap model + resolution: key ranges/zones (Tier-1 shape) and the (note, velocity) → sample/zone query; Tier-0 chromatic-from-single-root as the degenerate case. -- [ ] Tests: voice allocation under polyphony + stealing; ADSR envelope shape; +- [x] Tests: voice allocation under polyphony + stealing; ADSR envelope shape; repitch pitch-ratio correctness; keymap resolution (single-root chromatic + zoned); core boundary is plain-data-only (no VST3/REAPER types). @@ -194,15 +204,22 @@ following the active project works; it never captures and never inserts into the arrange (read-only over the bank). **Depends on:** S1, S2, S3. -- [ ] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus, - drive the S3 core, write per-voice audio to the output bus. -- [ ] Live-state seam: read the bank index + selected sample's root note from +- [x] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus, + drive the S3 core, write per-voice audio to the output bus. (Block-granular event + timing at Tier 0; sample-accurate offset scheduling is a later tier.) +- [x] Live-state seam: read the bank index + selected sample's root note from `"reasampler"` ext-state via the bridge; resolve the WAV audio path the M4 - project-relative way (shared convention with `persist`, not re-implemented). -- [ ] Sample selection UI (minimal, in the `IPlugView` LICE editor or a - parameters-only default view): choose which bank sample this instance plays. -- [ ] Tier-0 playback: chromatic-from-root, basic polyphony, amp envelope, - velocity→volume — plays in REAPER's routing/record/render path like any VSTi. + project-relative way (shared convention with `persist`, not re-implemented — the + parent-of-.rpp derivation is extracted to `capture_paths::projectDirOfRpp`, which both + `persist` and the bridge call). Bank JSON parsed via the shared `bank_book` path (the + spike string-scan reader retired); ext-state key names shared via pure `ext_keys.h`. +- [x] Sample selection UI (minimal, in the `IPlugView` LICE editor): a clickable list + of the bank's samples; the pick is the instance's own VST3 component state + (setState/getState), never written back to the bank. +- [x] Tier-0 playback: chromatic-from-root, basic polyphony (16 voices), amp envelope, + velocity→volume — plays in REAPER's routing/record/render path like any VSTi. Sample + load / decode / keymap build happen off the audio thread and hand to `process` via a + lock-free atomic pointer swap (graveyard-reclaim); `process` never allocates. ## S5 — Tier 1: "a keymap" (zoned multisamples, per-sample root notes) **Goal:** Multiple bank samples zoned across the keyboard (key ranges), each with its @@ -217,15 +234,15 @@ instrument (performance map) while root notes come from the bank intrinsics (S2) editing the keymap does not touch the bank. **Depends on:** S4. -- [ ] Keymap editor in the `IPlugView` LICE editor: assign bank samples to key ranges +- [x] Keymap editor in the `IPlugView` LICE editor: assign bank samples to key ranges (low/high note per sample), each with its own root note (from S2 intrinsics, overridable in the performance map). -- [ ] Tier-1 playback: zoned resolution — a note picks its zone's sample and repitches +- [x] Tier-1 playback: zoned resolution — a note picks its zone's sample and repitches from that sample's root note; one sample per key-region. -- [ ] Performance-map persistence: the keymap (zones, per-sample assignment) is the - instrument's own state — held in the instrument (read/written over the live - `"reasampler"` seam per D-B's data-ownership split), never written back as a bank - intrinsic. +- [x] Performance-map persistence: the keymap (zones, per-sample assignment) is the + instrument's own state — held in the instrument as VST3 component state (setState/getState) + per D-B's data-ownership split; the live `"reasampler"` seam is read-only (bank + + intrinsics in, nothing written back), never written back as a bank intrinsic. ## S6 — embedded TCP/MCP UI (D-D — scheduled in-phase, after the editor) **Goal:** Render a compact keymap/level strip **inline in the track/mixer control @@ -243,29 +260,1019 @@ as the main editor is reused. build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`. -- [ ] Implement `IReaperUIEmbedInterface` on the VST3; draw a compact keymap/level +- [x] Implement `IReaperUIEmbedInterface` on the VST3; draw a compact keymap/level strip inline in the TCP/MCP using the same LICE surface as the editor. -- [ ] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects +- [x] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects the live keymap/levels. +## S7 — stereo channel mode (mono | stereo; core channel dimension + bus negotiation) +**Goal:** Give the instrument a per-instance **channel-mode toggle — 1 (mono) or 2 +(stereo)** — that "works with the REAPER audio bus automatically." Mono keeps today's +downmix path; stereo grows the S3 core a **channel dimension** (2-channel sample data, +per-voice stereo render, stereo interp/loop) and negotiates the VST3 output bus so +mono/stereo just works in REAPER's routing. **This is an S3-core extension, not a shell +hack** — it touches the engine Daniel smoke-tests, so it sequences first after the +editor/embed work. CONTEXT.md §Phase S (channel mode, D-E). **Decided direction +(2026-07-26); leans below are build-time residuals, not open forks.** +**Verify (in DAW):** an instance set to stereo plays a stereo capture in true stereo, +its VST3 output bus negotiated to 2 channels via `setBusArrangements` so REAPER routes it +without manual channel wiring; an instance set to mono plays the existing downmix path; a +mono source in stereo mode plays dual-mono (centered); a stereo source in mono mode +downmixes (existing policy); the mode is per-instance state that survives project +save/reopen (component state, like the selected sample); the pure core's stereo render is +asserted against a known two-channel signal (mirror of `peaks`), and mono behavior is +unchanged (regression). +**Depends on:** S3 (extends the core), S4 (extends the process/bus shell). Independent of +S8/S9. + +- [x] Core channel dimension (pure, S3 extension): `SampleData` carries 1- or 2-channel + decoded PCM (`frames` + optional length-matched `framesR`; `channelCount()`); + `Voice::renderFrameStereo` + a `VoiceEngine::render(left,right,n)` overload produce a + per-channel frame sharing one read head + one envelope tick; stereo linear interpolation + + loop read per channel. Mono stays the degenerate case (`renderFrame` reads channel 0 only, + byte-identical). Tests: stereo render asserted against a known 2-channel signal; dual-mono; + per-channel repitch + additive mix; mono render unchanged (regression) — sampler_core_tests. +- [x] Channel-mode toggle as per-instance state: `ChannelMode {Mono,Stereo}` in the + instrument's own component state (v4 = v3 + a channel-mode byte; setState/getState); + default mono. Cross-mode policy in `decodeChannels`: **mono source + stereo mode → + dual-mono**; **stereo source + mono mode → downmix** (existing decode-side policy). The + toggle lives in the instrument, never written to the bank (D-B). v1/v2/v3 blobs lift to v4 + with mono default; round-trip + lift tests — sample_map_tests. +- [x] Shell: `decodeRelative` fills 1- or 2-channel `DecodedZonePcm` per the active mode + (source channel count from the WAV layout); the process path renders the host's negotiated + output channel count (stereo into ch0/ch1, mono into ch0) — RT discipline unchanged. +- [x] VST3 bus negotiation: `setBusArrangements` accepts only the mode's arrangement + (kMono/kStereo), else rejects (kResultFalse) but keeps a valid mode arrangement so + `getBusArrangement` (base default) reports it; a runtime mode change repoints the output bus + + calls `restartComponent(kIoChanged)` so REAPER re-negotiates. **Verified** against the + vendored Steinberg SDK (`ivstaudioprocessor.h` contract, `vstsinglecomponenteffect.cpp` + base impl, `ivsteditcontroller.h` kIoChanged); see handoff notes. + +## S8 — ingest through the bank (one gesture: capture/import into bank + assign to instance) +**Goal:** Loading a sample into the sampler is **one gesture** — capture/import-into-bank +**and** auto-assign to the active sampler instance. **The extension owns ingest** (it has +arrange access, media-explorer access, and drop-target surface on its own panels); the +instrument stays a **read-only bank consumer**. This lives in the *extension* codebase +(actions + bank_panel + capture/insert), routing through the existing capture add-path and +the live `"reasampler"` seam the instrument already reads. CONTEXT.md §Phase S +(ingest-through-bank contract). **Decided direction "option 1" (2026-07-26).** +**Verify (in DAW):** a one-click "capture selected item / time-selection into the bank and +assign to the active instance" action captures via the existing capture path (never +auto-inserting into the arrange — load-bearing principle intact) and the target instance +plays the new sample on its next reload; a Media Explorer file imports into the bank and +assigns the same way; a file dropped onto a ReaSampler panel surface ingests into the bank +and assigns; the instrument never captures or imports (read-only over the bank throughout). +**Depends on:** S4 (an instance to assign to), M7 capture add-path, B2 (active-bank add +target). Best paired with S9 so assignment refreshes hands-free; functional without it +(assign triggers a reload on the target instance directly). + +- [ ] "Capture selected item / time-selection into bank + assign to active instance" + action (`command_id`/`gaccel`/`hookcommand`, MIDI-bindable): reuse the existing capture + request path (`CountSelectedMediaItems`/`GetSelectedMediaItem` + `GetSet_LoopTimeRange` + as the capture inputs), add the resulting `Sample` to the active bank, then assign its + id to the target instance. **Never inserts a timeline item** (capture/placement stay + separate — the assignment is a bank-index + instance-selection act, not a placement). +- [ ] Media Explorer import → bank → assign: read the Media Explorer's current selection + via `MediaExplorerGetLastPlayedFileInfo` (path + selection range), import the file into + the bank (existing import/capture add-path), assign to the target instance. **Honest SDK + limit (verified against the vendored headers):** the Media-Explorer surface is thin — + `OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the *one* + last-played/selected file + its range) are the whole contract; there is **no** + enumerate-selected-files and **no** register-a-drop-handler-on-the-Media-Explorer API. + So ME import is *single-file, pull-on-action* (an action the user fires while a file is + selected in the ME), not a push/drop from inside the Media Explorer. **Spike:** confirm + `MediaExplorerGetLastPlayedFileInfo` returns a usable path+range for a merely-*selected* + (not-yet-played) file, or whether a play is required first. +- [ ] Drag-and-drop onto ReaSampler surfaces: accept an OS file drop onto the docked + `bank_panel` (and its bank/tab regions) → ingest into the bank → assign. **Honest SDK + limit (verified):** REAPER exposes **no** drag-drop registration API; drop handling is on + ReaSampler's *own* HWNDs via SWELL/Win32 (`WM_DROPFILES` / an `IDropTarget` on the panel + HWND), the same surface the panel already owns. **Assess-and-flag (spike, do not promise + here):** a drop *onto the VST3 editor window* — whether the `IPlugView` HWND can accept an + OS file drop and relay it to the extension as a bank-ingest request (the instrument does + **not** ingest; it forwards a request to the extension over an agreed seam). Reported + honestly as a spike because it crosses the two-artifact boundary and the relay mechanism + is unproven; if it proves gnarly, drop-onto-panel is the shipped path and drop-onto-editor + is deferred. +- [ ] "Assign to instance" seam: how the ingest action names the target instance and hands + it the new sample id. Lean (build-time residual, not a fork): the active/last-focused + instance is the target, discovered via the host context the bridge already resolves; the + assignment is the same instance-owned selection state S4 already persists, so a reload + picks it up. If the change-detection seam (S9) exists, assignment refreshes hands-free; + without it, the ingest action pokes the target instance's reload directly. + +## S9 — bank-generation change-detection (recapture / ingest refreshes instances hands-free) +**Goal:** Because instances reference sample **ids**, a **recapture** (M10) landing under +the same id — or an **ingest** (S8) touching the active bank — should refresh playing +instances **hands-free**, without the user re-opening each editor. Add a **bank-generation +counter** to `"reasampler"` ext-state that the extension bumps on any bank-content +mutation, and that the instrument polls off the audio thread on a safe cadence, calling its +existing `reloadFromBank()` when the generation changes. CONTEXT.md §Phase S +(bank-generation seam). **Closes the missing change-detection trigger the recapture +auto-update story needs.** +**Verify (in DAW):** a recapture that regenerates a sample already assigned to a live +instance refreshes that instance's playback within a bounded cadence, no editor re-open; an +ingest (S8) that updates the active bank likewise refreshes assigned instances; the poll +runs off the audio thread (never in `process`) and triggers the existing off-thread reload +path; instances not referencing a changed sample do not audibly glitch (reload is atomic — +the S4 graveyard-reclaim handoff); a project with no generation stamp (pre-S9) defaults +cleanly (treated as generation 0; first bump refreshes). +**Depends on:** S4 (the off-thread `reloadFromBank` + atomic handoff this drives). Writer +side is extension-only and independent of S8; consumed by S8 and M10 recapture. Best landed +alongside S8. + +- [x] Writer (extension): a monotonic **bank-generation counter** stamped into + `"reasampler"` ext-state (new `ext_keys.h` constant — forever-stable spelling), bumped + on every bank-content mutation that changes what an instance would play (capture add, + recapture-in-place, sample-remove, move/copy affecting the active bank). Additive to the + persist blob; defaults to 0 for projects saved before the stamp exists. +- [x] Reader (instrument): poll the generation over the bridge on a safe **off-audio-thread + cadence** (a UI/timer tick, not `process`), compare to the last-seen value, and call the + existing `reloadFromBank()` on change — reusing S4's atomic pointer-swap handoff so a + refresh mid-play does not glitch. No new audio-thread work; no allocation in `process`. +- [x] Cadence + coalescing: pick a poll interval that is responsive but cheap (build-time + residual — a low-frequency UI timer, coalescing multiple bumps between polls into one + reload). **Must-verify before build:** that a bridge ext-state read on the instrument's + UI/timer thread is safe against a concurrent extension write (the read already tolerates a + stale value by design — it reloads on the *next* poll; confirm no torn-read hazard for the + single integer generation key). + +> **S0-DS moved to Phase L (2026-07-26).** The shared LICE drawing kit that stood here is +> now **Phase L point L1** on `dev` — see PLAN.md §Phase L + CONTEXT.md §Phase L and +> `docs/product/visual-design-language.md` (all on `dev`). S10–S13 below build with the +> current drawing and **adopt the L1 kit when it lands — not gated on Phase L.** + +## S10 — capture-first editor: browser + guided single-capture setup ("ReaSampler 9000" UX overhaul, part 1) +**Goal (REVISED 2026-07-26 — workflow-first reframe, Daniel):** Rebuild the editor's +default face around the **primary flow = one capture, fast**, not a keymap. A giant list of +"item" blocks is visually useless; most instances play a *single capture*, and zones are a +nice-to-have. So the default view is a **capture browser** (scannable cards with peak +thumbnails, name, root/key badge; **bank filter**) feeding a **guided single-capture setup** +(root note, play-mode basics, level) — and the keyboard strip serves the *single-capture* +case first (shows where the capture sits / its root). **Time-to-first-note is the metric.** +Multi-zone keymap editing is **demoted to an opt-in "Zones" panel** (S10-Z below), not the +default. The keyboard-strip drag machinery is still built here, but in service of the +capture-first layout. All layout/hit-test math is **pure geometry** (new `keyboard_strip` + +a `capture_browser` layout module — mirrors of `mode_switch`/`editor_geometry`); the LICE +draw + drag-state machine is the editor shell. RT discipline untouched (edits commit +off-thread via `commitMapAndReload`); the instrument stays a **read-only bank consumer**. +CONTEXT.md §Phase S (ReaSampler 9000 UX — capture-first editor). + +**Policy reversal — fresh instance is SILENT, nothing auto-selected (was S4).** The S4 +"first sample plays" fallback is **removed**: on open with no stored selection, the +instrument plays **nothing** and the editor shows a clear **empty state** ("pick a capture") +— it does not auto-play sample #1. Retires the `selectSample` first-sample fallback +(`sample_map.cpp` "No stored id → fall back to the FIRST sample") and the processor's +Tier-0 fallback that resolved it; an empty stored id now resolves to silence. A capture is +loaded when the user picks one (or via S13 drop-to-load / S8 ingest). This is a deliberate +reversal of the S4 convenience default, not a regression. + +**Verify (in DAW):** a fresh instance plays **nothing** and shows the "pick a capture" +empty state (no auto-play of sample #1); the capture browser draws **peak thumbnails** +(the `Sample` peaks bank_model already carries — same data the dock panel thumbnails use), +name, and a root/key badge where present, and is **filterable by bank** (bank_book named +banks); picking a capture loads it, shows it (waveform/peaks + its root on the keyboard +strip), and it plays repitched from its root; time-to-first-note is a pick-then-play, not a +list-scroll; the keyboard strip shows the single capture's root and is draggable to set it; +the pure geometry modules are CTest-green (browser card/grid layout + hit-test; strip +edge-grab/body-move/key→note) with no host types at their boundary; the ±1 nudge-button row +is gone. +**Depends on:** S4 (the selection state + reload path this reverses the fallback on), S1 +(the LICE `IPlugView` drag/event routing — extends the click-only `wndProc` to +`WM_MOUSEMOVE`/`WM_LBUTTONUP`), S5 (the `PerformanceMap`/zone model the opt-in Zones panel +edits — but the default face does not require a keymap). **Adopts the Phase L kit when +available — not gated on Phase L.** S10 builds its browser cards + keyboard strip with the +current LICE drawing; when Phase L's L1 kit lands on `dev`, this surface adopts it (the one +source of drawing). The drag machine's `WM_MOUSEMOVE` tracking also lights the kit's +**hover** states at near-zero marginal cost once the kit is present. + +- [ ] No-auto-select + empty state (the policy reversal): remove the `selectSample` + first-sample fallback (`sample_map.cpp`) and the processor's Tier-0 fallback that + consumed it — an empty stored selection resolves to **silence**, not sample #1. The + editor draws a clear **empty state** ("pick a capture" affordance) when nothing is + selected. Pure change is testable (empty id → `nullopt`); the empty-state draw is shell. +- [ ] Capture browser (pure layout + shell draw): grow `SampleChoice` to carry the + **peak thumbnail data** (from the `Sample` peaks bank_model already stores — the same + peaks the dock panel draws), the **root/key badge** (S2 `rootNote` intrinsic / the + optional musical key), and its bank. A new pure `capture_browser` module lays out + scannable **cards/rows** (card rect grid, thumbnail rect, hit-test a point → card) — no + host types at the boundary, unit-tested. The shell draws each card's peak thumbnail + + name + badge in LICE (house palette) and routes a click to select. +- [ ] Bank filter (pure + shell): a filter/tab strip over the browser that narrows the + drawn cards to a chosen bank_book bank (or "all"). Filter-tab layout + hit-test pure + (mirror of `mode_switch`); the active-filter state is transient UI state; the shell draws + the tabs and applies the filter to the card list. (Type-to-filter search folds in from + S12 — see S12's boundary note; a name-substring filter over the same card list.) +- [ ] Guided single-capture setup (the fast path): once a capture is picked, a prominent, + self-explanatory setup surface — **root note** (settable on the keyboard strip / typed), + **play-mode basics**, **level** — sized for the single-capture case, not a zone table. + Graphic and descriptive; the point is to get from pick → set → play with no hunting. +- [ ] Pure `keyboard_strip` geometry module (serves the single-capture case first): map a + MIDI key span across a strip width (128 keys → pixels, reusing the S6 `embed_strip` + key-span idiom); a **root marker** for the loaded capture; `pixel→note` and a `keyAtPoint` + for click-to-set-root; a drag-delta resolver `(grabbedField, startNote, dxPixels) → + newNote`; **per-zone bar rect** + **edge-grab hit regions** (resize handles vs. body + move-handle) for the opt-in Zones panel. No VST3/REAPER/LICE types at the boundary; + unit-tested (root marker, edge grabs, body-move delta, key mapping, clamps low≤high, + boundary rounding). Mirror of `mode_switch`/`editor_geometry`. +- [ ] Editor shell drag-state machine: `WM_LBUTTONDOWN` grabs a card / a key / a zone + edge-or-body, `WM_MOUSEMOVE` updates the in-flight edit against the pure resolver, + `WM_LBUTTONUP` commits via the existing `commitMapAndReload` (off-thread reload; RT path + untouched). Live visual feedback while dragging; a single undo-coherent edit on release. + +### S10-Z — Zones panel (opt-in multi-zone keymap editing; demoted from the default face) +The multi-zone keymap editor is now an **opt-in view/panel** ("Zones" toggle), not the +default. It reuses the same `keyboard_strip` geometry and drag-state machine: each zone a +bar over the keys it covers; **drag an edge** → low/high note; **drag the bar body** → move +the zone (span preserved); **click a key** → set/relocate the zone's root. This is the +capability RS5K structurally lacks (multi-zone in one instrument), kept as a *nice-to-have* +per Daniel's hierarchy — "most of the time the zones won't be used." Add/select/delete a +zone; overlapping zones render legibly and resolve first-match. The seven ±1 nudge/delete +mini-buttons are retired everywhere; delete is one affordance (a small × on the bar or a +keystroke). The `zoneHitTest`/±1 nudge path in `editor_geometry` is retired (a numeric +fallback for accessibility is a build-time residual, not a fork). +**Verify (in DAW):** the Zones panel is reachable via an explicit toggle (default view is +the capture browser + single-capture setup, not this); a zone's range is set by **dragging +edges** (not nudge clicks); body-drag moves the span; click-a-key sets the root (audible on +the next held note); zone add/select/delete work; the ±1 nudge row is gone. + +- [ ] "Zones" panel toggle (opt-in): the default editor face is the capture browser + + single-capture setup; a toggle reveals the multi-zone keymap editor. Toggle state is + transient UI state (or per-instance component state if it should persist — build-time + residual). +- [ ] Zone edit via the shared strip: draw the keyboard strip + zone bars in LICE, drive + the shared drag-state machine (edge = resize, body = move, key = root), commit via + `commitMapAndReload`. Zone add/select/delete as single affordances; ±1 nudge row gone. + +## S11 — waveform view with draggable loop points (UX overhaul, part 2) +**Goal:** Give each sample/zone a **waveform display** with **draggable start/end/loop +markers** — the S2 loop-point intrinsics and the S5 performance map already carry the data; +today there is no way to *see* a sample or *set* its loop by eye. Selecting a zone (or a +bank sample) shows its waveform (peaks via the existing `peaks` module, fed the decoded +PCM the shell already loads); drag the **loop-start / loop-end** markers to set the sustain +loop, snapping to zero-crossings (the S2 spec's zero-crossing-aware requirement). Loop +points are a **performance-map override on the zone** where set, seeded from the bank +intrinsic (D-B split: the bank carries the file-fact default; the instrument's drag is the +performance choice). All marker/waveform layout + hit-test is pure geometry; peaks compute +reuses `peaks`; the draw + drag is the shell. CONTEXT.md §Phase S (ReaSampler 9000 UX — +waveform view). +**Verify (in DAW):** selecting a zone shows its sample's waveform; dragging the loop-start +and loop-end markers sets the sustain loop and a held note audibly loops that region; +markers snap to the nearest zero-crossing; a sample with no loop shows the "no loop" state +and a held note past the end goes silent (existing core behavior); the waveform peaks match +the audio (mirror of the `peaks` envelope assertion); the marker geometry module is +CTest-green (px↔frame mapping, marker grab regions, clamp start≤end). +**Depends on:** S2 (loop-point intrinsics), S3 (loop-aware sustain the markers drive), S5 +(the zone the loop attaches to), S10 (shares the editor's drag-state machine + shell). The +zero-crossing snap is a small pure helper over the decoded PCM. + +> **Boundary note (S10 reframe, 2026-07-26):** the waveform view is now **central to the +> single-capture fast path**, not just per-zone. Selecting a capture in S10's browser shows +> its waveform (this is "see it" in pick → see it → play it); the loop-marker drag here +> extends that same waveform surface. S11's waveform draw is the same one S10's picked- +> capture view uses — build it once, S10 shows it read-only for the single capture, S11 adds +> the draggable loop markers. No renumber; S11 stays the loop-editing point. + +- [ ] Pure waveform/marker geometry: `frame↔pixel` mapping across the waveform rect, marker + x-position from a frame index, marker grab regions (start/end/loop-start/loop-end), + drag-delta `(grabbedMarker, dxPixels) → newFrame` with clamps (start≤end, in-bounds). A + **zero-crossing snap** helper: nearest sign-change frame to a target (pure, over the + decoded mono PCM). No host types; unit-tested. +- [ ] Waveform draw: compute peaks with the existing `peaks` module from the shell's already- + decoded PCM (no new decode path, no new WAV reader); draw the envelope in LICE in the + house style; draw the loop markers over it. Reuses the S10 drag-state machine. +- [ ] Loop-point edit → performance-map override: a dragged loop writes a per-zone loop + override (seeded from the S2 bank intrinsic, D-B), committed off-thread via + `commitMapAndReload`; the bank intrinsic is never written back (instrument is a read-only + bank consumer). Extends `PerformanceZone` with an optional loop override (additive, same + shape as `rootOverride`) + its component-state (de)serialize (version bump, back-compat + with S5's v2 map blob — a truncated/older blob defaults the override absent). + +## S12 — editor scale + ergonomics (UX overhaul, part 3; scrollable/searchable list, direct entry) +**Goal:** Make the editor usable **at bank scale** and close the remaining RS5K-parity +gaps: the sample list **scrolls** (today a long bank's rows run off the panel with no way +to reach them) and has a **type-to-filter search**; add **direct numeric entry** for a +zone's low/high/root (a click-to-type field over the strip, for precision the drag can't +hit) and an **ADSR control** for the amp envelope (S3 already has the ADSR math; today it +is fixed — expose attack/decay/sustain/release as draggable sliders, per-instance state). +This is the "sensible list handling + direct manipulation of the parameters that exist" +tier. All slider/scroll/search-box layout + hit-test is pure geometry; the shell draws + +routes; ADSR/scroll/filter state is instrument-owned (component state / transient UI +state). CONTEXT.md §Phase S (ReaSampler 9000 UX — scale + ergonomics). +**Verify (in DAW):** a bank with more samples than fit **scrolls** (wheel + drag) and every +sample is reachable; typing filters the list to matching names; a zone's low/high/root can +be **typed** (not only dragged) via a click-to-edit field; the amp envelope's ADSR is +**adjustable** (four draggable controls) and the change is audible + persists across project +save/reopen (component state); the scroll/search/slider geometry is CTest-green. +**Depends on:** S10 (the editor shell + drag-state machine + the **capture browser** the +scroll/search now apply to), S3 (the `AdsrParams` the ADSR sliders drive — already wired +into the voice engine; today they are fixed defaults), S5 (the map the numeric fields edit). + +> **Boundary note (S10 reframe, 2026-07-26):** the "sample list" S12 originally scrolled and +> searched **is now S10's capture browser** (cards with peak thumbnails, bank filter). What +> pulled INTO S10: the browser layout itself, the peak thumbnails, and the **bank filter** +> (a bank_book tab, distinct from name search). What stays in S12 and applies **to S10's +> browser**: (a) **scroll** for a bank longer than the panel, and (b) **type-to-filter +> search** (a name-substring narrow over the same cards, composing with S10's bank filter — +> bank filter picks the bank, search narrows within it). The scroll/search geometry is pure, +> layered over the `capture_browser` module S10 builds. Net: S12 = scroll + search over the +> S10 browser + numeric entry + ADSR; the browser *card* work is S10's. **S12 now also +> carries the S15/S16 control surfaces** (per-zone Gate/Trigger mode toggle, AHDSR hold +> control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch +> envelope depth/shape controls) — deferred here from S15 and S16 per spec. + +- [x] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a + bank longer than the panel is fully reachable; a **type-to-filter search** that narrows + the drawn cards to matching display names, **composing with S10's bank filter** (bank + filter selects the bank; search narrows within it). Scroll/search layout + hit-test is + pure geometry (visible-card window, scrollbar thumb rect, search-box rect), layered over + S10's `capture_browser` module; filter/scroll state is transient UI state. **Landed:** + pure `browser_scroll` module (`browser_scroll_tests`) — scrollContentHeight / max / clamp, + visibleCardRange window, scrolledCardCellRect, scrollThumbRect + thumbDragToOffset inverse, + searchBoxRect, nameMatchesQuery + filterNameIndices. Editor shell wires wheel (`WM_MOUSEWHEEL`), + thumb-drag (`DragKind::kScrollThumb`), and the search box (`WM_CHAR` -> `onSearchChar`, + composed into `rebuildVisible`). Scroll/search are transient (never persisted). +- [x] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip + (LICE text-entry idiom) so a precise note can be typed, not only dragged. Commits via + `commitAndReload` like every other edit. **Landed:** pure `note_entry` module + (`note_entry_tests`) — `parseNoteEntry` accepts a decimal integer OR a note name (C4==60), + clamps to [0,127], rejects garbage. Editor shell hosts three focusable fields (low/high/root) + on the Zones legend, committing on Enter through `commitAndReload`. +- [x] ADSR/AHDSR editor + S15/S16 control surfaces: draggable sliders over the S3 `AdsrParams` + (attack/**hold**/decay/sustain/release — hold is the S15 addition) plus the deferred S15/S16 + controls — per-zone Gate|Trigger mode toggle, Trigger %-length/fade-in/fade-out, Varispeed| + Preserve engine toggle, and the AD pitch-envelope enable/attack/decay/±semitone depth. All + edit the SELECTED zone's `ZonePlayParams` (instrument-owned, D-B; never the bank), round-trip + through the existing v3 component-state blob (no new persistence — the S15/S16 payload already + landed in the core pass), and commit off-thread via `commitAndReload`. **Landed:** pure + `param_slider` module (`param_slider_tests`) — control-panel stack layout, toggle-segment + split + hit-test, slider value<->pixel round-trip + clamping, point->control routing. The + shell owns the control-id -> engine-param binding + the value DOMAIN mapping (frames/fraction/ + semitones); the module stays engine-free. + +## S13 — drop-to-load: the S8 ingest story, folded into the editor UX (UX overhaul, part 4) +**Goal:** Make "load a sample into the sampler" **one gesture from the editor**: dropping +an OS file (or REAPER media item) **onto the editor window** ingests it into the bank and +assigns it to this instance — the RS5K "drag a file straight onto it" affordance, which is +the single biggest first-impression win RS5K has and we currently lack. This is the +**editor-window end of S8's `option 1` ingest** — the instrument does **not** ingest itself +(it stays a read-only bank consumer); the editor's drop handler **relays a bank-ingest +request to the extension** over the agreed cross-artifact seam, and the extension performs +the capture/import-into-bank + assign. S8 already flags drop-onto-editor as an unproven +cross-artifact **spike** — this point is where that spike is either proven and shipped or +falls back to the docked-panel drop path. CONTEXT.md §Phase S (ReaSampler 9000 UX — drop to +load / S8 relay). **Cross-artifact relay is a spike, not a promise — sequence after S8.** +**Verify (in DAW):** an OS file dropped onto the ReaSampler 9000 editor window ingests into +the active bank and this instance plays it (via the S8 capture/import add-path + S9 refresh, +or a direct reload if S9 absent) — **never** inserting a timeline item (capture/placement +separation intact); the instrument itself performs no capture/import/write (the editor only +*relays* the request to the extension); if the cross-artifact relay proves unworkable, the +drop-onto-docked-`bank_panel` path (S8) is the shipped ingest and this degrades cleanly with +a clear affordance pointing there. +**Depends on:** S8 (owns the extension-side capture/import + assign, and the relay seam), S1 +(the editor HWND that accepts the drop), S9 (hands-free refresh after assign; functional +without it via a direct reload). **Spike — do not promise the drop-onto-editor path until +the relay is proven.** + +- [x] Editor-window drop target: accept `WM_DROPFILES`/`IDropTarget` on the editor child + HWND (the same SWELL/Win32 surface `bank_panel` owns), extracting the dropped file + path(s). Windows-only (D5). This is the *acceptance* half; the ingest is the extension's. + **Landed:** the editor child window calls `DragAcceptFiles(TRUE)` on attach and handles + `WM_DROPFILES` (`reasampler_editor.cpp`). Windows-only (D5). +- [ ] Cross-artifact ingest relay (the S8-flagged spike): the editor hands the dropped + path + this instance's identity to the extension as a **bank-ingest request** over the + agreed seam (the instrument never writes the bank). **Prove the relay mechanism before + promising it**; if gnarly, fall back to the S8 docked-panel drop path and mark + drop-onto-editor deferred. **SPIKE VERDICT (ps-w12, 2026-07-27): DEGRADED — relay + deferred.** The instrument's REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY + (resolves only `GetProjExtState`, never `SetProjExtState`); a relay would need (a) a new + instrument WRITE seam into ext-state and (b) an extension-side timer poller servicing a + drop-ingest inbox key with a claim/clear nonce — the SAME cross-process handshake race the + S17 spec rejected for its own alternative (A). Both the read-only-instrument boundary and + the new poller are load-bearing design calls, so the relay is deferred to a future wave and + surfaced as a decision, not crossed unilaterally. The shipped ingest gesture stays + drop-onto-docked-panel (S8). The drop-onto-editor path degrades cleanly (below). +- [x] UX degrade path: when the relay is unavailable/unproven, the editor shows a clear + "drop files on the ReaSampler panel to add" affordance rather than silently swallowing the + drop — the shipped ingest gesture stays discoverable either way. **Landed:** the editor + ACCEPTS the drop and flashes a transient banner ("drop files onto the ReaSampler bank panel + to add them") that decays over a few sync ticks, plus a persistent affordance line in the + empty state ("drop a file onto the ReaSampler bank panel"). No file is ingested; NO timeline + item is ever inserted (the hard invariant — the editor only displays guidance). + +## S15 — sampling modes: Trigger vs Gate (per-sample play-mode; core + editor) +**Goal:** Give each played sample a **play mode** — **Gate** (classic held note) or +**Trigger** (one-shot) — a per-sample/per-zone performance choice (D-B, instrument-owned). +**Gate** is today's behavior grown from ADSR to **AHDSR** (adds a Hold stage): note-on → +attack/hold/decay/sustain, note-off → release, sustain **loop points apply** (S11's +draggable loop UI is Gate-mode UI). **Trigger** is a one-shot drum-pad: note-on fires +playback of a defined **% of sample length** with a **fade-in** and **fade-out** ramp, +**ignores note-off**, and uses **no sustain loop**. **Both** modes carry a **modifiable +start point** (playback begins at an offset into the sample, not always frame 0). This is +an **S3-core extension** (the engine Daniel smoke-tests) plus editor surfacing — the mode + +its parameters are instrument performance-map state, never a bank fact. CONTEXT.md §Phase S +(Sampling modes — Trigger vs Gate). **Daniel's feature set is settled; the leans below are +build-time residuals, not open forks — except the flagged forks S15-F1/F2.** +**Verify (in DAW):** a sample in **Gate** mode plays held with the AHDSR envelope (hold +stage audible between attack and decay), releases on note-off, and loops its sustain region +if loop points are set; a sample in **Trigger** mode fires a fixed % of its length on +note-on with audible fade-in/out, **plays through to completion regardless of note-off**, +and never sustain-loops; the **start point** offsets playback in both modes (a note starts +partway into the sample); the mode + parameters are per-instance component state that +survive save/reopen; the pure core's Trigger envelope (fade-in → hold → fade-out over +%-length frames) and the AHDSR hold stage are asserted against known signals (mirror of +`peaks`); existing Gate/ADSR behavior is unchanged when hold=0 (regression). **Spec +channel-count-agnostic** — the mode/envelope logic is per-frame amplitude and read-position, +independent of the S7 channel dimension (§sequencing). +**Depends on:** S3 (extends the envelope + voice read-position machinery), S5 (the +`PerformanceZone` the mode + params attach to), S11 (Gate loop-point UI; Trigger's waveform +shows start + %-length + fades on the **same** waveform surface). Independent of S7 — +orthogonal dimensions (§sequencing note in CONTEXT.md). + +- [x] Core: `PlayMode { Gate, Trigger }` on the voice + the envelope split. **Gate** grows + `AdsrParams` → `AhdsrParams` (add `holdFrames` between attack and decay; hold=0 is the + exact current ADSR — back-compat). **Trigger** is a distinct envelope: play `[start, + start + lengthFraction·(frames−start))` with a **fade-in** ramp (0→1 over `fadeInFrames`) + and a **fade-out** ramp (1→0 over `fadeOutFrames` ending at the play-length end), + **ignoring note-off** (release is a no-op in Trigger). Fade curve default **equal-power** + (constant-power `sin`/`cos`, click-free on one-shots) with the shape noted; linear is a + build-time residual. Pure, unit-tested against a known signal. +- [x] Core: **modifiable start point** — the voice's initial `readPos_` is `startFrame` + (frame offset), applied in both modes; the existing per-frame `readPos_ += ratio_` read + and loop/interp machinery is otherwise unchanged. Clamp `0 ≤ startFrame < frames`. +- [x] Core: **% length → frames + fade mapping** for Trigger. `lengthFraction ∈ (0,1]` + resolves to `playEnd = start + round(lengthFraction·(frames − start))`; `fadeInFrames` / + `fadeOutFrames` clamp so their sum ≤ play length (fade-out anchored to `playEnd`). Note-off + in Trigger does nothing; the voice frees when `readPos_ ≥ playEnd` (mirror of the current + run-off-end idle). **Choke on note-off is NOT in scope** (fork S15-F1, held below). +- [x] Parameter ownership (per-sample/per-zone, instrument-owned): the play mode + its + params (Gate: AHDSR; Trigger: %-length, fade-in, fade-out; both: start point) attach to + the **capture selection / zone**, stored in the **performance map** (D-B). **Lean + (build-time residual):** start point joins `rootOverride`/loop-override as another + per-`PerformanceZone` optional override, and a per-zone `PlayMode` + its param struct is + added additively (version-bumped component state, back-compat — a truncated/older blob + defaults to **Gate**, hold=0, start=0, no fades = exactly today's behavior). **Fork + S15-F2 (flagged):** whether these live per-capture-selection (S10's single-capture flow) + **and** per-zone, or per-zone only with the single-capture case as a one-zone map. Lean: + per-zone only — the single capture is already a one-zone map (S10-Z back-compat lift), + so one storage site serves both. Flagged because it touches S10's single-capture setup + surface shape. +- [x] Editor (S11 waveform surface, mode-aware): **Gate** shows draggable **start + loop + markers** (S11's loop UI); **Trigger** shows **start + %-length end + fade-in/out** + handles on the same waveform. A **mode toggle** per capture/zone in the guided setup + (S10) / Zones panel (S10-Z). Marker/handle geometry is pure (extends the S11 + `frame↔pixel` + marker-grab module); commits off-thread via `commitMapAndReload`. The + instrument stays a **read-only bank consumer** (mode/params are performance map, never + written to the bank). **Editor control surface deferred to S12 tier (spec-sanctioned).** + +> **S15 × S16 pitch-engine interaction (informs the S16 Preserve engine).** S15's amplitude +> semantics are defined over the voice's **source-frame** timeline, which the S16 pitch-engine +> mode (Varispeed vs Preserve) changes underneath them. Contracts to hold: +> - **Trigger %-length** — under **Preserve**, %-length is measured in **source frames** +> (`playEnd = start + round(lengthFraction·(frames − start))`, unchanged) but wall-clock is +> now **stable under transpose** (a transposed Trigger keeps its %-length duration). This is +> *cleaner* than Varispeed, where transposing a Trigger also scales its audible length. So +> S15's %-length spec is unchanged; Preserve just makes it pitch-independent. +> - **Gate sustain loop** — under **Preserve**, loop the **source read** (the `[loopStart, +> loopEnd)` source-frame region S15/S2 already defines) and feed the looped source stream +> into the shifter; the shifter transposes the **output**. Contract: *loop the source, shift +> the output* — the loop points stay source-frame facts (S11's markers are unchanged), and +> the Preserve engine sits after the loop read. Under Varispeed the loop read itself carries +> the pitch (today's behavior). +> - **Start point** — unaffected by engine: it is a source-frame offset (initial read +> position) in both, independent of how pitch is applied. +> These are S16-owned build details (the Preserve engine consumes S15's source-frame read); +> S15 lands its amplitude/read machinery source-frame-defined and channel-agnostic (S7), and +> S16's engine wraps it — no change to S15's committed points. + +## S16 — pitch engine modes (Varispeed vs Preserve) + pitch envelope (per-voice) +**Goal:** Give the sampler **two pitch behaviors** and a pitch envelope that rides whichever +is chosen. Repitch today is **Varispeed** — resampling that couples pitch and duration (an +octave up halves the note's duration; the classic sampler / RS5K default). Daniel's directive +(2026-07-26, verbatim: *"isn't that ratio stuff going to change the playback rate? I want +duration-preserving repitching"*) adds **Preserve** — duration-preserving repitch, where a +transposed note keeps its original length. Both are musically legitimate: **drums / one-shots +often want varispeed character** (the pitch-down-lengthens-the-hit sound), **tempo-locked +loops and phrases want Preserve** (a repitched loop still lines up to the bar). So the shape +is a **per-zone/per-capture pitch-engine mode** — a **Varispeed** engine (current, cheap, +`readPos_ += ratio_` resampling) vs a **Preserve** engine (duration-preserving pitch shift). +On top of either engine rides a per-voice **AD pitch envelope**, **off by default** — a short +attack-decay pitch modulation (the classic percussive **pitch drop**): under Varispeed it +biases `ratio_`; under Preserve it biases the shifter's shift amount. Per-instance +performance-map state (D-B). CONTEXT.md §Phase S (Pitch engine modes + pitch envelope). +**Feature settled per the directive; the engine default is a flagged Daniel fork (S16-F1), +the Preserve-engine implementation choice is a flagged fork (S16-F2).** +**Verify (in DAW):** +- **Varispeed engine** (per-zone): a note an octave up plays **half as long** as the root + note (pitch and duration coupled) — the current behavior, now explicitly the Varispeed mode. +- **Preserve engine** (per-zone): a note an octave up plays at the **same duration** as the + root note (pitch shifted, length held) — a Gate held note sustains as long as held; a + Trigger one-shot at %-length keeps its %-length wall-clock regardless of transpose. +- **Pitch envelope off** (default) under **either** engine: playback is identical to the + engine's un-modulated output (regression — no pitch modulation applied). +- **Pitch envelope on**: an AD envelope makes a note **start offset in pitch and glide to the + zone's base pitch** over attack+decay (percussive drop when the offset is positive-then- + settle); range settable in **semitones (±)**; per-voice (polyphonic notes each run their own). +- **Preserve CPU / voice-cap:** with a chord of Preserve-mode voices, CPU stays within budget + and no audio dropout at the polyphony cap; if Preserve is materially heavier, a **Preserve- + mode-specific voice cap** kicks in (below the Varispeed cap) rather than glitching. +- **Latency honesty:** a Preserve note has a small onset latency (the shifter's window); the + spec accepts this as a Preserve-mode property, and a **note-onset click/smear is absent** + (the shifter is pre-warmed at voice-allocation, not cold-started in `process`). +- **RT-safety:** no allocation in `process` for **either** engine — the Varispeed path is the + same per-frame tick idiom as the amp envelope; the Preserve shifter's buffers are + pre-sized/pre-warmed at voice allocation and reused (no queue growth in steady state). +- Pure-core assertions: the pitch-envelope curve against known values (offset at t=0, base at + t=attack+decay); the Varispeed ratio math; the Preserve engine's duration invariance (a + transposed render is the same frame-length as the un-transposed render). +**Depends on:** S3 (the voice read-increment + envelope tick idiom; the Varispeed path *is* +the current read loop), S5 (the `PerformanceZone` the mode + envelope attach to), S15 (the +per-zone param plumbing + component-state version bump the mode/envelope hang on; and the +Gate-loop / Trigger-%-length semantics the Preserve engine must honor — see S15 interaction +below). Independent of S7 (both engines operate per-frame, channel-count agnostic). + +- [x] Core: **pitch-engine mode on the voice/zone** — `PitchEngine { Varispeed, Preserve }`. + **Varispeed** = today's path (`readPos_ += ratio_`, `ratio_ = pitchRatio(note,root)`), pitch + and duration coupled. **Preserve** = duration-preserving: the read advances at the **source** + rate (duration held) while a pitch shifter transposes the output by + `2^((note−root)/12)`. Mode is per-`PerformanceZone` performance state (D-B), additive/ + version-bumped; **default is S16-F1 (flagged Daniel fork — lean Preserve, argued below).** + Absent/older blob → the fork's default. Pure where possible: the Varispeed math and the + duration-invariance contract are unit-tested; the Preserve DSP core is unit-tested for + duration invariance and transpose correctness against a known signal. +- [x] Core: **Preserve engine implementation (fork S16-F2, flagged).** Two viable routes, both + RT-disciplined (pre-allocated, no locks, no `process` allocation): + - **(a) `WDL_SimplePitchShifter`** (`vendor/WDL/WDL/simple_pitchshift.h`) — a per-voice + time-domain OLA shifter. **Now the right category** (duration-preserving is the + requirement, not the wrong tool it was under the varispeed-only framing). Viability from + the header (assessed this pass): push/pull block API (`GetBuffer`/`BufferDone`/ + `GetSamples`), `set_shift(2^(semi/12))` for pitch with `set_tempo(1.0)` for held duration + — pitch and duration are **separately controllable**, exactly Preserve. Per-instance memory + is modest (an OLA ring `bsize = window_ms·srate` ≈ a few KB/voice at the ~50 ms quality-0 + window, plus a bounded output queue). CPU is cheap (O(length), a few mults + one OLA + crossfade per frame — REAPER's "SimpleWindowed" mode, known-basic but usable), so N + polyphonic voices each running one is **feasible within RT discipline**. **Costs, stated:** + (i) **latency** — inherent ~half-window onset delay (~25 ms at the 50 ms window) plus + fill-up, so Preserve notes have a real onset lag; mitigated by pre-warming the shifter at + voice-allocation, and it lands mostly on sustained/loop material (Varispeed serves the + tight-transient one-shots); (ii) **queue allocation** — `BufferDone` grows `m_queue` via + `WDL_Queue::Add`, an RT hazard *only* until steady state; pre-warm with silence at voice- + allocation so the buffers settle and stop growing; (iii) **quality** — basic OLA, audible + warble on large transpositions and no formant preservation (`set_formant_shift` is an + empty stub), acceptable for the loop/phrase use case. **Implemented as hand-rolled pure + OLA (`pitch_shift` module, house pattern — CTest-testable, no WDL/REAPER/VST3 type at the + boundary); WDL_SimplePitchShifter excluded by include-chain (windows.h), held as swap.** + - **(b) hand-rolled OLA/granular pitch core** as a **pure module** (`pitch_shift`, mirroring + the house pattern — CTest-testable, no REAPER/VST3/WDL type at the boundary). More work, + but full control over latency/window/crossfade, RT-shape owned by us, and it sits natively + alongside `peaks`/`wav_trim`. **Lean: start with (a)** to prove the Preserve mode end-to- + end at low cost, and hold (b) as the quality/latency upgrade if the SimpleWindowed warble + or onset lag proves musically unacceptable — the mode's *contract* is identical either way, + so the engine swap is behind the `PitchEngine::Preserve` seam. **`WDL_Resampler` does not + apply here** — it is a *resampler* (couples duration), a Varispeed-quality option, not a + Preserve engine. **elastique is NOT available** (licensed zplane, not in the vendored tree + — restated, not worked around); JUCE / rubberband / signalsmith are **new-dependency forks + carrying full D-A weight** (bare-VST3-no-framework is locked D-A) — not proposed. +- [x] Core: a per-voice **AD pitch envelope**, engine-aware — `PitchEnvParams { enabled=false, + int64 attackFrames, int64 decayFrames, double peakSemitones }`. **Shape (lean, build-time + residual):** two-segment AD — at note-on the pitch offset rises to `peakSemitones` over + `attackFrames`, then falls to 0 over `decayFrames` (**zero attack** = the pure "start high, + drop to base" percussive drop). Off by default (`enabled=false` → offset always 0). **Applied + per engine:** under **Varispeed** the offset multiplies `ratio_` by + `2^(pitchEnvSemitones(frame)/12)` (the read-rate bias, as before); under **Preserve** the + offset is **added to the shifter's shift amount** — `set_shift(2^((note−root + + pitchEnvSemitones(frame))/12))` — so the pitch bends without touching duration. Pure, unit- + tested (t=0 offset, t=attack peak, t=attack+decay → 0; and the semitone→shift/ratio mapping + for both engines). +- [x] Parameter ownership + editor: pitch-engine mode + pitch envelope are per-zone + instrument performance-map state (D-B), additive/version-bumped (absent → engine default per + S16-F1, envelope disabled). Editor exposure: a **per-zone Varispeed/Preserve toggle** in the + S10 guided setup / S10-Z Zones panel (a two-state control next to the S15 mode toggle), plus + a small AD + ±semitone depth control for the envelope (folds into the S12 ADSR-editor tier). + Default-off envelope so the control is discoverable but inert until enabled. The instrument + stays a **read-only bank consumer** (mode/envelope are performance map, never written to the + bank). **Editor control surface deferred to S12 tier (spec-sanctioned).** + +> **S16-F1 (FLAGGED — Daniel fork): the default pitch engine.** **Lean: Preserve default.** +> Argued honestly both ways: +> - **For Preserve default** (the lean): Daniel asked for duration-preserving *unprompted*, +> which reads as the behavior he expects; and the capture workflow is **loop/phrase-heavy** +> (banks are captured slices of a project — tempo-locked material that benefits from +> duration preservation when transposed). For that material, Varispeed's tempo-drift on +> transpose is the surprising/wrong-feeling result. +> - **For Varispeed default** (the honest counter): **Varispeed is the classic-sampler +> expectation** (RS5K, hardware samplers, the whole tradition default to it); it is **cheaper +> and zero-latency** (no shifter, no onset lag); and it is bit-identical to the current +> shipped S3/S5 behavior, so a Preserve default is a *behavior change* for any existing feel. +> Percussive one-shot material specifically *wants* the varispeed character. +> - **Recommendation:** default **Preserve** because Daniel asked for it and the material skews +> loops, but make the per-zone toggle **prominent and cheap to flip** so drum/one-shot zones +> trivially opt into Varispeed. **Daniel's call.** + +> **S16-F2 (FLAGGED): the Preserve engine implementation.** `WDL_SimplePitchShifter` (route a, +> low-cost proof) vs a hand-rolled pure `pitch_shift` OLA/granular module (route b, more work, +> full control, house-native + CTest-testable). **Lean: (a) first, (b) as the held quality/ +> latency upgrade** — same `PitchEngine::Preserve` contract behind the seam either way. Not +> load-bearing for the *feature* decision (S16-F1); a build-time route choice flagged because +> it sets whether a new vendored-WDL usage or a new pure module enters the tree. + +> **WDL pitch capabilities — verified finding (feeds S16 build, not a committed point).** +> The full WDL pitch/resample surface was swept (`vendor/WDL/WDL/resample.h`, +> `simple_pitchshift.h` — the only two pitch/resample headers; no elastique, no +> formant-preserving/time-stretch anywhere in the vendored tree). Findings, honest: +> - **`WDL_Resampler`** (`resample.h`) — a real **sinc/linear resampler** (`SetMode(interp, +> filtercnt, sinc, sinc_size, sinc_interpsize)`; sinc up to 64-tap). It is **RT-suitable** +> (streaming `ResamplePrepare`/`ResampleOut`, prealloc-able, no per-block alloc if +> pre-sized) and its **sinc mode beats the core's current 2-point linear interp** for +> repitch quality (fewer aliasing artifacts on large transpositions) at a real CPU cost +> (64-tap conv per output sample vs. one lerp). **Fit:** an *optional quality upgrade for +> the **Varispeed** base repitch path* — a per-voice quality toggle (linear = cheap default, +> sinc = quality) — **not** required for S16 and **not** committed here. A resampler couples +> duration, so it is **not** a Preserve engine. Held as a Tier-2/3 Varispeed-quality option. +> - **`WDL_SimplePitchShifter`** (`simple_pitchshift.h`) — a **time-domain overlap-add, +> duration-preserving pitch shifter** (push/pull block API; `set_shift` for pitch and +> `set_tempo` as an independent duration knob; quality parameter selecting window/overlap +> sizes). **Under Daniel's duration-preserving directive this is the right category** — the +> candidate Preserve engine (fork S16-F2, route a). **Viability (from the header):** per-voice +> instantiable at modest memory (an OLA ring ≈ window·srate, a few KB/voice at the ~50 ms +> quality-0 window, plus a bounded output queue); CPU is cheap (O(length), a few mults + one +> OLA crossfade per frame — no FFT); N polyphonic voices each running one is **feasible in +> RT discipline** with **two caveats:** (i) **inherent latency** ~half-window (~25 ms @ 50 ms +> window) + fill-up → a real note-onset lag (pre-warm at voice-allocation; it lands on +> sustained/loop material where it is least harmful); (ii) `BufferDone` grows `m_queue` via +> `WDL_Queue::Add` — an allocation hazard **only until steady state**, pre-warmed away with a +> silence pass at voice-allocation. **Quality is basic** (SimpleWindowed warble on large +> transpositions) and **`set_formant_shift` is an empty stub** (no formant preservation) — +> acceptable for the loop/phrase Preserve use, replaceable by the hand-rolled route (b) if not. +> - **Formant-preserving / high-quality time-stretch (elastique-class): NOT in WDL, confirmed.** +> REAPER's elastique is **licensed (zplane), not part of the open WDL/reaper-sdk vendored +> tree** — grep of `vendor/WDL` for elastique/formant/time-stretch found only unrelated +> libpng/giflib string matches. Formant-correct / studio-grade duration-preserving repitch is +> **unavailable to the instrument** without a new third-party dependency (JUCE / rubberband / +> signalsmith would each be a **new-dependency fork carrying D-A weight** — bare-VST3-no- +> framework is the locked D-A choice — and are **not proposed**). **Stated, not worked around.** +> - **Recommendation:** the **Preserve** engine (S16-F2) is either `WDL_SimplePitchShifter` +> (route a, low-cost proof) or a hand-rolled pure `pitch_shift` module (route b, held quality +> upgrade). The **pitch-envelope** modulation stays hand-rolled over whichever engine (a per- +> frame `ratio_` multiply under Varispeed, a per-frame shift-amount add under Preserve). +> `WDL_Resampler` (sinc) remains a held **Varispeed-quality** upgrade only. + +> **S14 moved to Phase L (2026-07-26).** The dock-panel refresh that stood here is now +> **Phase L point L2** on `dev` — and, per Daniel's DS-3 call, expanded from a light re-skin +> into a **thorough dock-panel layout redesign** that lays out the full M11-aware button +> inventory before applying the kit. See PLAN.md §Phase L + CONTEXT.md §Phase L and +> `docs/product/visual-design-language.md` (all on `dev`). The design-system forks DS-1/ +> DS-2/DS-3 are all **SETTLED (2026-07-26)** and recorded in the Phase L docs on `dev`. + +## Phase S — product name (ReaSampler 9000) +The MIDI-playback instrument's product name is **ReaSampler 9000** (Daniel, 2026-07-26, +on DAW-testing the S1–S6 instrument). The extension remains **ReaSampler**; the instrument +is **ReaSampler 9000**. Framing + propagation surfaces: +`docs/product/midi-playback.md` §Product name. + +- [ ] Propagate the display name **ReaSampler 9000** across user-visible surfaces: the VST3 + class **display name** string (in the factory registration), the `IPlugView` editor title + band (currently "ReaSampler Instrument"), the S6 embed-strip label, and the Phase S docs. + **Do NOT change the VST3 class UID** — instances in already-saved projects key off it; a + UID change orphans every existing instance. +- [ ] **Rename the binary filename too (S-NAME-1 SETTLED, Daniel 2026-07-26):** rename the + built VST3 module (CMake `OUTPUT_NAME` / target artifact — e.g. `reasampler_9000.vst3`) + alongside the display strings, so the on-disk name matches the product name. Record the + full rename surface: **CMake output name** (the second VST3 target's artifact name), the + **factory vendor/name strings**, the **`IPlugView` editor title**, and the **S6 embed + label**. Do NOT touch the **VST3 class UID** (unchanged — the compat anchor). +- [ ] **Compat verification (must-DAW-verify before shipping the rename):** the working + assumption is that REAPER **rebinds a saved instance by its VST3 class UID, not by the + module filename**, so a filename rename with an unchanged UID keeps saved projects working + (existing instances still resolve). **This is not yet confirmed from source** — a web + check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's binding is more nuanced + than "UID only" (it can involve an FXID match), so treat UID-rebind as **to-verify, not + asserted fact**. **DAW-verify:** save a project with a ReaSampler 9000 instance under the + old filename, rename the module, reopen — confirm the instance rebinds and restores its + state. If REAPER does key partly on filename, fall back to keeping the current filename + (display-strings-only) and record that as the shipped choice. + +## S17 — drop-and-load: drag a capture onto a track's FX button → instantiate ReaSampler 9000 with the capture loaded +**Goal:** Turn a bank capture into a playable instrument in one gesture. Today a drag +out of the `bank_panel` becomes an OS file drag once it leaves the panel (M11 — +`drag_out` + `drag_out_win`, CF_HDROP). This wave adds a **second, internal drag mode**: +while a capture is dragged, a track's TCP **FX button** lights as a drop zone, and +dropping there instantiates a **ReaSampler 9000** (the Phase S VST3 sampler) on that +track with the dragged capture **already loaded and selected** for playback. The +extension drives the whole gesture itself — REAPER's FX button is not a native +plugin-with-file drop target, so this cannot ride the CF_HDROP path. CONTEXT.md §Phase S +(drop-and-load — internal-drag hover mode + the FX-button drop → add-VST + load-capture +seam). Product framing: `docs/product/midi-playback.md` (drop-and-load — the third +integration gesture). +**Consistent with the load-bearing principle (make the reasoning explicit):** this is an +**explicit, user-driven placement gesture** — the user is deliberately choosing to place +a playing instrument on a track, exactly as inserting an item into the arrange is a +deliberate placement act. It does **not** auto-capture (the file already exists in the +bank) and does **not** insert a media item into the timeline; it instantiates a *reader* +of the bank on a track and points it at one already-captured sample. Capture, placement, +and playback stay distinct acts; drop-and-load is a placement-of-the-player gesture, not +a capture and not a timeline insert. +**Two-part mechanism:** +- **(a) Internal-drag hover mode.** A drag armed with a *single* capture that stays + *inside* REAPER's own UI (does not cross to Explorer / another app) is tracked by the + extension: it detects the pointer hovering a track's TCP FX button, highlights it as a + drop target, and on release drives the insert. This is a *third* `DragGesture` beyond + the existing `Internal` (bank-to-bank) and `OsDrag` (M11) — call it `InstrumentDrop`. +- **(b) FX-button drop → add-VST + load-capture.** On drop, the extension adds a + ReaSampler 9000 instance to the target track via `TrackFX_AddByName` (verified present + in `reaper_plugin_functions.h`; signature + `int TrackFX_AddByName(MediaTrack*, const char* fxname, bool recFX, int instantiate)` — + use `"VST3:ReaSampler 9000"` and a negative `instantiate` to always create a new + instance), then pushes the dragged capture's identity into that instance so it plays + that sample — via the **load-capture seam** (below). +**The ReaSampler 9000 load-capture seam (the hard Phase S coupling — MUST be added):** +The current Phase S spec gives the instrument a *live-state read* seam (it reads the bank +index + mapping from `"reasampler"` ext-state via the bridge) but **no entry point for an +external actor to say "instantiate playing *this specific* capture."** This wave is the +reason to add that seam. It is a Phase S dependency, not extension-side, and must land in +the instrument before drop-and-load's drop half can work end-to-end. **Mechanism (SETTLED +— (B) VST3 component-state injection):** after `TrackFX_AddByName`, the extension writes the +new instance's component state directly via `TrackFX_SetNamedConfigParm(track, fx, +"vst_chunk", )` — **verified present** in `reaper_plugin_functions.h` (`bool +TrackFX_SetNamedConfigParm(MediaTrack*, int fx, const char* parmname, const char* value)`; +the header documents the `vst_chunk`/`vst_chunk_program` parms as the base64-encoded +VST-specific chunk write-path). Deterministic, no shared-state race, no cross-process +handshake. **Load-bearing caveat:** `vst_chunk` is the plugin's **own** base64-encoded +serialized chunk (its `getChunk`/`setChunk` FXP/FXB-style blob), **not** a raw VST3 +`IComponent::setState` stream REAPER re-marshals — so the extension must construct exactly +ReaSampler 9000's own state-blob bytes with the target capture pre-selected. That makes the +**component-state blob format a shared cross-artifact contract** — still being defined in +Phase S — and a coordination dependency between the extension and the instrument: both must +agree on the exact byte layout the instrument's `setChunk` accepts. Rejected alternative — +**(A) fresh-instance ext-state handshake** (extension writes a "pending load" hint into +`"reasampler"` ext-state, the fresh instance claims + clears it on init): loosely coupled +through the existing bridge, but loses on the claim/clear race — "which instance claims +which hint" needs a stable key and a cross-process handshake to get right. See CONTEXT.md +§Phase S (drop-and-load) for the full seam decision. +**Coexistence with the M11 OS drag-out (disambiguation, load-bearing):** the two drag +modes are disambiguated by **where the pointer goes**, not by a mode toggle. Inside the +panel client rect → `Internal` (unchanged). Left the panel but still over REAPER's own +window/UI → `InstrumentDrop` (new — hover-tracks the FX button). Left REAPER entirely +(Explorer / another app) → `OsDrag` (unchanged M11). The M11 `decideGesture` boundary +(pointer left the client rect) is **refined**, not replaced: leaving the client rect no +longer immediately means OS-bound; it means "resolve which of InstrumentDrop / OsDrag by +whether the pointer is over REAPER's UI." Single-capture vs. multi-capture also +disambiguates — see open questions. +**Verify (in DAW):** dragging a single capture from the dock over a track's FX button +highlights it; dropping instantiates ReaSampler 9000 on that track with the dragged +capture loaded, selected, and MIDI-playable immediately (no manual pick step); the OS +drag-out to Explorer / another DAW still works unchanged; the internal bank-to-bank drag +still works unchanged; no media item is ever inserted into the arrange; the instrument +holds no private copy (it reads the one authoritative bank). +**Depends on:** M11 (`drag_out` gesture machinery — the mode it extends); **Phase S S4** +(a loadable, playing ReaSampler 9000 instance must exist) **AND the new load-capture seam +added inside ReaSampler 9000 via (B) component-state injection — the shared component-state +blob contract must be defined so the extension can construct it and the instrument's +`setChunk` accept it**. Composes with — but is distinct from — **S8** (ingest +through the bank: capture/import/drop *into* the bank) and **S13** (drop-to-load *inside* +the editor). S17 is the third integration gesture: drop *onto a track's FX button* to +instantiate a player. Gated on the rest of Phase S; the instrument seam is a Phase S +artifact, not extension-only. + +- [x] Extend the `drag_out` pure module with the third gesture: `decideGesture` (or a + successor) returns `InstrumentDrop` when a drag armed with a single capture is over + REAPER's UI outside the panel client rect, `OsDrag` only when it has left REAPER + entirely, `Internal`/`OsDrag`/`None` otherwise unchanged. Pure over (drag state + + pointer + panel rect + an "over-own-UI" predicate the shell supplies). Unit-tested — + the existing `drag_out` invariants (M11) must not regress. **Landed:** `DragGesture:: + InstrumentDrop` + two defaulted `DragState` fields (`singleCapture`, `overReaperUi`, the + shell-supplied predicate). Defaults false, so an M11 caller filling only + `{dragging, hasArmedSamples}` gets byte-identical M11 behavior — the existing tests are + the non-regression proof. **OPEN QUESTION RESOLVED (multi-capture over FX button): + REJECT** — only `singleCapture` arms InstrumentDrop; a multi payload over REAPER's UI + falls through to `OsDrag` (the natural multi-file drag-out), matching the spec's Tier-0 + reject-or-first lean toward reject. +- [x] Shell (extension): hover-track the pointer over REAPER's UI during the drag, + resolve the hovered track + its FX button (verify the TCP/FX-button hit surface against + the SDK — see must-verify), highlight it as a drop target, and on release drive the + drop. Extends the `bank_panel` drag hook alongside the M11 `drag_out_win` path. + **Landed:** `instrument_drop_win::resolveFxDropTarget` wraps `GetThingFromPoint` + (verified present; its info string reports `"fx_chain"`/`"fx_N"` for the FX region and a + null-track-empty-info for off-REAPER). **OPEN QUESTION RESOLVED (FX hotspot vs. whole + TCP): FX HOTSPOT** — the drop target is the FX region specifically, decided from the SDK's + own hit-test string (info prefix `"fx_"`), not a home-grown geometry guess. The + bank_panel drag hook now hover-tracks outside the client rect (holding internal-drag + capture, NOT the modal OS loop) and only hands to `drag_out_win` (OsDrag) when the pointer + has left REAPER entirely. +- [x] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:ReaSampler 9000", + false, /*instantiate*/ negative)` to always add a fresh instance; capture the returned + FX index; then invoke the load-capture seam to point the new instance at the dragged + capture. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the + whole gesture is one Ctrl-Z (mirrors the bank-verb undo discipline). **Landed:** + `instrument_drop_win::performInstrumentDrop` — `TrackFX_AddByName` with the CHANNEL-correct + name (`"VST3:" + app_version::vstPluginName()`, so a beta extension drops the beta VST), + negative instantiate, then `TrackFX_SetNamedConfigParm(..., "vst_chunk", blob)`, all in one + `Undo_BeginBlock2`/`EndBlock2` block. NEVER inserts a timeline item. +- [x] **ReaSampler 9000 (Phase S artifact):** add the **load-capture seam** — the entry + point that lets the just-added instance be told which capture to play via **(B) + component-state injection**: the instrument's `getChunk`/`setChunk` must round-trip a + component-state blob whose byte layout is the **shared cross-artifact contract** (defined + in Phase S) that the extension constructs with the target capture pre-selected and writes + via `TrackFX_SetNamedConfigParm(..., "vst_chunk", ...)`. This is the cross-artifact half; + it lands in the instrument, not the extension. **Coordination dependency:** the blob + format must be agreed between extension and instrument before either half is final. + **Landed (no NEW instrument code needed — the seam already exists):** the instrument's + `setState`/`getState` already round-trip the full `ComponentState` via + `sample_map::serializeComponentState`/`deserializeComponentState` (S10). The extension side + REUSES that exact serializer through the new pure `instrument_drop` module + (`buildInstrumentDropChunk` -> `serializeComponentState` -> base64) — the shared-writer + requirement is met STRUCTURALLY (one serializer, called from both artifacts), so the blob + format cannot drift. Pure round-trip test decodes back through the instrument's OWN reader + (`deserializeComponentState`) and asserts the capture is selected. **DAW-VERIFY (the one + live unknown):** whether REAPER's `vst_chunk` write-parm expects the plugin's raw + IComponent-state bytes as we write them, or wraps them in a REAPER container header — the + load-bearing caveat is only confirmable in the DAW; if REAPER wraps, the fix is to match + its container, the shared serializer stays. +- [x] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across + single- and multi-capture payloads; FX-button hit resolution (pure geometry where it + can be factored out); M11 OS drag-out and internal bank-to-bank drag both unchanged. + **Landed:** `test_drag_out.cpp` adds the InstrumentDrop cases (single over REAPER UI -> + InstrumentDrop; single inside -> Internal; single off-REAPER -> OsDrag; multi over UI -> + OsDrag; not-dragging short-circuits) with the M11 cases retained as the non-regression + guard; `test_instrument_drop.cpp` is the blob round-trip + base64 codec coverage. FX hit + resolution is REAPER-API-bound (`GetThingFromPoint`), so it is DAW-verified in the shell, + not pure-tested — noted honestly. + +## S18 — VST3 channel isolation: a beta ReaSampler 9000 that pairs with the beta extension only +**Goal:** Extend Phase V's beta/stable channel split (V4 — the *extension* is fully +isolated per channel) to the **ReaSampler 9000 VST3 instrument**, so a beta-built VST is a +distinct plugin that pairs only with the beta extension, and a stable VST pairs only with +stable — installable side-by-side in one REAPER with no collision. **What already works +(established from the tree, not re-derived): data pairing is done.** `ext_keys.h`'s +namespace is channel-derived (`kProjExtNamespace()` → `app_version::extStateNamespace()`), +so a beta-compiled VST already *reads* `"reasampler_beta"`; every wire key (`banks`, +`assign_request`, S9's future generation key, any future key) is a plain constant *under* +that namespace, so channel data-isolation is **structural, not per-key** — new keys inherit +it automatically. **What is missing is the VST's *plugin identity*:** its class UID, binary +filename, and display strings are single-valued today (same for both channels → a +UID/filename collision if both are installed). This wave closes that. Mirrors V4's +philosophy: **one channel per binary; all identity derives from the ONE +`REASAMPLER_CHANNEL_IS_BETA` bit via `app_version`, no scattered `#ifdef`s.** CONTEXT.md +§Phase S (VST3 channel identity — the UID-pair invariant + the pairing surface). Product +framing: `docs/product/midi-playback.md` §Addendum — VST channel isolation. +**Consistent with V4 (make the invariant explicit):** V4 already committed the extension to +*two* forever-stable command-id families and *two* forever-stable ext-state namespaces. This +wave commits the *instrument* to the parallel permanent cost: **two forever-stable VST3 class +UIDs** — the existing stable UID (S-NAME-1, locked) and a second beta UID (minted once, +locked forever the same way). Both are frozen forever; the channel bit selects which is +*compiled into this binary*. **One class per binary, not both in one binary** — this mirrors +V4's fully-isolated-binary philosophy (a channel build carries only its own identity), keeps +the factory registration a single `DEF_CLASS2`, and means a beta-installed VST cannot present +the stable identity. +**Verify (in DAW):** the stable VST3 (`reasampler_9000.vst3`, existing UID) and a beta VST3 +(`reasampler_9000_beta.vst3`, the new UID) install side-by-side in one REAPER and both appear +in the FX browser as distinct plugins ("ReaSampler 9000" and "ReaSampler 9000 beta"); a beta +instance reads only the beta extension's banks (its browser shows the beta banks, never +stable's) and a stable instance only stable's; a project saved with a **beta** instance +reopens rebinding to the beta VST (not the stable one) and restores its state; a project +saved with a **stable** instance opened where only the beta extension has banks shows a clean +empty "pick a capture" state (S10 policy), **not** an error; the S-NAME-1 save-rename-reopen +compat test extends to the beta UID (a beta instance rebinds by its own UID). **Nothing plays +differently** — this is an identity/pairing wave, no engine change. +**Depends on:** V4 (the `app_version` channel-identity single-source this extends), S1 (the +VST3 factory + `reasampler_vst.h` identity constants + the CMake second target it edits). +Consumes the S4 live-state seam only to *demonstrate* pairing; adds no new seam. **Disjoint +from the in-flight waves:** touches `vst_entry.cpp`, `reasampler_vst.h`, and the CMake VST3 +block — S9 (ext_keys additions) and S15/S16 (processor/editor) are elsewhere. **ext_keys +coordination note:** S9's new generation key is a plain constant under the channel-derived +namespace, so it inherits this isolation with no S18 change; no coordination edit is needed — +only the shared awareness that all wire keys must live under `kProjExtNamespace()` (already +the rule). Dispatchable in parallel with, or immediately after, the in-flight waves. + +- [x] **Beta VST3 class UID (the permanent commitment).** Mint a second FOREVER-STABLE + class UID in `reasampler_vst.h` alongside the existing `kReaSamplerProcessorUID` + (`REASAMPLER_PROC_UID_1..4`) — a distinct forever-frozen constant (e.g. + `REASAMPLER_PROC_UID_BETA_1..4` + `kReaSamplerProcessorUIDBeta`). The channel bit selects + which UID the factory registers (`DEF_CLASS2`'s `INLINE_UID` + the runtime `FUID`) — + compile-time, one class per binary. **Both UIDs are frozen forever once shipped** — the + same lock the stable UID carries (a saved project records the UID and rebinds by it; a + changed UID orphans every saved beta instance). Record the invariant in the header's + UID-lock comment. **Fork S18-F1 (flagged — Daniel's call):** whether the beta UID is + minted *now* (locked from this wave) or deferred to *first beta release* of the VST + (leaving a compile-time placeholder that must be replaced-once-and-locked before any beta + VST ships). Lean: **mint now** — mirrors how the stable UID was minted at the S1 spike + (locked long before ship), removes a "remember to mint before shipping" landmine, and the + cost of an unused-until-beta constant is zero. Flagged because it is a forever commitment. +- [x] **Channel-derived binary + display identity (no scattered `#ifdef`s — the V4 + invariant).** Route all VST identity through `app_version` accessors, mirroring how the + extension's `OUTPUT_NAME` already derives from `REASAMPLER_OUTPUT_NAME`. (a) **Binary + name:** the CMake VST3 target's `OUTPUT_NAME` forks by channel — `reasampler_9000` + (stable) / `reasampler_9000_beta` (beta) — driven by the same `REASAMPLER_CHANNEL` config + the extension target reads (add a `REASAMPLER_VST_OUTPUT_NAME` beside + `REASAMPLER_OUTPUT_NAME`, or reuse the one channel bit; **check what the extension does and + mirror it exactly**). (b) **Display name:** the factory `DEF_CLASS2` plug-in display string + is "ReaSampler 9000" / "ReaSampler 9000 beta" — sourced from an `app_version` VST-name + accessor (a new channel-derived accessor beside `binaryName()`/`dockTitle()`, e.g. + `vstPluginName()`), not a literal in `reasampler_vst.h`/`vst_entry.cpp`. (c) **Editor title + band + S6 embed-strip label** are channel-aware from the same accessor. All fan out from the + ONE channel bit — no per-file `#ifdef`. +- [x] **Factory vendor/version strings channel-aware where V4 does the equivalent.** The + `BEGIN_FACTORY` vendor/url/email and the `PClassInfo2` version string + (`REASAMPLER_VST_VERSION`, currently a fixed `"0.1.0.0"`) align with the channel where the + extension's V4 equivalents do — the version display carries the `-beta` render + (`appVersion()` already yields `"0.9.01-beta"` on beta); the vendor strings stay shared + unless V4 qualified them (**check V4's treatment and match it** — V4 kept the lane-name + prefix shared, so shared-where-V4-shares is the default). No new forever-stable string + beyond the UID and the id-family V4 already owns. +- [x] **Pairing-surface invariant recorded (no new code — a documented guarantee).** Write + the complete pairing surface as an invariant in CONTEXT.md: a channel's VST talks to that + channel's extension **only**, because (1) plugin identity (UID + filename + display) is + channel-forked here, and (2) **all** wire keys — `banks`, `assign_request`, S9's + generation key, and any future key — live under the channel-derived `kProjExtNamespace()`, + so the beta VST's bridge reads only `"reasampler_beta"`. The invariant to write: **channel + isolation is structural — no per-key opt-in — so a future wire key that forgets to isolate + is impossible by construction** (it keys under the namespace accessor, not a raw literal). + This is the guard that S9/S8/S17's cross-artifact keys inherit isolation for free. +- [x] **DAW-verify contract (the acceptance gate, no unit test — identity is a shell fact).** + Both channels installed side-by-side: each browser sees only its channel's banks; a + stable-project + beta-VST opens clean-empty (not error); the S-NAME-1 rename/rebind compat + test extended to the beta UID (save a beta instance, confirm it rebinds by the beta UID on + reopen). The pure `app_version` accessors (binary/display name per channel) are CTest-green + in `app_version_tests` (extend the existing V4 channel-string assertions to the new VST + accessors); the UID selection + factory wiring are DAW-verified (no host-free test path). + ## Phase S — held and optional-forever (noted, not specified) - **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun), full ADSR, per-sample tuning/gain trim, sustain loops. The next depth increment once Tier 0–1 proves the instrument belongs — **its points are not drawn up here.** - **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch envelopes, LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave - room for, never a commitment. + room for, never a commitment. **Note:** S16 lands the *pitch* envelope + the Varispeed/ + Preserve pitch-engine mode early (Daniel's directive) — the Tier-3 "filter/pitch envelopes" + line now means the *filter* envelope + LFOs remainder. +- **Sinc Varispeed-quality upgrade (HELD — WDL_Resampler).** `WDL_Resampler`'s sinc mode + beats the core's 2-point linear interp for **Varispeed** base-repitch quality (see the S16 + WDL finding). An optional per-voice quality toggle (linear default / sinc), RT-suitable but + heavier. Held as a Tier-2/3 quality option — not needed for S15/S16, not scheduled. (A + resampler couples duration, so it is a Varispeed-quality option only, **not** a Preserve + engine.) +- **WDL_SimplePitchShifter swap (HELD — fork S16-F2 route a).** `WDL_SimplePitchShifter` + as a drop-in swap for the `pitch_shift` pure module if the hand-rolled OLA onset latency + or warble proves musically unacceptable. Same `PitchEngine::Preserve` contract behind the + seam. WDL excluded from the shipped build by include-chain (windows.h); held as the + quality/latency alternative. +- **Trigger choke-on-note-off (HELD — fork S15-F1).** A future option for Trigger mode to + *cut* (choke) on note-off or on a same-group re-trigger (hi-hat open/closed). Deliberately + out of S15 scope (Trigger ignores note-off entirely there); a Tier-3 choke-group direction. ## Phase S — must-verify-before-build (carried from CONTEXT.md §Phase S) - **Steinberg VST3 SDK surface** — interface members, base-class overrides, factory-macro spellings, Windows module-export symbol names (`InitDll`/`ExitDll`/`GetPluginFactory`), and whether VSTGUI is bundled. Several are §1a experienced-estimates until S1 confirms them against the vendored SDK. -- **VST-host bridge** — opcodes `0xdeadf00d` (resolve-by-name) / `0xdeadf00e` (host - context) and the exact call marshalling, against `reaper_plugin.h` / - `video_processor.h` / `reaper_plugin_functions.h`. -- **`IReaperUIEmbedInterface`** — embed contract + message/lifecycle, against - `reaper_plugin_fx_embed.h` (needed only at S6). +- **VST-host bridge** — ~~opcodes `0xdeadf00d`/`0xdeadf00e`~~ **verified (S1):** VST3 + path is `IReaperHostApplication::getReaperApi` (resolve-by-name) and + `getReaperParent(3)` (host context) via `reaper_vst3_interfaces.h`; the + `0xdeadf00d`/`0xdeadf00e` opcodes are VST2-only and do not apply. +- **`IReaperUIEmbedInterface`** — ~~embed contract + message/lifecycle, against + `reaper_plugin_fx_embed.h` (needed only at S6).~~ **verified (S6):** interface + exposed via `queryInterface` on the controller; inline strip drawn into REAPER's + embed bitmap (LICE idiom, no HWND); `WM_GETMINMAXINFO` size hints confirmed; + embed open/close/resize lifecycle clean. +- **VST3 bus arrangement (S7)** — ~~`setBusArrangements` / `getBusArrangement` and REAPER's + mono/stereo instrument-bus expectations, against the vendored Steinberg SDK + + `reaper_vst3_interfaces.h`. The channel-mode toggle depends on the output bus + re-negotiating cleanly.~~ **verified (S7):** `setBusArrangements` accepts only the mode's + arrangement (kMono/kStereo); `getBusArrangement` (base default) reports the valid mode; + a runtime mode change calls `restartComponent(kIoChanged)` so REAPER re-negotiates; + verified against the vendored Steinberg SDK (`ivstaudioprocessor.h`, + `vstsinglecomponenteffect.cpp` base impl, `ivsteditcontroller.h` kIoChanged). +- **Media Explorer surface (S8)** — confirmed thin against the vendored headers: + `OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the one + last-played/selected file + range) are the whole contract; **no** enumerate-selected and + **no** ME-drop-handler API. Spike: does `MediaExplorerGetLastPlayedFileInfo` return a + usable path+range for a merely-selected (not-yet-played) file? +- **Drop targets (S8)** — REAPER exposes **no** drag-drop registration API (verified); drop + handling is on ReaSampler's own panel HWNDs via SWELL/Win32 (`WM_DROPFILES` / `IDropTarget`). + Drop-onto-VST3-editor relayed as a bank-ingest request is an **unproven cross-artifact + spike**, not a promise. +- **Bank-generation ext-state read (S9)** — confirm no torn-read hazard on the single + integer generation key for a bridge read on the instrument's UI/timer thread concurrent + with an extension write. +- **WDL pitch/resample surface (S15/S16)** — **verified this pass:** `resample.h` + (`WDL_Resampler`, sinc/linear, RT-suitable — a *resampler*, couples duration → Varispeed + path) and `simple_pitchshift.h` (`WDL_SimplePitchShifter`, time-domain OLA, **duration- + preserving** — the S16 Preserve-engine candidate, fork S16-F2 route a) are the **whole** + pitch/resample surface; **no** elastique / formant-preserving in the vendored WDL tree. + **S16 Preserve engine shipped as hand-rolled pure OLA (`pitch_shift` module); WDL excluded + by include-chain (windows.h), held as swap.** The pitch-envelope modulation is hand-rolled + over the engine. If the held sinc Varispeed-quality upgrade is ever taken, verify + `WDL_Resampler` streaming/prealloc against per-voice RT budget before use. +- **Drop-and-load (S17) — three surfaces.** (1) `TrackFX_AddByName` — **verified present** + in `reaper_plugin_functions.h` (signature confirmed; the `"VST3:"` name prefix and the + negative-`instantiate`-always-adds semantics are documented in the header comment). (2) + **TCP / FX-button hit resolution** — how the extension resolves the pointer-under-cursor + to a track and its FX-button hotspot during a drag: **not yet confirmed against the + SDK/SWELL** — `GetTrackFromPoint` / `GetThingFromPoint` are candidates to verify against + `reaper_plugin_functions.h`; whether the FX button specifically is addressable (vs. the + TCP as a whole) is an open verification. (3) The **ReaSampler 9000 load-capture seam** — + a *new* interface added inside the Phase S instrument; its mechanism is a Phase S design + choice (see CONTEXT.md §Phase S open question), not a pre-existing SDK surface. --- diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index d9d5f0d..ddc1ddc 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -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 +S1–S6 instrument: the product name **ReaSampler 9000** and the **"better than RS5K" UX +overhaul** (Phase S points S10–S13) — see the r5 Addendum in §4. r4 (below) settled the four +residual forks D-A..D-D. The "no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D" collides with the existing Design View phase). **PLAN.md §Phase S is now the @@ -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 (S7–S9), not +re-litigated. Recorded here per the doc's settled-decisions convention. + +**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ PLAN.md S7).** +Captures are often stereo; the current mono downmix is a Tier-0 simplification. The +engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with +the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus +arrangement (`setBusArrangements`) so mono/stereo just works in REAPER's routing. Honest +scope: **this is an S3-core extension, not a shell hack** — the core is mono-per-sample by +design today, so stereo mode grows a channel dimension (2-channel sample data, per-voice +stereo render, per-channel loop/interp). Mono mode keeps the existing downmix path. +Cross-mode policy: mono-source-in-stereo → dual-mono; stereo-source-in-mono → downmix +(existing). The toggle is instrument-owned per-instance state (D-B: a performance choice, +never a bank fact). Sequenced **first after the editor/embed work** because it touches the +engine Daniel smoke-tests. + +**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md +S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank ++ auto-assign to the active instance. The **extension owns ingest** (it has arrange +access, Media-Explorer access, and the drop-target surface on its own panels); the +**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts, +with the honest SDK reality verified against the vendored headers: + +- *(a) Arrange capture → bank → assign* — a one-click action reusing the existing capture + path; **never inserts a timeline item** (capture/placement separation intact). +- *(b) Media Explorer import → bank → assign* — the ME surface is **thin** + (`OpenMediaExplorer` + `MediaExplorerGetLastPlayedFileInfo` are the whole contract; no + enumerate-selected, no ME-drop-handler), so ME import is **single-file, pull-on-action**, + not a push/drop from inside the ME. Spike: does the last-played-file read work for a + merely-*selected* file? +- *(c) Drag-and-drop* — REAPER exposes **no** drag-drop registration API; drop handling is + SWELL/Win32 on ReaSampler's *own* panel HWNDs. Drop *onto the VST3 editor window* relayed + to the extension as a bank-ingest request is a genuine **cross-artifact spike**, not a + promise (drop-onto-panel is the shipped path if it proves gnarly). +- *(d) Recapture / ingest auto-refresh (→ S9)* — because instances reference sample **ids**, + a recapture landing under the same id (M10) or an ingest touching the active bank should + refresh live instances **hands-free**. The missing trigger is a **bank-generation counter** + in `"reasampler"` ext-state: the extension bumps it on any bank-content mutation; the + instrument polls it **off the audio thread** on a safe cadence and calls its existing + `reloadFromBank()` on change (reusing S4's atomic handoff). This seam serves both S8 ingest + and M10 recapture. + +*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the +ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both +are honestly-flagged as spikes in PLAN.md S8, not promised. + +### Addendum — product name + UX overhaul (Daniel, 2026-07-26, post-S1–S6 DAW test) + +Daniel DAW-tested the S1–S6 instrument and set two directives. These are **settled +directions**, specced as new Phase S points (S10–S13) and a product-name convention — not +open forks (the two flagged forks below are the only calls left to Daniel). + +**The instrument's product name is `ReaSampler 9000`.** The extension stays **ReaSampler** +(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is +a checklist item (PLAN.md §Phase S — product name; CONTEXT.md §Product name): the VST3 +class **display name** string, the `IPlugView` editor title band (today "ReaSampler +Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-bearing):** the +**VST3 class UID must NOT change** — instances in saved projects key off it; a UID change +orphans every existing instance. The name change is **display-string-only** on the code +side. *Fork S-NAME-1 (Daniel's call):* the on-disk **binary filename** — renaming it +(`reasampler_9000.vst3`) carries compat weight (REAPER keys a saved project's plugin +reference partly by filename), so the r5 lean was **keep the filename, change only display +strings**; flagged, not decided. *(Now SETTLED in r6, below: rename the filename too, UID +locked, compat is a DAW-verify — the r5 lean is superseded.)* + +**The UX bar is "better than ReaSamplOMatic5000."** Verdict verbatim: "okay it works, but +the UX is awful." The S1–S6 editor was spike-grade — a clickable list, zone rows with +**seven ±1 nudge/delete mini-buttons** each, text labels, **no keyboard, no waveform, no +drag, no scroll.** Setting a zone range by ±1 clicks is the catastrophe. The overhaul +(S10–S13) makes "better than RS5K" *specific*: + +- **RS5K's strengths, matched or beaten.** Drag-a-file-onto-it load (→ S13 relay); + note-range + a visual keyboard (→ S10 — RS5K uses two *number fields*, so a **draggable + keyboard strip** beats it); waveform with draggable start/end/loop markers (→ S11); ADSR + sliders (→ S12). Velocity layers / round-robin stay Tier 2 (held). +- **RS5K's weaknesses, our opening.** RS5K is **one-sample-per-instance** (forcing track + sprawl — one instance per drum) with **no multi-zone view in a single instance**. + ReaSampler 9000 is **multi-zone in one instrument** (S5), so the keyboard-strip editor + showing *every* zone at once is a capability RS5K structurally lacks. That is the + sharpest "better than RS5K" claim, and it's free — it falls out of the existing model. + +The overhaul honors every settled constraint: **LICE/SWELL only** (D-A), **pure geometry +modules** for all layout/hit-test (mirror of `mode_switch`/`editor_geometry`/`embed_strip`), +**RT discipline untouched** (edits commit off-thread), the instrument stays a **read-only +bank consumer** (loop/root/ADSR edits are the instrument's performance map, D-B — never +written to the bank). Component-state persistence and read-only-over-bank stay settled. + +*Sequencing (product recommendation).* **S10 leads** — the nudge-button zone editor is the +friction Daniel hits on every test pass, so removing it buys the most felt improvement per +unit of work and de-risks the drag-state machine S11/S12 reuse. Against the queued **S7 +(stereo)**: S10 should land **before or interleaved with** S7 — S7 is real engine work but +the *reason* Daniel keeps smoke-testing is the editor, and every test pass is currently +taxed by the UX; the two are orthogonal (S7 = engine/bus, S10 = editor/geometry), so there +is no hard ordering, but the live wound is the editor. Honest counter: if the stereo +*sound* is what blocks real use, S7-first is defensible — but "it works, the UX is awful" +names the editor as the wound. + +### 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 (S1–S6, sequenced by -dependency order: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded -UI); **CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the -pure/shell split in the new artifact, the must-verify SDK/bridge surfaces). This doc is the -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"** (**S10–S13**; S10 **reframed r6**): + S10 = **capture browser (peak thumbnails + bank filter) + guided single-capture setup**, + **silent-on-open / no auto-select** (reverses S4), multi-zone editing demoted to an opt-in + Zones panel (S10-Z); S11 waveform + draggable loop points; S12 scroll/search over the S10 + browser + numeric entry + ADSR; S13 drop-to-load folding in the S8 relay. Metric: + time-to-first-note. +9. **Visual design language → modern/sleek, system-wide — moved to its own Phase L + (2026-07-26).** The look-and-feel work (a shared LICE drawing kit + the surfaces that + adopt it) was originally drafted here as Phase S points S0-DS + S14; it has been **lifted + out of Phase S into its own Phase L** (Look-and-feel) on `dev`, taken up by a parallel team + so Phase S feature work proceeds ungated. S0-DS → **L1** (shared kit); S14 → **L2** + (expanded to a thorough dock-panel layout redesign per DS-3); VST editor + embed restyle → + **L3** (gated on Phase S landing on dev). Forks DS-1 (LICE + WDL free game, no external + frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and + DS-3 (thorough panel layout) are all **SETTLED (2026-07-26)**. Framing + palette + the three + visual directions + forks: `docs/product/visual-design-language.md` (on `dev`); roadmap + + spec: **PLAN.md §Phase L + CONTEXT.md §Phase L** (on `dev`). **S10–S13 build with the + current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers + Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26, + post-S1–S6 DAW test). +10. **Sampling modes + pitch engine → engine features** (**S15** Trigger vs Gate, **S16** + pitch-engine modes + pitch envelope; see the "sampling modes" r7 + "duration-preserving" + r8 Addenda in §4). Gate = AHDSR held note (hold added to today's ADSR); Trigger = one-shot + with %-length + fade-in/out, ignores note-off; both carry a modifiable start point; Gate + keeps loop points. **S16 reshaped (r8, Daniel's duration-preserving correction):** a per- + zone **pitch-engine mode — Varispeed** (current, cheap, pitch/duration coupled — classic + sampler, right for drums) **vs Preserve** (duration-preserving via a per-voice pitch + shifter — right for tempo-locked loops/phrases). Pitch envelope = per-voice AD, off by + default, riding either engine (biases `ratio_` under Varispeed, the shift amount under + Preserve). WDL verdict corrected: **`WDL_SimplePitchShifter` is the Preserve-engine + candidate** (duration-preserving OLA — RT-viable per-voice with pre-warm; the load-bearing + cost is onset latency), `WDL_Resampler` (sinc) held as a Varispeed-quality upgrade only; no + formant-preserving/elastique in WDL. Forks: **S16-F1** (engine default — lean Preserve, + Daniel's call), **S16-F2** (Preserve impl — WDL shifter first / hand-rolled held), plus + S15-F1 (choke, held) / S15-F2 (param granularity, lean per-zone). Feature set settled; + the engine default is Daniel's fork. + +**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original +dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded +UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the ReaSampler +9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-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. --- diff --git a/src/actions.cpp b/src/actions.cpp index 1868561..be69618 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -645,7 +645,9 @@ void doBankDelete() { ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); return; } - persistBankOp("ReaSampler: delete bank"); + // S9: bump only when the deleted bank held samples — dropping them changes what a live + // instance referencing one could play. Deleting an EMPTY bank is purely organizational. + persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); } // Evacuate a named bank: move every member back to the pool (index-only, collapse by @@ -666,7 +668,8 @@ void doBankEvacuate() { "destination, not a source).\n"); return; } - persistBankOp("ReaSampler: evacuate bank"); + // S9: evacuate moves members between banks (bank membership changes) -> bump. + persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); } // Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), @@ -752,7 +755,9 @@ void doBankTransferSelected(bool copy) { if (mutated) { const std::string label = std::string("ReaSampler: ") + verb + " sample(s)"; - persistBankOp(label.c_str()); + // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an + // instance may reference) -> bump so assigned instances refresh hands-free. + persistBankOp(label.c_str(), /*bumpGeneration=*/true); } } @@ -794,7 +799,9 @@ void doBankRemoveSelected() { } // No-op guardrail (R-B): open an undo point only if the index actually mutated. - if (removed > 0) persistBankOp("ReaSampler: remove sample(s)"); + // S9: a remove drops a sample from a bank (an instance referencing it must refresh — it + // will resolve to silence, per the stale-id policy) -> bump. + if (removed > 0) persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); } // Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE @@ -884,8 +891,14 @@ void doBankPruneFolder() { // change stands and persists on the user's next save; it just earns no undo point until // there is a project to persist into (undo of an unsaved bank op has nothing to roll // back to anyway). The Begin/End must still be balanced, hence the close-either-way. -void persistBankOp(const char* label) { +void persistBankOp(const char* label, bool bumpGeneration) { Undo_BeginBlock2(nullptr); + // S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the + // fresh generation rides the same ext-state write the persist makes (persistBook() -> + // saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs + // (the caller decides); a pure-organizational verb passes false and leaves the counter be, + // so a rename/activate does not needlessly refresh live instances. + if (bumpGeneration) g_session->bumpBankGeneration(); const bool persisted = persistBook(); if (persisted) Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); diff --git a/src/actions.h b/src/actions.h index 4ca2a74..e9d9bfe 100644 --- a/src/actions.h +++ b/src/actions.h @@ -79,6 +79,14 @@ int bankPruneCommandId(); // must invoke this ONLY after a successful/effective mutation — rejected ops (duplicate // name, un-deletable pool, etc.) must return before reaching here so no empty undo // point is ever opened for a no-op. Defined in actions.cpp alongside persistBook(). -void persistBankOp(const char* label); +// +// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a live +// instance would PLAY — move / copy / remove / evacuate / delete-with-members (a sample left, +// arrived, or dropped out of a bank an instance may reference). Leave it false (the default) +// for a PURELY ORGANIZATIONAL verb — create / rename / activate / reorder — which changes no +// existing (bankId, sampleId) -> content mapping, so no instance need refresh. The bump (when +// requested) happens INSIDE the block, BEFORE persistBook(), so the stamped counter rides the +// same ext-state write and undo captures the pre/post generation with the rest of the blob. +void persistBankOp(const char* label, bool bumpGeneration = false); } // namespace reasampler diff --git a/src/app_version.cpp b/src/app_version.cpp index b51e7cd..247c24a 100644 --- a/src/app_version.cpp +++ b/src/app_version.cpp @@ -86,6 +86,24 @@ const std::string& dockIdent() { return kIdent; } +const std::string& vstOutputName() { + // The .vst3 module OUTPUT_NAME base — FOREVER-STABLE per channel. Stable is + // byte-identical to pre-S18 ("reasampler_9000"); beta is isolated so both install + // side-by-side without a filename collision. + static const std::string kName = + kIsBeta ? "reasampler_9000_beta" : "reasampler_9000"; + return kName; +} + +const std::string& vstPluginName() { + // The factory display name / editor title / embed label. Stable is byte-identical to + // pre-S18 ("ReaSampler 9000"); beta appends " beta" so the two channels are distinct + // plugins in the FX browser. + static const std::string kName = + kIsBeta ? "ReaSampler 9000 beta" : "ReaSampler 9000"; + return kName; +} + std::string channelCommandId(const std::string& suffix) { return commandIdPrefix() + suffix; } diff --git a/src/app_version.h b/src/app_version.h index 052c273..3bee96f 100644 --- a/src/app_version.h +++ b/src/app_version.h @@ -104,6 +104,33 @@ const std::string& binaryName(); const std::string& dockTitle(); const std::string& dockIdent(); +// --- VST3 instrument identity (S18, beta-in-isolation) ------------------------------ +// +// The ReaSampler 9000 VST3 instrument forks its plugin identity per channel exactly as the +// extension forks its binary/dock idents above — one channel per binary, all derived from +// the ONE channel bit here, so the VST shell carries no #ifdef fork. These are the VST's +// analogues of binaryName()/dockTitle(): the on-disk module name and the human-facing name. +// +// vstOutputName() — the CMake OUTPUT_NAME base for the .vst3 module. Stable: +// "reasampler_9000" (byte-identical to pre-S18). Beta: +// "reasampler_9000_beta". Mirrors the CMake target's OUTPUT_NAME (the +// authoritative artifact name); exposed here so the one derivation lives +// in this module. FOREVER-STABLE per channel — the on-disk filename a +// REAPER project's saved instance path may reference. +// vstPluginName() — the factory display name (FX browser), editor title band, and S6 +// embed-strip label. Stable: "ReaSampler 9000". Beta: +// "ReaSampler 9000 beta". Sourced from here, never a literal in +// reasampler_vst.h / vst_entry.cpp / the editor / the embed strip. +// +// NOTE: the VST3 CLASS UID is NOT here — a UID is not a string derivation but a compile-time +// FUID/INLINE_UID constant the factory needs in brace-init form; it lives in reasampler_vst.h, +// channel-selected by the same REASAMPLER_CHANNEL_IS_BETA bit. This module owns the string +// identity; reasampler_vst.h owns the binary UID identity. The version display the factory +// stamps into PClassInfo2 reuses appVersion() (it already renders "-beta" on beta) — no +// separate VST version accessor. +const std::string& vstOutputName(); +const std::string& vstPluginName(); + // --- Channel-qualified action id / name builders ------------------------------------ // // The two composition helpers every action-registering shell (main.cpp, actions.cpp) diff --git a/src/assignment_request.cpp b/src/assignment_request.cpp new file mode 100644 index 0000000..077aeee --- /dev/null +++ b/src/assignment_request.cpp @@ -0,0 +1,147 @@ +// assignment_request.cpp — see assignment_request.h. Pure: standard library only. + +#include "assignment_request.h" + +#include +#include + +namespace reasampler { + +namespace { + +constexpr const char* kMagic = "rsassign1"; + +// Append one length-prefixed field: ':' . Mirror of +// provenance's putField so the two seams share one wire idiom. +void putField(std::string& out, const std::string& field) { + out += std::to_string(field.size()); + out += ':'; + out += field; +} + +// Cursor over the encoded string. All reads are bounds-checked; a short read fails +// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the +// three field kinds this record needs. +class Cursor { +public: + explicit Cursor(const std::string& s) : s_(s) {} + + bool ok() const { return ok_; } + bool atEnd() const { return pos_ >= s_.size(); } + + // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or + // non-numeric length, 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(c - '0'); + // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. + if (len > (std::numeric_limits::max() - digit) / 10u) + return fail(); + len = len * 10u + digit; + } + const std::size_t start = colon + 1; + // 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(c - '0'); + // Overflow guard: v * 10 + digit must not exceed INT64_MAX. + if (v > (std::numeric_limits::max() - digit) / 10) + return fail(); + v = v * 10 + digit; + } + out = neg ? -v : v; + return true; + } + + // Consumes an exact literal at the cursor (the magic tag). Fails if absent. + bool literal(const char* lit) { + if (!ok_) return false; + std::size_t i = 0; + for (; lit[i] != '\0'; ++i) { + if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); + } + pos_ += i; + return true; + } + +private: + bool fail() { + ok_ = false; + return false; + } + + const std::string& s_; + std::size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace + +std::string encodeAssignmentRequest(const AssignmentRequest& req) { + std::string out = kMagic; + putField(out, req.bankId); + putField(out, req.sampleId); + putField(out, std::to_string(req.generation)); + return out; +} + +std::optional decodeAssignmentRequest(const std::string& wire) { + Cursor cur(wire); + if (!cur.literal(kMagic)) return std::nullopt; + + AssignmentRequest req; + if (!cur.field(req.bankId)) return std::nullopt; + if (!cur.field(req.sampleId)) return std::nullopt; + if (!cur.fieldInt64(req.generation)) return std::nullopt; + + // Reject trailing garbage: a well-formed value ends exactly at the last field. + if (!cur.ok() || !cur.atEnd()) return std::nullopt; + return req; +} + +} // namespace reasampler diff --git a/src/assignment_request.h b/src/assignment_request.h new file mode 100644 index 0000000..46c1e03 --- /dev/null +++ b/src/assignment_request.h @@ -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 +#include +#include + +namespace reasampler { + +// One assignment request: the ingested sample's identity + a monotonic disambiguator. +// bankId — the bank the sample was ingested into (the active/target bank). +// sampleId — the ingested Sample's stable id (BankIndex key). +// generation — a monotonic value the reader compares to detect a NEW request. The +// writer supplies a unix-epoch-seconds stamp; the reader treats it as an +// opaque "did this change?" token, not a wall-clock it interprets. +struct AssignmentRequest { + std::string bankId; + std::string sampleId; + std::int64_t generation = 0; + + bool operator==(const AssignmentRequest& o) const { + return bankId == o.bankId && sampleId == o.sampleId && + generation == o.generation; + } + bool operator!=(const AssignmentRequest& o) const { return !(*this == o); } +}; + +// Encode an assignment request to the wire string. Length-prefixed fields behind a +// magic+version tag ("rsassign1"), so arbitrary bytes in an id (a GUID, a display- +// derived id) round-trip whole with no escaping ambiguity — the same idiom provenance +// uses. Deterministic: the same request always yields the same string. +// +// FORMAT (documented for the LATER instrument-side reader): +// "rsassign1" ':' ':' ':' +// where each is the decimal byte length of the field that follows the ':'. +std::string encodeAssignmentRequest(const AssignmentRequest& req); + +// Parse a wire string produced by encodeAssignmentRequest. std::nullopt on any +// malformed / truncated / trailing-garbage input (never UB, never a partial value) — +// the reader shell treats absence/malformed as "no pending request." Round-trips: +// decodeAssignmentRequest(encodeAssignmentRequest(x)) == x. +// +// READER REQUIREMENT (instrument-side, S8 follow-up dispatch): after successfully +// decoding a request, the reader MUST verify that (bankId, sampleId) resolves to an +// existing sample before acting on it. An undo on the extension side rolls back the +// `banks` ext-state key (removing the sample) but cannot atomically clear the +// `assign_request` key if the write happened outside the undo block. Even with the +// undo-grouping fix (Major 2), the reader must guard against this: treat an +// unresolvable (bankId, sampleId) pair as a stale/no-op request and discard it +// silently, never crashing or selecting a nonexistent entry. +std::optional decodeAssignmentRequest(const std::string& wire); + +} // namespace reasampler diff --git a/src/bank_model.cpp b/src/bank_model.cpp index e451219..a5286ec 100644 --- a/src/bank_model.cpp +++ b/src/bank_model.cpp @@ -35,6 +35,10 @@ bool Levels::operator==(const Levels& o) const { return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs; } +bool LoopPoints::operator==(const LoopPoints& o) const { + return start == o.start && end == o.end; +} + bool Sample::operator==(const Sample& o) const { return id == o.id && displayName == o.displayName && relativePath == o.relativePath && sourceMode == o.sourceMode && sourceRange == o.sourceRange && @@ -43,7 +47,8 @@ bool Sample::operator==(const Sample& o) const { lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats && captureTempo == o.captureTempo && captureTimeSigNum == o.captureTimeSigNum && - captureTimeSigDenom == o.captureTimeSigDenom && key == o.key && levels == o.levels && + captureTimeSigDenom == o.captureTimeSigDenom && key == o.key && + rootNote == o.rootNote && loop == o.loop && levels == o.levels && clipped == o.clipped && tier == o.tier && contentHash == o.contentHash && provenance == o.provenance && createdTimestamp == o.createdTimestamp; } @@ -253,6 +258,21 @@ void writeSample(std::string& out, const Sample& s) { w.keyBegin("key"); if (s.key) writeEscaped(out, *s.key); else out += "null"; + // Phase S seam fields (D-B). Emitted as null when absent (same shape as `key` + // and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely — + // parses to empty optionals and re-serializes without invention. + w.keyBegin("rootNote"); + if (s.rootNote) out += numToStr(*s.rootNote); else out += "null"; + + w.keyBegin("loop"); + if (s.loop) { + ObjWriter lp(out); + lp.keyRaw("start", numToStr(s.loop->start)); + lp.keyRaw("end", numToStr(s.loop->end)); + } else { + out += "null"; + } + w.keyBegin("levels"); { ObjWriter l(out); @@ -608,6 +628,41 @@ bool Parser::parseSample(Sample& s) { if (!parseString(k)) return false; s.key = k; } + } else if (key == "rootNote") { + bool wasNull = false; + if (!expectNullOr(wasNull)) return false; + if (wasNull) { + s.rootNote.reset(); + } else { + int v = 0; + if (!parseInt(v)) return false; + // Valid MIDI note range: 0..127 inclusive (boundaries valid). + if (v < 0 || v > 127) return false; + s.rootNote = v; + } + } else if (key == "loop") { + bool wasNull = false; + if (!expectNullOr(wasNull)) return false; + if (wasNull) { + s.loop.reset(); + } else { + if (!consume('{')) return false; + LoopPoints lp; + do { + std::string lk; + if (!parseKey(lk)) return false; + std::int64_t lv = 0; + if (!parseInt64(lv)) return false; + if (lk == "start") lp.start = lv; + else if (lk == "end") lp.end = lv; + } while (consume(',')); + if (!consume('}')) return false; + // Invariant: 0 <= start <= end. start == end is a valid zero-length + // marker; a negative index or start > end is malformed, not silently + // clamped (mirrors the enum-range rejection above). + if (lp.start < 0 || lp.end < lp.start) return false; + s.loop = lp; + } } else if (key == "levels") { if (!consume('{')) return false; do { diff --git a/src/bank_model.h b/src/bank_model.h index 19ce437..5ad8472 100644 --- a/src/bank_model.h +++ b/src/bank_model.h @@ -63,6 +63,22 @@ struct Levels { bool operator==(const Levels& o) const; }; +// Sample-accurate sustain-loop bounds, as frame indices into the captured file +// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like +// sampleRate or length — consumed by the future MIDI-playback instrument to hold +// notes past the recorded length. Modeled as one optional struct (not two loose +// optionals) so "both points or neither" is a structural invariant, not a rule to +// re-check at every boundary. Frame indices, not seconds, because the loop is a +// per-sample-frame contract; the instrument reads the file's sample rate to relate +// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end. +// start == end is a valid zero-length loop marker. +struct LoopPoints { + std::int64_t start = 0; + std::int64_t end = 0; + + bool operator==(const LoopPoints& o) const; +}; + // The metadata record for one captured sample. The audio itself lives in a // project-relative file; `relativePath` is ALWAYS relative (enforced at the // BankIndex::add boundary — see AddResult). @@ -95,6 +111,18 @@ struct Sample { std::optional key; // musical key, when known + // Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument, + // additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S + // samples deserialize without them and re-serialize without inventing values. + // - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument + // can repitch it across the keyboard. DISTINCT from the musical `key` above: + // `key` is a human label ("F#m"); `rootNote` is the exact pitch for repitch. + // Populated at/after capture only where derivable — left empty (never guessed) + // when the source is not a single played note. + // - loop: sustain-loop bounds, populated only where explicitly set. + std::optional rootNote; + std::optional loop; + Levels levels; bool clipped = false; diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 6037411..dc39592 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -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 #include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) +#include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest #else #include #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& 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& 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 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 paths; + const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0); + paths.reserve(count); + for (UINT i = 0; i < count; ++i) { + // Query the required length first (excludes the NUL), then read into a sized buffer. + const UINT len = DragQueryFile(hDrop, i, nullptr, 0); + if (len == 0) continue; + std::vector buf(static_cast(len) + 1, '\0'); + DragQueryFile(hDrop, i, buf.data(), static_cast(buf.size())); + std::string p(buf.data()); + if (!p.empty()) paths.push_back(std::move(p)); + } + DragFinish(hDrop); + if (!paths.empty()) ingestDroppedFiles(paths); +} + WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { + case WM_DROPFILES: + // S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import + // into the active bank + assign the first. wParam is the HDROP. + handleDropFiles(reinterpret_cast(wParam)); + return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); @@ -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); diff --git a/src/capture.cpp b/src/capture.cpp index 48bb3b6..61c9113 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -530,6 +530,11 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { } } s.createdTimestamp = static_cast(std::time(nullptr)); + // Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a + // master mix / track / time-selection is not a single played note, so no root + // note is derivable here — we do NOT guess one. Loop points are set later by an + // explicit user action, not at capture. Leaving them empty is the honest default; + // the instrument (Phase S) treats an absent root note as "not a pitched sample". result.status = CaptureStatus::Ok; result.sample = s; diff --git a/src/capture_paths.cpp b/src/capture_paths.cpp index 47336e4..33bfbce 100644 --- a/src/capture_paths.cpp +++ b/src/capture_paths.cpp @@ -5,6 +5,7 @@ #include #include #include // std::memcmp +#include #include namespace reasampler { @@ -213,6 +214,15 @@ std::string resolveBankFile(const std::string& projectDir, return dir + "/" + rel; } +std::string projectDirOfRpp(const std::string& rppPath) { + // An unsaved project reports an empty .rpp path; keep it empty so downstream + // resolution refuses (no default-location fallback). Mirrors persist.cpp's prior + // projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes. + if (rppPath.empty()) return {}; + std::string dir = std::filesystem::path(rppPath).parent_path().string(); + return normalizeSlashes(dir); +} + BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, const std::string& newProjectDir) { BankRelocation r; diff --git a/src/capture_paths.h b/src/capture_paths.h index a83387a..4ab8026 100644 --- a/src/capture_paths.h +++ b/src/capture_paths.h @@ -121,6 +121,14 @@ std::string bankRelativeForName(const std::string& fileName); std::string resolveBankFile(const std::string& projectDir, const std::string& relativePath); +// The project directory that holds a .rpp: its parent directory, forward-slashed, +// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty +// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the +// no-default-location invariant). This is the M4 convention persist uses to place +// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves +// audio paths the SAME way persist does rather than re-implementing the derivation. +std::string projectDirOfRpp(const std::string& rppPath); + // A relocation plan for the physical bank folder on Save-As to a new project // location. The index's relative paths do NOT change (they are relative to the // project dir, which is what moved with the .rpp), so relocation is purely a diff --git a/src/drag_out.cpp b/src/drag_out.cpp index 158ca32..db58c65 100644 --- a/src/drag_out.cpp +++ b/src/drag_out.cpp @@ -19,7 +19,13 @@ bool insideClient(int px, int py, const PanelClientRect& c) { DragGesture decideGesture(int px, int py, const PanelClientRect& client, const DragState& state) { if (!state.dragging || !state.hasArmedSamples) return DragGesture::None; - return insideClient(px, py, client) ? DragGesture::Internal : DragGesture::OsDrag; + if (insideClient(px, py, client)) return DragGesture::Internal; + // Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is + // still over REAPER's own UI is an instrument drop (heading for a track's FX button); + // anything else (a multi-capture payload, or the pointer off REAPER entirely) is the + // unchanged M11 OS drag-out. + if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop; + return DragGesture::OsDrag; } PathList assemblePathList(const std::vector& resolved) { diff --git a/src/drag_out.h b/src/drag_out.h index 3e50c1f..312b47e 100644 --- a/src/drag_out.h +++ b/src/drag_out.h @@ -52,28 +52,50 @@ struct PanelClientRect { // whether a drag is currently active (threshold crossed) and whether the armed payload // carries at least one sample. (Pre-threshold "armed but not yet dragging" is NOT a drag // for this decision — the shell only asks once a drag is under way.) +// +// S17 (drop-and-load) adds two inputs that refine the OUTSIDE-the-panel decision without +// touching the INSIDE decision (the internal bank-to-bank drag stays byte-identical): +// * singleCapture — the payload holds EXACTLY ONE sample id. Only a single-capture drag +// arms the InstrumentDrop gesture (per the S17 open-question lean: a multi-capture drag +// over an FX button is NOT an instrument drop — it falls through to OsDrag, the natural +// multi-file drag-out to Explorer/another DAW). REJECT, not load-first: the whole gesture +// is "make ONE capture a playable instrument", so a multi payload is out of contract here. +// * overReaperUi — a SHELL-SUPPLIED predicate: true when the pointer, though outside the +// panel client rect, is still over REAPER's OWN window/UI (the shell owns the REAPER +// hit query, e.g. GetThingFromPoint; the pure layer owns only the set/boundary algebra). +// Both default false, so an M11-era caller that fills only {dragging, hasArmedSamples} gets +// EXACTLY the M11 behavior: outside the client rect with overReaperUi=false -> OsDrag. struct DragState { bool dragging = false; // threshold crossed; a drag is in progress bool hasArmedSamples = false; // the drag payload holds >= 1 sample id + bool singleCapture = false; // S17: payload holds EXACTLY one sample (arms InstrumentDrop) + bool overReaperUi = false; // S17: pointer is over REAPER's own UI (shell-supplied) }; // What the shell should do with the drag given the current pointer position. enum class DragGesture { - None, // no drag under way, or an empty payload — do nothing - Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag - OsDrag, // dragging with samples, pointer left the client area — hand off to the OS + None, // no drag under way, or an empty payload — do nothing + Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag + InstrumentDrop, // S17: single-capture drag left the panel but is over REAPER's UI — + // the shell hover-tracks the TCP FX button and, on release, adds a + // ReaSampler 9000 instance preloaded with the dragged capture. + OsDrag, // dragging with samples, pointer left REAPER entirely — hand off to the OS }; // Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. // * Not dragging (or no armed samples): None — the shell ignores the move. // * Dragging with samples, pointer INSIDE the client rect: Internal — unchanged // bank-to-bank behavior (invariant #4: the internal drag stays byte-identical). -// * Dragging with samples, pointer OUTSIDE the client rect: OsDrag — the samples are -// leaving the panel; the shell initiates the native OS drag with the resolved paths. -// The boundary is the client rect edge: the internal drag never targets outside it, so -// crossing it is an unambiguous, discoverable OS-drag trigger. Re-entry is the shell's -// concern (the OS drag loop is modal once begun); this function reports OsDrag purely from -// position, so a shell that has already handed off simply will not ask again. +// * Dragging OUTSIDE the client rect, SINGLE capture, over REAPER's UI: InstrumentDrop — +// the drag is heading for a track's FX button (S17); the shell hover-tracks + highlights. +// * Dragging OUTSIDE the client rect otherwise (multi-capture, OR the pointer has left +// REAPER entirely): OsDrag — the samples are leaving to the OS; the shell initiates the +// native OS drag with the resolved paths. +// The INSIDE decision is untouched (M11 internal drag is byte-identical). The M11 boundary +// (left the client rect -> OsDrag) is REFINED, not replaced: leaving the rect now asks +// "single-capture and over REAPER's UI -> InstrumentDrop, else -> OsDrag" — so the M11 +// OS-drag-out (multi payload, or pointer off REAPER) keeps its exact behavior. Position-only +// + state-only (no hidden state), so re-entry back inside returns Internal. DragGesture decideGesture(int px, int py, const PanelClientRect& client, const DragState& state); diff --git a/src/ext_keys.h b/src/ext_keys.h new file mode 100644 index 0000000..9f8595e --- /dev/null +++ b/src/ext_keys.h @@ -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 diff --git a/src/ingest.cpp b/src/ingest.cpp new file mode 100644 index 0000000..78d1955 --- /dev/null +++ b/src/ingest.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path) +#include "app_version.h" // channelCommandId / channelActionName +#include "assignment_request.h" // pure (bankId, sampleId, generation) encode +#include "bank_book.h" // BankBook, Bank, activeBankId / activeIndex +#include "bank_model.h" // Sample, AddResult, findByHash +#include "bank_panel.h" // bankPanelRefresh +#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent +#include "persist.h" // ReaSamplerSession + +#include "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 buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + return projectDirOfRpp(std::string(buf.data())); +} + +// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror +// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy. +std::vector readFileBytes(const std::string& path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return {}; + const std::streamsize n = f.tellg(); + if (n <= 0) return {}; + std::vector bytes(static_cast(n)); + f.seekg(0); + f.read(reinterpret_cast(bytes.data()), n); + if (!f) return {}; + return bytes; +} + +// Writes a byte buffer to a file. Returns true on success. The caller is responsible for +// ensuring the directory exists before calling. +bool writeFileBytes(const std::string& path, const std::vector& bytes) { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + return f.good(); +} + +// Builds a minimal 32-bit-float RIFF/WAVE byte buffer from interleaved double samples. +// The output is a canonical WAV the bank and wav_trim can read: +// RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, 16-byte body), +// data chunk (interleaved little-endian float32, one float per sample per channel). +// `nch` channels, `rate` Hz sample rate, `frameCount` frames (total samples = frameCount*nch). +// Each ReaSample (double) is narrowed to float by assignment — the instrument expects +// 32-bit float; the reduction is intentional and matches how the bank contract is defined +// (capture.cpp kRenderFormatWavFloat32; wav_trim.h FORMAT ASSUMPTION). +std::vector buildFloat32Wav(int nch, std::uint32_t rate, + std::size_t frameCount, + const std::vector& interleaved) { + const std::size_t sampleCount = frameCount * static_cast(nch); + const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32 + + // The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload. + // Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount. + const std::uint32_t riffSize = + static_cast(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data + + std::vector out; + out.reserve(44u + dataBytesCount); + + auto putU16 = [&](std::uint16_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + }; + auto putU32 = [&](std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); + }; + auto putTag = [&](const char* t) { + for (int i = 0; i < 4; ++i) + out.push_back(static_cast(t[i])); + }; + auto putF32 = [&](float f) { + std::uint8_t tmp[4]; + std::memcpy(tmp, &f, 4); + for (int i = 0; i < 4; ++i) out.push_back(tmp[i]); + }; + + // RIFF header + putTag("RIFF"); + putU32(riffSize); + putTag("WAVE"); + + // fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003) + putTag("fmt "); + putU32(16u); // chunk body size + putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT + putU16(static_cast(nch)); + putU32(rate); + putU32(rate * static_cast(nch) * 4u); // avgBytesPerSec + putU16(static_cast(nch * 4)); // blockAlign + putU16(32u); // bitsPerSample + + // data chunk + putTag("data"); + putU32(static_cast(dataBytesCount)); + for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i) + putF32(static_cast(interleaved[i])); + + return out; +} + +// Decodes ALL samples from `src` into interleaved double-precision frames. +// Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0). +// Uses GetSamples in blocks; advances time_s monotonically. The caller has already +// queried channelCount and sampleRate from the same source; those values are passed in +// to avoid re-querying after GetSamples mutates decoder state. +std::vector decodePcmSource(PCM_source* src, int nch, double sampleRate, + double lengthSeconds) { + if (!src || nch <= 0 || sampleRate < 1.0 || lengthSeconds <= 0.0) return {}; + + const std::size_t totalFrames = + static_cast(lengthSeconds * sampleRate + 0.5); + if (totalFrames == 0) return {}; + + std::vector out; + out.reserve(totalFrames * static_cast(nch)); + + // Pull samples in blocks of ~4096 frames; loop until source is exhausted. + constexpr int kBlockFrames = 4096; + std::vector block(static_cast(kBlockFrames * nch)); + + PCM_source_transfer_t t{}; + t.samplerate = sampleRate; + t.nch = nch; + t.time_s = 0.0; + t.midi_events = nullptr; + + while (true) { + t.samples = block.data(); + t.length = kBlockFrames; + t.samples_out = 0; + src->GetSamples(&t); + if (t.samples_out <= 0) break; + const std::size_t got = static_cast(t.samples_out) * + static_cast(nch); + out.insert(out.end(), block.data(), block.data() + got); + t.time_s += static_cast(t.samples_out) / sampleRate; + } + return out; +} + +// The result of an import-into-bank: the sample id to assign (the existing id on a +// hash-dedup collapse, the new id otherwise) and whether anything was added to the index +// (so the caller opens an undo point only for a real mutation). +struct ImportResult { + std::string sampleId; // "" on failure (nothing to assign) + bool added = false; // true iff a NEW index entry was created (not a collapse) + std::string message; // human-readable outcome for the console +}; + +// 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 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 bankBytes; + if (isFloat32Wav) { + // Fast path: already canonical — bank bytes ARE the source bytes. + bankBytes = srcBytes; + if (srcHandle) PCM_Source_Destroy(srcHandle); + } else { + // Conversion path: decode all samples then write a fresh 32f WAV. + // PCM_source is opened on the source path (not a copy); we already have srcHandle. + std::vector decoded; + if (srcHandle && channelCount > 0 && sampleRate > 0 && lengthSeconds > 0.0) { + decoded = decodePcmSource(srcHandle, channelCount, + static_cast(sampleRate), lengthSeconds); + } + if (srcHandle) PCM_Source_Destroy(srcHandle); + + if (decoded.empty()) { + // No decodable audio. The source is on disk (valid path, REAPER could open it) + // but yielded no samples — e.g. a MIDI file, a zero-length audio file, or a + // format REAPER does not support. Fail loudly: we must not write a silent WAV + // and pretend the import succeeded. + out.message = "could not decode audio samples from: " + + fs::path(absoluteSourcePath).filename().string() + + " (unsupported format or no audio data)"; + return out; + } + + const std::size_t frameCount = + decoded.size() / static_cast(channelCount > 0 ? channelCount : 1); + bankBytes = buildFloat32Wav(channelCount, + static_cast(sampleRate), + frameCount, decoded); + } + // srcHandle is destroyed above in both branches. + + // Hash the converted (bank-format) bytes for dedup. WAV-aware hash (hashWavContent) + // so a re-import of the same source deduplicates against a previously-captured or + // previously-imported sample with identical audio content, even if non-audio RIFF + // chunks differ. Empty hash (unhashable) is treated as "not dedupable" (safe direction: + // copies + adds rather than silently collapsing onto an unrelated entry). + const std::string contentHash = hashWavContent(bankBytes); + + BankBook& book = g_session->book(); + + // 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::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 nameBuf(4096, '\0'); + int filemode = 0; + double selStart = 0.0, selEnd = 0.0; + double pitch = 0.0, vol = 0.0, rate = 0.0, srcbpm = 0.0; + std::vector extra(256, '\0'); // documented unused; sized generously to be safe + const bool ok = MediaExplorerGetLastPlayedFileInfo( + nameBuf.data(), static_cast(nameBuf.size()), &filemode, &selStart, &selEnd, + &pitch, &vol, &rate, &srcbpm, extra.data(), static_cast(extra.size())); + + const std::string path(nameBuf.data()); + if (!ok || path.empty()) { + ShowConsoleMsg("ReaSampler ingest: no Media Explorer file to import -- open the " + "Media Explorer and select (or preview) a file first.\n"); + return; + } + + const ImportResult r = importFileIntoActiveBank(path); + if (r.sampleId.empty()) { + ShowConsoleMsg(("ReaSampler ingest: Media Explorer import failed -- " + r.message + + ".\n").c_str()); + return; + } + + // Persist the bank add 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::time(nullptr)); + + g_session->writeAssignmentRequest(encodeAssignmentRequest(req)); +} + +// --- Drop-onto-panel ingest -------------------------------------------------- + +void ingestDroppedFiles(const std::vector& absolutePaths) { + if (!g_session || absolutePaths.empty()) return; + + // Import ALL dropped files; assign the FIRST successfully-imported one (documented + // multi-file policy). Batch the persist + undo point: many imports are ONE undo entry. + std::string firstAssignId; + std::string firstAssignBank; + int importedNew = 0; + int importedTotal = 0; // includes dedup collapses that still yielded an id to assign + std::string lastFailure; + + for (const std::string& path : absolutePaths) { + const ImportResult r = importFileIntoActiveBank(path); + if (r.sampleId.empty()) { + lastFailure = r.message; + continue; + } + ++importedTotal; + if (r.added) ++importedNew; + if (firstAssignId.empty()) { + firstAssignId = r.sampleId; + firstAssignBank = g_session->book().activeBankId(); + } + } + + // One undo point for the whole drop, opened only if a NEW index entry was created (a + // drop that only re-hit existing content mutated nothing on the index). 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 diff --git a/src/ingest.h b/src/ingest.h new file mode 100644 index 0000000..a6b2203 --- /dev/null +++ b/src/ingest.h @@ -0,0 +1,77 @@ +#pragma once +// ingest — the S8 "ingest through the bank" shell (EXTENSION side). +// +// Compiled into the reaper_reasampler MODULE. REAPER-facing (PCM_Source metadata reads, +// Media-Explorer query, ext-state assignment write, action registration), so it is +// DAW-verified, not unit-tested; the pure serialization it drives lives in +// assignment_request (tested in CTest). +// +// -- The one gesture (CONTEXT.md §Ingest through the bank) -------------------- +// +// Loading a sample into the sampler is ONE gesture: capture/import-into-bank AND +// auto-assign to the active sampler instance. The EXTENSION owns ingest (it has arrange +// access, Media-Explorer access, and the drop-target surface on its own panels); the +// instrument stays a READ-ONLY bank consumer. Three ingest surfaces: +// +// 1. Arrange capture -> bank -> assign (a bindable action; reuses the capture path). +// 2. Media-Explorer import -> bank -> assign (a bindable action; single-file, pull-on- +// action via MediaExplorerGetLastPlayedFileInfo). +// 3. Drop-onto-panel -> bank -> assign (an OS file drop on the docked bank_panel HWND; +// multi-file: import all, assign the first). +// +// -- The load-bearing principle (restated) ----------------------------------- +// +// Ingest NEVER inserts a timeline item. Capture writes a file + an index entry; import +// copies a file + adds an index entry; assignment is a bank-index + instance-selection +// act, not a placement. Any path here that calls InsertMedia would be a bug. +// +// -- Import semantics --------------------------------------------------------- +// +// A Media-Explorer/drop import is a FILE COPY into the project-relative bank folder + +// an index add, mirroring how a capture lands (relative-paths-only, hash-dedup). If the +// active bank already holds the imported content (by content hash), the import collapses +// onto the existing sample and assigns THAT sample's id — no redundant on-disk copy. + +#include +#include + +// Forward declarations keep this header REAPER-free at its own boundary (the .cpp pulls +// the SDK). reaper_plugin_info_t is REAPER's dispatch struct; ReaSamplerSession owns the +// book + persist bridge the ingest paths mutate. +struct reaper_plugin_info_t; + +namespace reasampler { + +class ReaSamplerSession; + +// Registers the S8 ingest action family (command_id/gaccel per the house contract), +// mirror of bankRegisterActions. `session` is the live session the ingest paths mutate +// (shared with the capture / bank / Design-View families). The single hookcommand in +// main.cpp routes fired ids here via ingestHandleCommand. +void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); + +// Routes a fired command id to its ingest action. Returns true iff it was one of ours +// (claim-only, per the hookcommand contract); false otherwise so the hook keeps looking. +bool ingestHandleCommand(int command); + +// Mirror-unregisters the ingest action family on unload (the '-'-prefixed strings). +void ingestUnregisterActions(reaper_plugin_info_t* rec); + +// Write the S8 assignment request for a just-ingested sample: "the active sampler +// instance should now play (bankId, sampleId)." Encodes the pure assignment_request value +// (with a fresh monotonic generation stamp) and routes it to ext state via the session. +// Called by EVERY ingest surface after the sample lands in the bank — the arrange +// capture+assign action (main.cpp, alongside the capture machinery it reuses), the ME +// import action, and the drop path. A no-op-safe write: if there is no saved/active +// project the request is silently dropped (nothing to signal into), matching the +// book/manifest quiet-persist idiom. `sampleId` empty -> no write (nothing to assign). +void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId); + +// Ingest OS-dropped files onto a ReaSampler surface (S8 drop path). Called by the +// bank_panel's WM_DROPFILES handler with the dropped file paths (absolute, OS-native). +// Imports EVERY file into the active bank (copy + index add, hash-dedup) and assigns the +// FIRST successfully-imported sample to the active instance. A no-op on an empty list or +// an unsaved/no-active project (nothing to import into). Reports outcomes to the console. +void ingestDroppedFiles(const std::vector& absolutePaths); + +} // namespace reasampler diff --git a/src/instrument_drop.cpp b/src/instrument_drop.cpp new file mode 100644 index 0000000..bc28657 --- /dev/null +++ b/src/instrument_drop.cpp @@ -0,0 +1,108 @@ +// instrument_drop — pure implementation. See instrument_drop.h. +// NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer. + +#include "instrument_drop.h" + +#include "vst/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer) + +namespace reasampler { + +namespace { + +constexpr char kB64Alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// -1 = not a base64 char; index by unsigned byte. Built once. +int b64Value(unsigned char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} + +} // namespace + +std::vector instrumentDropStateBytes(const std::string& sampleId) { + // The ONE fact the drop carries: this capture is the instance's selection. Everything + // else stays at the fresh-instance defaults (no zones, mono, generation 0) — the same + // ComponentState a browser click would produce. serializeComponentState is the + // instrument's own writer (the single source of truth for the byte layout), so this is + // NOT a parallel encoder — it IS the instrument's encoder. + ComponentState cs; + cs.selectionId = sampleId; + return serializeComponentState(cs); +} + +std::string buildInstrumentDropChunk(const std::string& sampleId) { + return encodeBase64(instrumentDropStateBytes(sampleId)); +} + +std::string encodeBase64(const std::vector& bytes) { + std::string out; + out.reserve(((bytes.size() + 2) / 3) * 4); + std::size_t i = 0; + const std::size_t n = bytes.size(); + while (i + 3 <= n) { + const std::uint32_t triple = (static_cast(bytes[i]) << 16) | + (static_cast(bytes[i + 1]) << 8) | + static_cast(bytes[i + 2]); + out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]); + out.push_back(kB64Alphabet[triple & 0x3F]); + i += 3; + } + const std::size_t rem = n - i; + if (rem == 1) { + const std::uint32_t triple = static_cast(bytes[i]) << 16; + out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]); + out.push_back('='); + out.push_back('='); + } else if (rem == 2) { + const std::uint32_t triple = (static_cast(bytes[i]) << 16) | + (static_cast(bytes[i + 1]) << 8); + out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]); + out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]); + out.push_back('='); + } + return out; +} + +std::vector decodeBase64(const std::string& b64) { + std::vector out; + if (b64.size() % 4 != 0) return out; // malformed length -> empty (never throws) + out.reserve((b64.size() / 4) * 3); + for (std::size_t i = 0; i < b64.size(); i += 4) { + const char c0 = b64[i], c1 = b64[i + 1], c2 = b64[i + 2], c3 = b64[i + 3]; + const int v0 = b64Value(static_cast(c0)); + const int v1 = b64Value(static_cast(c1)); + if (v0 < 0 || v1 < 0) return {}; // illegal char in a non-pad position -> empty + // Padding is only legal in the last two positions of the last quad. + const bool pad2 = (c2 == '='); + const bool pad3 = (c3 == '='); + if ((pad2 || pad3) && i + 4 != b64.size()) return {}; // pad before the final quad + if (pad2 && !pad3) return {}; // "=X" is malformed + std::uint32_t triple = (static_cast(v0) << 18) | + (static_cast(v1) << 12); + out.push_back(static_cast((triple >> 16) & 0xFF)); + if (!pad2) { + const int v2 = b64Value(static_cast(c2)); + if (v2 < 0) return {}; + triple |= static_cast(v2) << 6; + out.push_back(static_cast((triple >> 8) & 0xFF)); + if (!pad3) { + const int v3 = b64Value(static_cast(c3)); + if (v3 < 0) return {}; + triple |= static_cast(v3); + out.push_back(static_cast(triple & 0xFF)); + } + } + } + return out; +} + +} // namespace reasampler diff --git a/src/instrument_drop.h b/src/instrument_drop.h new file mode 100644 index 0000000..3fbddbf --- /dev/null +++ b/src/instrument_drop.h @@ -0,0 +1,65 @@ +#pragma once +// instrument_drop — the PURE blob-construction core of S17 drop-and-load. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK, +// NO vendor/ includes. Standard library only (+ the pure sample_map it reuses). Unit-tested +// outside the DAW — the same "small pure builder + round-trip proof" pattern as +// assignment_request / provenance. +// +// -- What it is (the S17 seam, extension side) -------------------------------- +// +// S17 drops a bank capture onto a track's FX button, which instantiates ReaSampler 9000 on +// that track ALREADY PLAYING that capture. The SETTLED mechanism (PLAN.md §S17, mechanism +// (B) — VST3 component-state injection) is: after TrackFX_AddByName creates the instance, the +// extension writes the instance's component state directly via +// TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", ) +// with the dragged capture PRE-SELECTED. +// +// LOAD-BEARING CAVEAT (PLAN.md §S17): "vst_chunk" is the plugin's OWN base64-encoded +// serialized chunk — the exact bytes ReaSampler 9000's setState/getState round-trips — NOT a +// neutral representation REAPER re-marshals. So the extension must construct EXACTLY the +// instrument's own state-blob bytes. This module does that WITHOUT hand-rolling a parallel +// byte writer: it calls the instrument's OWN serializer, sample_map::serializeComponentState +// (the single source of truth for the byte layout — the same function the processor's +// getState calls), then base64-encodes the result. The shared-writer requirement (both +// artifacts live in this repo → reuse the exact same code) is satisfied structurally: if the +// instrument's format changes, this module changes with it because it CALLS it. +// +// The base64 encoding is what REAPER's vst_chunk write-parm documents it accepts (see +// reaper_plugin_functions.h: "vst_chunk[_program] : base64-encoded VST-specific chunk"). + +#include +#include +#include + +namespace reasampler { + +// Build the base64 blob the extension writes to TrackFX_SetNamedConfigParm(..., "vst_chunk"). +// `sampleId` is the dragged capture's stable bank id — the ONLY thing the drop pre-selects. +// The resulting ComponentState is the instrument's default face with just this one capture +// picked: {selectionId = sampleId, no zones, mono, lastConsumedAssignGeneration = 0} — exactly +// what a fresh instance would hold after the user clicked that capture in the browser. The +// keymap builds under the product defaults (Gate + Preserve) from the bank's own S2 intrinsics, +// so the sample plays MIDI-triggered immediately (the S17 "loaded, selected, playable" verify). +// +// An EMPTY sampleId yields the empty-state blob ({"", no zones}) — a drop of nothing selects +// nothing (the S10 silent empty state); the shell guards against this upstream, but the pure +// contract is defined. +// +// Deterministic: the same sampleId always yields the same blob (base64 of the same bytes). +std::string buildInstrumentDropChunk(const std::string& sampleId); + +// The raw (pre-base64) component-state bytes — exposed so the round-trip test can decode them +// back through the instrument's OWN reader (sample_map::deserializeComponentState) and assert +// the capture is selected, proving buildInstrumentDropChunk feeds the instrument exactly what +// its setState expects. Not called by the shell (which uses the base64 form). +std::vector instrumentDropStateBytes(const std::string& sampleId); + +// Standard base64 encode/decode (RFC 4648, '+' '/' alphabet, '=' padding). Exposed so the +// round-trip test can decode buildInstrumentDropChunk's output. decodeBase64 returns the +// decoded bytes; on malformed input (bad length / illegal char) it returns an EMPTY vector +// (never throws) — the test asserts a clean decode, and the shell never decodes. +std::string encodeBase64(const std::vector& bytes); +std::vector decodeBase64(const std::string& b64); + +} // namespace reasampler diff --git a/src/instrument_drop_win.cpp b/src/instrument_drop_win.cpp new file mode 100644 index 0000000..a0245c1 --- /dev/null +++ b/src/instrument_drop_win.cpp @@ -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 +#include + +#include "app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing) + +#include "reaper_plugin.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetThingFromPoint +#define REAPERAPI_WANT_TrackFX_AddByName +#define REAPERAPI_WANT_TrackFX_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 diff --git a/src/instrument_drop_win.h b/src/instrument_drop_win.h new file mode 100644 index 0000000..fba0cce --- /dev/null +++ b/src/instrument_drop_win.h @@ -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 + +// 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 diff --git a/src/main.cpp b/src/main.cpp index a8e5b8c..668eba4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -32,6 +32,7 @@ #include "bank_panel.h" #include "batch_capture.h" #include "capture.h" +#include "ingest.h" #include "insert.h" #include "persist.h" #include "provenance.h" @@ -151,6 +152,16 @@ static int g_cmdCaptureTrackRealtime = 0; // explicit action, allowed by the console policy). static int g_cmdRecaptureFromSource = 0; +// Command id for the S8 "capture selected item / time-selection into bank + assign" +// action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline +// Item-scope capture path (RunCapture) verbatim — same razor-else-time range, same +// FX-scope neutralize, same bank/persist landing — then writes an S8 assignment request +// so the active sampler instance plays the just-captured sample on its next reload. NEVER +// inserts a timeline item (the capture/placement separation holds; assign is a bank-index +// + instance-selection act). Lives in the capture family (not the ingest family) because +// it leans on main.cpp's capture render machinery, which is not exposed cross-module. +static int g_cmdCaptureItemAssign = 0; + // Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string. // Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user // who started a long capture can bail without waiting for the range end or hunting for @@ -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); diff --git a/src/persist.cpp b/src/persist.cpp index 1986840..e1f6e09 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -96,6 +96,7 @@ #include "app_version.h" #include "capture_paths.h" #include "prune_reconcile.h" +#include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects @@ -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(proj), projExtNamespace(), kProjExtVersionKey, stampVersion().c_str()); + // S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME + // seam so the counter and MarkProjectDirty stay paired. The value is whatever + // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so + // every content mutation's own save carries the fresh generation the instrument reads. The + // format is the SHARED pure encoder (vst::formatBankGeneration) so writer and reader agree + // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtBankGenKey, + vst::formatBankGeneration(bankGeneration_).c_str()); + + MarkProjectDirty(static_cast(proj)); + return true; +} + +bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { + std::string rppPath; + void* proj = readActiveProject(rppPath); + if (!proj) return false; // no active project — nothing to signal + if (rppPath.empty()) return false; // unsaved project — no .rpp to store into + + // One-shot write of the ingest assignment request under its own key (S8). Independent + // of the book/view/tail blobs — this is a transient signal to the instrument, not + // session state that must ride every save. Uses the channel-derived namespace + // (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta + // instrument reads only a beta extension's assignment requests. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtAssignKey, wire.c_str()); MarkProjectDirty(static_cast(proj)); return true; } @@ -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(proj), projExtNamespace(), + kProjExtBankGenKey) + : std::string{}); + if (!proj) { book_ = BankBook{}; return; diff --git a/src/persist.h b/src/persist.h index b4f1c10..6b67f56 100644 --- a/src/persist.h +++ b/src/persist.h @@ -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 #include #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& 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 diff --git a/src/realtime_record.cpp b/src/realtime_record.cpp index 9175005..9b287d1 100644 --- a/src/realtime_record.cpp +++ b/src/realtime_record.cpp @@ -58,6 +58,9 @@ Sample sampleFromRecordedCapture(const RecordedCapture& cap) { // finalized and on disk — the hash is over the finished file bytes. Left empty // here because sampleFromRecordedCapture runs before the file exists (the // mapping is pure / DAW-free); the shell patches it in after the move+trim. + // Phase S seam fields (rootNote / loop) left empty (D-B) — same reasoning as the + // offline path: a realtime record of wet output is not a single played note, so + // no root note is derivable; loop points are set by a later explicit action. s.createdTimestamp = cap.createdTimestamp; return s; } diff --git a/src/vst/bank_sync.cpp b/src/vst/bank_sync.cpp new file mode 100644 index 0000000..8ca894b --- /dev/null +++ b/src/vst/bank_sync.cpp @@ -0,0 +1,70 @@ +// bank_sync.cpp — see bank_sync.h. Pure; standard library only. + +#include "bank_sync.h" + +#include +#include +#include + +namespace reasampler::vst { + +std::int64_t parseBankGeneration(const std::string& raw) { + if (raw.empty()) return kBankGenerationAbsent; + + // Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises. + // A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max + // all reject to the absent default (0). Manual accumulation with an overflow guard so a + // pathologically long digit run can never wrap into a bogus small value. + std::int64_t value = 0; + constexpr std::int64_t kMax = std::numeric_limits::max(); + for (const char c : raw) { + if (c < '0' || c > '9') return kBankGenerationAbsent; // any non-digit -> reject whole + const int digit = c - '0'; + // Guard value*10 + digit against overflow before performing it. + if (value > (kMax - digit) / 10) return kBankGenerationAbsent; // would overflow -> reject + value = value * 10 + digit; + } + return value; +} + +std::string formatBankGeneration(std::int64_t generation) { + // Non-negative decimal; a negative (should never be produced by the writer) formats as + // its std::to_string form and would parse back to 0, so the writer's monotonic counter + // stays in the >= 0 domain by construction. + return std::to_string(generation); +} + +bool bankGenerationChanged(std::int64_t seen, std::int64_t current) { + return current != seen; +} + +AssignConsumeDecision consumeDecision(const std::optional& request, + std::int64_t lastConsumed, bool resolves, + bool isFocusedTarget) { + AssignConsumeDecision d; + d.consumedGeneration = lastConsumed; // default: nothing changes + + // Rule 1: no request, or not newer than what we already consumed -> nothing new. + if (!request) return d; + if (request->generation <= lastConsumed) return d; + + // Rule 2: a new request, but this instance is not the target -> do not act, do NOT + // advance the marker (stay eligible if focus later lands here). No thundering herd. + if (!isFocusedTarget) return d; + + // The request is new AND we are the target: it will be consumed-as-seen either way, so + // advance the marker to its generation so it is never re-evaluated. + d.consumedGeneration = request->generation; + + // Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker + // advanced above, but no selection change. + if (!resolves) return d; + + // Rule 4: new, target, resolvable -> apply the selection. + d.apply = true; + d.bankId = request->bankId; + d.sampleId = request->sampleId; + return d; +} + +} // namespace reasampler::vst diff --git a/src/vst/bank_sync.h b/src/vst/bank_sync.h new file mode 100644 index 0000000..acf49ad --- /dev/null +++ b/src/vst/bank_sync.h @@ -0,0 +1,105 @@ +#pragma once +// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the +// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO +// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of +// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a +// host-facing shell. +// +// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state +// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and +// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string +// read crosses the bridge in the shell; every DECISION after — parse the generation +// stamp, decide whether it differs from what we last saw, decide whether a decoded +// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here. +// +// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side +// effects (reloadFromBank, setSelectedSampleId); this module owns only the yes/no maths so +// the reader's rules are provable without a host. assignment_request.h owns the WIRE format +// (encode/decode); this module owns the CONSUME decision layered over a decoded request. + +#include +#include +#include + +#include "assignment_request.h" // AssignmentRequest (the decoded request this consumes) + +namespace reasampler::vst { + +// The S9 bank-generation "generation 0 = never stamped" default. A project saved before +// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value +// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the +// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation). +inline constexpr std::int64_t kBankGenerationAbsent = 0; + +// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a +// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative +// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp +// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never +// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing +// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored +// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT +// poll once the value is clean). +std::int64_t parseBankGeneration(const std::string& raw); + +// Format a bank-generation counter for the ext-state stamp. The inverse of +// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so +// the stamp is byte-stable across writes of the same value. +std::string formatBankGeneration(std::int64_t generation); + +// Has the bank generation changed since the reader last saw `seen`? True when `current` +// differs from `seen` — the reader then triggers a reload. Any difference counts (not just +// an increase): the writer is monotonic, but a project switch or reload can legitimately +// lower the value, and the reader should re-read the bank in that case too. `seen` starts at +// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 / +// first-bump refresh the spec requires). +bool bankGenerationChanged(std::int64_t seen, std::int64_t current); + +// The verdict of the S8 assignment-request consume decision (below). A pure value the +// processor shell acts on: apply the selection (or not) and advance the consumed marker +// (or not). Distinct booleans because the two are NOT the same event — a request may be +// consumed-as-seen (marker advances) without being applied (it named an unresolvable +// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it +// every poll. +struct AssignConsumeDecision { + bool apply = false; // set this instance's selection to (bankId, sampleId) + reload + std::string bankId; // the request's bank (valid only when apply) + std::string sampleId; // the request's sample (valid only when apply) + std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new) +}; + +// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader). +// +// `request` — the decoded assignment request (nullopt when the assign_request key +// is absent / malformed — nothing pending). +// `lastConsumed` — the generation this instance last consumed (persisted in component +// state so a re-open does not re-apply a request the user already got, +// then manually changed away from). Defaults to 0 for a fresh instance. +// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank +// sample RIGHT NOW (the shell computed this against the live bank blob). +// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's +// thundering-herd policy (e.g. only the focused-editor instance applies). +// The shell passes true when this instance should act; false suppresses +// consumption entirely so a non-target instance neither applies nor +// advances its marker (it stays eligible if it later becomes the target). +// +// RULES (all pure, order matters): +// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not +// apply, marker unchanged. (Covers the re-open case: the persisted marker == the +// request's generation, so it is not re-applied.) +// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not +// apply and do NOT advance the marker — a non-target instance must stay able to consume +// the request if focus later lands on it. (No thundering herd: only the target acts.) +// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT +// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply, +// but DO advance the marker to the request's generation so a stale/unresolvable request +// is consumed-as-seen and never re-evaluated (no error state, no selection change). +// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and +// advance the marker to the request's generation. +// +// The shell then: if apply, setSelectedSampleId + reloadFromBank; always persist +// consumedGeneration into component state when it advanced. +AssignConsumeDecision consumeDecision(const std::optional& request, + std::int64_t lastConsumed, bool resolves, + bool isFocusedTarget); + +} // namespace reasampler::vst diff --git a/src/vst/bridge_marshal.cpp b/src/vst/bridge_marshal.cpp new file mode 100644 index 0000000..23d908c --- /dev/null +++ b/src/vst/bridge_marshal.cpp @@ -0,0 +1,16 @@ +// bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types. + +#include "bridge_marshal.h" + +namespace reasampler::vst { + +std::optional decodeGetProjExtState(int apiReturn, + const std::string& buffer) { + // REAPER returns the length of the stored value; 0 means the key is absent. Guard + // both the return AND the buffer: a caller that reused a dirty buffer must not + // surface stale bytes as a value when the API reported nothing. + if (apiReturn <= 0 || buffer.empty()) return std::nullopt; + return buffer; +} + +} // namespace reasampler::vst diff --git a/src/vst/bridge_marshal.h b/src/vst/bridge_marshal.h new file mode 100644 index 0000000..2c75068 --- /dev/null +++ b/src/vst/bridge_marshal.h @@ -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 +#include + +namespace reasampler::vst { + +// Interpret a GetProjExtState result: the int return value (bytes the API reports for +// the key) and the buffer it filled. Returns the value only when the API reported a +// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer +// untouched for an absent key, and we must not treat stale buffer contents as a hit. +// +// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it +// wrote (already truncated to the C string by the caller). +std::optional decodeGetProjExtState(int apiReturn, + const std::string& buffer); + +} // namespace reasampler::vst diff --git a/src/vst/browser_scroll.cpp b/src/vst/browser_scroll.cpp new file mode 100644 index 0000000..85be0f8 --- /dev/null +++ b/src/vst/browser_scroll.cpp @@ -0,0 +1,158 @@ +// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10 +// capture_browser. No host types; only the shared Rect + BrowserLayout. + +#include "browser_scroll.h" + +#include +#include + +namespace reasampler::vst { + +namespace { +// The minimum thumb height so a very long bank still yields a grabbable thumb. +constexpr int kMinThumbHeight = 20; + +char asciiLower(char c) { + return static_cast(std::tolower(static_cast(c))); +} +} // namespace + +int scrollContentHeight(const BrowserLayout& layout, int cardCount) { + if (cardCount <= 0) return 0; + const int columns = (std::max)(1, layout.columns); + const int rows = (cardCount + columns - 1) / columns; // ceil + return rows * kBrowserCardHeight; +} + +int scrollMaxOffset(const BrowserLayout& layout, int cardCount) { + const int content = scrollContentHeight(layout, cardCount); + const int gridH = (std::max)(0, layout.grid.height()); + return (std::max)(0, content - gridH); +} + +int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset) { + const int maxOff = scrollMaxOffset(layout, cardCount); + if (proposedOffset < 0) return 0; + if (proposedOffset > maxOff) return maxOff; + return proposedOffset; +} + +VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset) { + VisibleRange vr; + if (cardCount <= 0) return vr; + const int columns = (std::max)(1, layout.columns); + const int gridH = (std::max)(0, layout.grid.height()); + if (gridH <= 0 || kBrowserCardHeight <= 0) { + vr.first = 0; + vr.last = 0; + return vr; + } + if (offset < 0) offset = 0; + // First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row + // partially scrolled off the top still draws (its lower part is visible). + const int firstRow = offset / kBrowserCardHeight; + // Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for + // the exclusive end. A row straddling the bottom edge still draws. + const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1; + int first = firstRow * columns; + int last = lastRow * columns; + if (first > cardCount) first = cardCount; + if (last > cardCount) last = cardCount; + if (last < first) last = first; + vr.first = first; + vr.last = last; + return vr; +} + +Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) { + Rect r = cardCellRect(layout, index); + if (r.right <= r.left && r.bottom <= r.top) return r; // empty (negative index) stays empty + return Rect{r.left, r.top - offset, r.right, r.bottom - offset}; +} + +Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) { + const int content = scrollContentHeight(layout, cardCount); + const int gridH = (std::max)(0, layout.grid.height()); + if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar + const int maxOff = content - gridH; + if (offset < 0) offset = 0; + if (offset > maxOff) offset = maxOff; + + const int trackRight = layout.grid.right; + const int trackLeft = trackRight - kScrollbarWidth; + const int trackTop = layout.grid.top; + + // Thumb height proportional to the visible fraction, floored at a grabbable minimum but + // never taller than the track. + int thumbH = static_cast(static_cast(gridH) * gridH / content); + thumbH = (std::max)(kMinThumbHeight, thumbH); + thumbH = (std::min)(thumbH, gridH); + + // Thumb top proportional to the offset over the movable track span. + const int trackSpan = gridH - thumbH; // >= 0 + int thumbTop = trackTop; + if (maxOff > 0 && trackSpan > 0) { + thumbTop = trackTop + static_cast( + static_cast(offset) * trackSpan / maxOff); + } + return Rect{trackLeft, thumbTop, trackRight, thumbTop + thumbH}; +} + +int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, + int dyPixels) { + const int content = scrollContentHeight(layout, cardCount); + const int gridH = (std::max)(0, layout.grid.height()); + if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset); + + // Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels. + int thumbH = static_cast(static_cast(gridH) * gridH / content); + thumbH = (std::max)(kMinThumbHeight, thumbH); + thumbH = (std::min)(thumbH, gridH); + const int trackSpan = gridH - thumbH; + if (trackSpan <= 0) return clampScrollOffset(layout, cardCount, startOffset); + + const int maxOff = content - gridH; + // A 1px thumb move covers maxOff/trackSpan content px. Round to nearest for symmetry. + const long long deltaOffset = + (static_cast(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) / + trackSpan; + const long long proposed = static_cast(startOffset) + deltaOffset; + if (proposed < 0) return 0; + if (proposed > maxOff) return maxOff; + return static_cast(proposed); +} + +Rect searchBoxRect(int w) { + if (w <= 0) return Rect{}; + return Rect{0, 0, w, kSearchBoxHeight}; +} + +bool nameMatchesQuery(const std::string& name, const std::string& query) { + if (query.empty()) return true; + if (query.size() > name.size()) return false; + // Case-insensitive substring scan (ASCII fold). Small strings; a naive scan is fine. + for (std::size_t i = 0; i + query.size() <= name.size(); ++i) { + bool match = true; + for (std::size_t j = 0; j < query.size(); ++j) { + if (asciiLower(name[i + j]) != asciiLower(query[j])) { + match = false; + break; + } + } + if (match) return true; + } + return false; +} + +std::vector filterNameIndices(const std::vector& names, + const std::string& query) { + std::vector out; + out.reserve(names.size()); + for (int i = 0; i < static_cast(names.size()); ++i) { + if (nameMatchesQuery(names[static_cast(i)], query)) + out.push_back(i); + } + return out; +} + +} // namespace reasampler::vst diff --git a/src/vst/browser_scroll.h b/src/vst/browser_scroll.h new file mode 100644 index 0000000..81aa831 --- /dev/null +++ b/src/vst/browser_scroll.h @@ -0,0 +1,107 @@ +// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10 +// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of +// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box +// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the +// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes +// into these functions. +// +// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell +// clips at the browser bottom — a bank longer than the panel runs off with no way to reach +// it (the S12 gap). This module adds the two things S12 layers over that stable geometry: +// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the +// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a +// wheel tick or a thumb drag reaches every card; and +// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards, +// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first, +// then this search narrows within it) + the search-box rect the shell draws the field in. +// +// It holds NO card data and draws nothing — it knows only the browser layout (from +// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It +// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom). + +#pragma once + +#include +#include + +#include "capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect + +namespace reasampler::vst { + +// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell +// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so +// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is +// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter. +inline constexpr int kScrollbarWidth = 10; + +// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a +// thin band spanning the browser width). Exposed so the shell reserves the band and tests +// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the +// BrowserLayout it feeds to capture_browser by kSearchBoxHeight). +inline constexpr int kSearchBoxHeight = 22; + +// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s +// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height. +// Zero cards -> 0. Pure — the content extent the scroll offset ranges over. +int scrollContentHeight(const BrowserLayout& layout, int cardCount); + +// The maximum scroll offset (px): content height minus the visible grid height, floored at 0. +// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp +// ceiling for every offset the shell tracks. +int scrollMaxOffset(const BrowserLayout& layout, int cardCount); + +// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel +// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card +// or above the first. Pure. +int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset); + +// The half-open range of card INDICES [first, last) at least partially visible in the grid at +// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every +// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row +// yields an empty range (first==last==cardCount). Pure. +struct VisibleRange { + int first = 0; // first card index drawn (inclusive) + int last = 0; // one past the last card index drawn (exclusive) +}; +VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset); + +// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell +// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with +// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card. +Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset); + +// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to +// the visible fraction (grid height / content height) and positioned proportional to the +// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell +// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very +// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against. +Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset); + +// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time +// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new +// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to +// content pixels (a 1px thumb move covers content/track px of content). A degenerate track / +// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map. +int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels); + +// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser +// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width; +// the shell adds its origin. A zero/negative width yields an empty rect. Pure. +Rect searchBoxRect(int w); + +// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query +// matches everything (the no-filter identity). Matching is ASCII case-folded (the display +// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only +// text). Pure — the single match predicate the shell's search narrow is built from. +bool nameMatchesQuery(const std::string& name, const std::string& query); + +// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving +// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank +// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this +// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list +// and applies the bank filter FIRST, then feeds the surviving display names here (search +// narrows within the bank). Pure. +std::vector filterNameIndices(const std::vector& names, + const std::string& query); + +} // namespace reasampler::vst diff --git a/src/vst/capture_browser.cpp b/src/vst/capture_browser.cpp new file mode 100644 index 0000000..a36f63e --- /dev/null +++ b/src/vst/capture_browser.cpp @@ -0,0 +1,96 @@ +// capture_browser.cpp — see capture_browser.h. Pure math; no host types. + +#include "capture_browser.h" + +#include + +namespace reasampler::vst { + +namespace { + +// The left edge of tab i in a strip of the given x-origin and width divided into `count` +// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same +// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly. +int tabEdge(int x, int width, int i, int count) { + return x + (i * width) / count; +} + +} // namespace + +BrowserLayout layoutBrowser(int w, int h) { + const int cw = std::max(0, w); + const int ch = std::max(0, h); + + BrowserLayout out; + const int tabH = std::min(kBrowserTabHeight, ch); + out.tabStrip = Rect{0, 0, cw, tabH}; + out.grid = Rect{0, tabH, cw, ch}; + + const int gridW = std::max(0, out.grid.width()); + out.columns = std::max(1, gridW / kBrowserCardWidth); + return out; +} + +Rect cardCellRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const int cols = std::max(1, layout.columns); + const int col = index % cols; + const int row = index / cols; + const int left = layout.grid.left + col * kBrowserCardWidth; + const int top = layout.grid.top + row * kBrowserCardHeight; + return Rect{left, top, left + kBrowserCardWidth, top + kBrowserCardHeight}; +} + +Rect cardContentRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const Rect cell = cardCellRect(layout, index); + return Rect{cell.left + kBrowserCardGutter, cell.top + kBrowserCardGutter, + cell.right - kBrowserCardGutter, cell.bottom - kBrowserCardGutter}; +} + +Rect cardThumbnailRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const Rect content = cardContentRect(layout, index); + const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height())); + return Rect{content.left, content.top, content.right, content.top + thumbH}; +} + +Rect cardLabelRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const Rect content = cardContentRect(layout, index); + const Rect thumb = cardThumbnailRect(layout, index); + return Rect{content.left, thumb.bottom, content.right, content.bottom}; +} + +int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) { + if (cardCount <= 0) return -1; + if (!contains(layout.grid, x, y)) return -1; + const int cols = std::max(1, layout.columns); + const int col = (x - layout.grid.left) / kBrowserCardWidth; + const int row = (y - layout.grid.top) / kBrowserCardHeight; + if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone) + const int index = row * cols + col; + if (index < 0 || index >= cardCount) return -1; + // Only a hit inside the card CONTENT counts — a click in the inter-card gutter misses. + if (!contains(cardContentRect(layout, index), x, y)) return -1; + return index; +} + +Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) { + if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{}; + const Rect& strip = layout.tabStrip; + const int left = tabEdge(strip.left, std::max(0, strip.width()), index, tabCount); + const int right = tabEdge(strip.left, std::max(0, strip.width()), index + 1, tabCount); + return Rect{left, strip.top, right, strip.bottom}; +} + +int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) { + if (tabCount <= 0) return -1; + if (!contains(layout.tabStrip, x, y)) return -1; + for (int i = 0; i < tabCount; ++i) { + if (contains(filterTabRect(layout, tabCount, i), x, y)) return i; + } + return -1; +} + +} // namespace reasampler::vst diff --git a/src/vst/capture_browser.h b/src/vst/capture_browser.h new file mode 100644 index 0000000..864f378 --- /dev/null +++ b/src/vst/capture_browser.h @@ -0,0 +1,92 @@ +// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default +// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3, +// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / +// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is +// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail + +// name + root/key badge and routes clicks into these functions. +// +// The browser replaces the old text item-list (the named anti-pattern). It lays out N +// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab +// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This +// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the +// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this +// module only "where does card i draw" / "what did the user click". +// +// Scroll is NOT here (S12 layers it over this module). The browser lays out every card +// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping +// scroll out keeps this module the stable card/tab geometry S12 builds on. +// +// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom). + +#pragma once + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a +// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list. +inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height +inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter) +inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter) +inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card +inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card + +// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to +// the area so a degenerate (tiny/zero) size never yields an inverted rect. +struct BrowserLayout { + Rect tabStrip; // top: the bank-filter tabs + Rect grid; // below the tabs: where the capture cards tile + int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width() +}; + +// Divide a (w x h) browser area into its regions and compute the column count. Pure: same +// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never +// exceeds the area); the grid takes the rest. columns = max(1, grid.width()/cardWidth) so a +// browser narrower than one card still lays out a single column. A zero/negative size +// yields empty rects + columns==1. +BrowserLayout layoutBrowser(int w, int h); + +// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then +// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect +// insets it to the drawable card. Rows past the visible grid are still computed (the shell +// clips at paint time). A negative index yields an empty rect. Pure. +Rect cardCellRect(const BrowserLayout& layout, int index); + +// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides. +// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure. +Rect cardContentRect(const BrowserLayout& layout, int index); + +// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top +// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the +// name + badge go in the remaining strip below. Pure. +Rect cardThumbnailRect(const BrowserLayout& layout, int index); + +// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band. +// The shell draws the display name + root/key badge here. Pure. +Rect cardLabelRect(const BrowserLayout& layout, int index); + +// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside +// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT +// rect counts as a hit — a click in the inter-card gutter is a miss. Pure. +int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y); + +// --- Bank-filter tabs -------------------------------------------------------- +// +// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch): +// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount == +// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell +// supplies the labels and tracks which tab is active. A tab click narrows the card list to +// that bank (the shell filters its SampleChoice list before laying out cards). + +// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal +// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with +// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an +// empty rect. Pure. +Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index); + +// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the +// tab strip. Pure. +int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y); + +} // namespace reasampler::vst diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp new file mode 100644 index 0000000..f838420 --- /dev/null +++ b/src/vst/editor_geometry.cpp @@ -0,0 +1,164 @@ +// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types. + +#include "editor_geometry.h" + +#include + +namespace reasampler::vst { + +namespace { + +// Spike editor layout constants. These are the editor's fixed metrics; the real +// editor (S4/S5) will parameterize as its content demands. +constexpr int kTitleBarHeight = 28; +constexpr int kButtonMargin = 10; +constexpr int kButtonWidth = 120; +constexpr int kButtonHeight = 24; + +} // namespace + +bool contains(const Rect& r, int x, int y) { + if (r.width() <= 0 || r.height() <= 0) return false; + return x >= r.left && x < r.right && y >= r.top && y < r.bottom; +} + +EditorLayout layoutEditor(int w, int h) { + // Clamp the surface to non-negative extents so a degenerate view can't produce + // inverted rects. + const int cw = std::max(0, w); + const int ch = std::max(0, h); + + EditorLayout out; + + // Title bar spans the top, clamped so it never exceeds the client height. + const int titleH = std::min(kTitleBarHeight, ch); + out.titleBar = Rect{0, 0, cw, titleH}; + + // Canvas is everything below the title bar. + out.canvas = Rect{0, titleH, cw, ch}; + + // Button sits at the top-left of the canvas, inset by a margin, and is clamped to + // fit inside the canvas so it never overhangs on a small view. + const int bx = out.canvas.left + kButtonMargin; + const int by = out.canvas.top + kButtonMargin; + const int bRight = std::min(bx + kButtonWidth, out.canvas.right); + const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom); + out.button = Rect{bx, by, std::max(bx, bRight), std::max(by, bBottom)}; + + return out; +} + +HitTarget hitTest(const EditorLayout& layout, int x, int y) { + if (contains(layout.button, x, y)) return HitTarget::kButton; + return HitTarget::kNone; +} + +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 diff --git a/src/vst/editor_geometry.h b/src/vst/editor_geometry.h new file mode 100644 index 0000000..f9165d7 --- /dev/null +++ b/src/vst/editor_geometry.h @@ -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 diff --git a/src/vst/embed_strip.cpp b/src/vst/embed_strip.cpp new file mode 100644 index 0000000..e1bf0d9 --- /dev/null +++ b/src/vst/embed_strip.cpp @@ -0,0 +1,86 @@ +// embed_strip.cpp — see embed_strip.h. Pure math; no host types. + +#include "embed_strip.h" + +#include + +namespace reasampler::vst { + +namespace { + +// Clamp a MIDI note to [0, kEmbedKeyCount-1]. +int clampNote(int n) { + if (n < 0) return 0; + if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1; + return n; +} + +// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given +// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right. +// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1), +// which tiles adjacent zones without a seam. +int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { + if (keyEdge <= 0) return bandLeft; + if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth; + return bandLeft + (keyEdge * bandWidth) / kEmbedKeyCount; +} + +} // namespace + +EmbedLayout layoutEmbed(int w, int h) { + const int cw = std::max(0, w); + const int ch = std::max(0, h); + + EmbedLayout out; + + // The level band takes a fixed height at the bottom, but never so much that the keymap + // above it falls below its minimum (or that the band exceeds the area). On a very short + // area the band yields to the keymap entirely. + int bandH = std::min(kEmbedLevelBandHeight, ch); + if (ch - bandH < kEmbedKeymapMinHeight) { + bandH = std::max(0, ch - kEmbedKeymapMinHeight); + } + const int keymapBottom = ch - bandH; + + out.keymap = Rect{0, 0, cw, keymapBottom}; + out.levelBand = Rect{0, keymapBottom, cw, ch}; + return out; +} + +Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) { + const Rect& band = layout.keymap; + const int bandWidth = std::max(0, band.width()); + + int lo = clampNote(lowNote); + int hi = clampNote(highNote); + if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts + + const int leftX = keyEdgeToX(band.left, bandWidth, lo); + const int rightX = keyEdgeToX(band.left, bandWidth, hi + 1); + return Rect{leftX, band.top, std::max(leftX, rightX), band.bottom}; +} + +int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, + int y) { + if (zoneCount <= 0 || zones == nullptr) return -1; + if (!contains(layout.keymap, x, y)) return -1; + // First covering zone in draw order wins (first-match, mirroring the core's resolve). + for (int i = 0; i < zoneCount; ++i) { + const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote); + if (contains(r, x, y)) return i; + } + return -1; // on the band but on an uncovered key +} + +Rect levelFillRect(const EmbedLayout& layout, double level) { + const Rect& band = layout.levelBand; + if (band.width() <= 0 || band.height() <= 0) return Rect{}; + double l = level; + if (l < 0.0) l = 0.0; + if (l > 1.0) l = 1.0; + const int fillW = static_cast(l * band.width()); + if (fillW <= 0) return Rect{}; + return Rect{band.left, band.top, band.left + fillW, band.bottom}; +} + +} // namespace reasampler::vst diff --git a/src/vst/embed_strip.h b/src/vst/embed_strip.h new file mode 100644 index 0000000..b16a88d --- /dev/null +++ b/src/vst/embed_strip.h @@ -0,0 +1,76 @@ +// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3, +// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / +// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives +// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp) +// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions. +// +// The strip is a single compact band REAPER draws inline in the track/mixer control panel +// (context TCP or MCP) via the Cockos embedded-UI surface. It shows: +// * the zone layout — each performance zone as a horizontal segment across the keyboard +// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and +// * a thin level band at the bottom — a 0..1 activity indicator the shell fills. +// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a +// click maps to the zone whose key range covers that point, or -1. +// +// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share +// one geometry idiom), so this header depends on editor_geometry.h rather than redefining +// a second rectangle type. + +#pragma once + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's +// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle. +inline constexpr int kEmbedKeyCount = 128; + +// Fixed metrics for the strip, exposed so the shell and tests agree. +inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px) +inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller + +// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal +// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM — +// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are +// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed +// zone never yields an out-of-strip rect. +struct EmbedZone { + int lowNote = 0; + int highNote = 127; +}; + +// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to +// the area so a degenerate (tiny) size never yields a region spilling outside the surface. +struct EmbedLayout { + Rect keymap; // top: the zone-segment band (the compact keymap) + Rect levelBand; // bottom: the thin level/activity indicator +}; + +// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout. +// The level band takes a fixed height at the bottom (clamped so it never exceeds the area +// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or +// negative size yields empty rects (no inversion). +EmbedLayout layoutEmbed(int w, int h); + +// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote] +// (inclusive). The 128-key span maps linearly across keymap.width(); the returned rect +// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g. +// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low +// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect, +// never an inverted one. Pure. +Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote); + +// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click +// outside the keymap band or on a key not covered by any zone. When zones overlap on a +// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match +// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure. +int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, + int y); + +// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the +// returned rect is the left sub-rectangle of levelBand whose width is level * band width +// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure. +Rect levelFillRect(const EmbedLayout& layout, double level); + +} // namespace reasampler::vst diff --git a/src/vst/keyboard_strip.cpp b/src/vst/keyboard_strip.cpp new file mode 100644 index 0000000..9e6f4c1 --- /dev/null +++ b/src/vst/keyboard_strip.cpp @@ -0,0 +1,126 @@ +// keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types. + +#include "keyboard_strip.h" + +#include + +namespace reasampler::vst { + +namespace { + +int clampNote(int n) { + if (n < 0) return 0; + if (n > kStripKeyCount - 1) return kStripKeyCount - 1; + return n; +} + +// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given +// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer +// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling +// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX). +int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { + if (keyEdge <= 0) return bandLeft; + if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth; + return bandLeft + (keyEdge * bandWidth) / kStripKeyCount; +} + +} // namespace + +StripLayout layoutStrip(int w, int h) { + const int cw = std::max(0, w); + const int ch = std::max(0, h); + StripLayout out; + out.keys = Rect{0, 0, cw, ch}; + return out; +} + +int keyLeftX(const StripLayout& layout, int note) { + const Rect& band = layout.keys; + const int bandWidth = std::max(0, band.width()); + // note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to + // get a key's right edge, and 128 maps to the band right. + const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); + return keyEdgeToX(band.left, bandWidth, edge); +} + +Rect keyRect(const StripLayout& layout, int note) { + const int n = clampNote(note); + const int leftX = keyLeftX(layout, n); + const int rightX = keyLeftX(layout, n + 1); + return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; +} + +Rect rootMarkerRect(const StripLayout& layout, int rootNote) { + return keyRect(layout, rootNote); +} + +int keyAtPoint(const StripLayout& layout, int x, int y) { + const Rect& band = layout.keys; + if (!contains(band, x, y)) return -1; + const int bandWidth = std::max(0, band.width()); + if (bandWidth <= 0) return -1; + // Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide + // the pixel offset back to a key; clamp defensively (a point on band.right-1 maps to 127). + const int offset = x - band.left; + int note = (offset * kStripKeyCount) / bandWidth; + return clampNote(note); +} + +Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) { + int lo = clampNote(lowNote); + int hi = clampNote(highNote); + if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts + const int leftX = keyLeftX(layout, lo); + const int rightX = keyLeftX(layout, hi + 1); + return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; +} + +ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) { + const Rect bar = zoneBarRect(layout, lowNote, highNote); + if (!contains(bar, x, y)) return ZoneGrab::kNone; + + const int barW = bar.width(); + // A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so + // a click exactly on the midpoint resizes low (deterministic). + if (barW < 2 * kStripEdgeGrabWidth) { + const int mid = bar.left + barW / 2; + return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge; + } + if (x < bar.left + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge; + if (x >= bar.right - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge; + return ZoneGrab::kBody; +} + +ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs, + int count, int x, int y) { + if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{}; + if (!contains(layout.keys, x, y)) return ZoneBarHit{}; + for (int i = 0; i < count; ++i) { + const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y); + if (g != ZoneGrab::kNone) return ZoneBarHit{i, g}; + } + return ZoneBarHit{}; // on the band but on no bar +} + +int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) { + if (dxPixels == 0) return clampNote(startNote); + const int bandWidth = std::max(0, layout.keys.width()); + if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion + // 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 diff --git a/src/vst/keyboard_strip.h b/src/vst/keyboard_strip.h new file mode 100644 index 0000000..9b6a846 --- /dev/null +++ b/src/vst/keyboard_strip.h @@ -0,0 +1,121 @@ +// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first +// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. +// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle + +// note-mapping arithmetic lives here so it is unit-tested outside the DAW, while the +// editor shell (reasampler_editor.cpp) draws the strip and marshals mouse events into +// these functions. +// +// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span +// idiom embed_strip uses). It serves TWO faces of the S10 editor: +// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on +// the strip, click-a-key (or drag the marker) sets the capture's root note; and +// * the opt-in ZONES panel (S10-Z, demoted): each performance zone drawn as a bar over +// the keys it covers, with edge-grab resize handles + a body move-handle so a drag +// sets low/high (edges) or moves the span (body), and a key-click sets the zone root. +// +// All interaction resolves through the pure DRAG-DELTA resolver here: the shell captures +// a grab on WM_LBUTTONDOWN, feeds each WM_MOUSEMOVE's pixel delta back through +// resolveDragNote, and commits the resolved note(s) on WM_LBUTTONUP. Live feedback is the +// shell re-drawing the in-flight note; one coherent edit lands on release. +// +// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), +// so this header depends on editor_geometry.h rather than redefining a rectangle type. + +#pragma once + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named +// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay +// independent — the editor strip may grow octave labels/metrics the embed strip never does. +inline constexpr int kStripKeyCount = 128; + +// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started +// within this many pixels of the bar's left/right edge resizes that edge; a drag started +// anywhere else on the bar moves the whole span. A zone narrower than 2*this has no body +// move-handle (both edges win their halves) — deliberate: a 1-key zone is all edges. +inline constexpr int kStripEdgeGrabWidth = 6; + +// The strip's regions, derived from the (w x h) band the shell allots it. The keys band +// takes the whole area today (a future octave-label lane can carve a sub-band here without +// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect. +struct StripLayout { + Rect keys; // the key band: the 128-key span maps linearly across keys.width() +}; + +// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or +// negative size yields empty rects (no inversion). +StripLayout layoutStrip(int w, int h); + +// The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key +// span maps linearly across keys.width(); key N occupies the half-open pixel range +// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's +// right edge (so a key's right edge is keyLeftX(note+1)). Pure. +int keyLeftX(const StripLayout& layout, int note); + +// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note), +// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range) +// note clamps to [0,127]. Pure. +Rect keyRect(const StripLayout& layout, int note); + +// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`, +// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point +// so the shell's intent (this is the root marker, not just any key) reads at the call site, +// and so a future marker shape (a triangle over the key) has one place to change. Pure. +Rect rootMarkerRect(const StripLayout& layout, int rootNote); + +// The MIDI note a point (x, y) lands on, or -1 for a point outside the keys band. Backs +// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure. +int keyAtPoint(const StripLayout& layout, int x, int y); + +// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote] +// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes +// clamp to [0,127] and low clamps to <= high, so a malformed zone yields an in-band +// (possibly zero-width) rect, never an inverted one. Mirrors embed_strip::zoneSegmentRect. +// Pure. +Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote); + +// Which part of a zone bar a grab landed on. The shell uses this to decide what a drag +// edits: an edge resizes that boundary; the body moves the whole span; none means the grab +// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a +// deselect). +enum class ZoneGrab { + kNone, // the point is not on this zone's bar + kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low + kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high + kBody, // on the bar but not an edge -> move the whole span +}; + +// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the +// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within +// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth) +// resolves the near half to each edge (no body). The LOW edge wins a tie at the exact +// midpoint of a narrow bar (deterministic). Pure. +ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y); + +// The zone (index into `lows`/`highs`, draw order) whose bar a grab at (x, y) lands on, +// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in +// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The +// arrays are parallel (lows[i]/highs[i] is zone i's inclusive range); `count` is their +// length. Pure — no host containers at the boundary (a raw pointer pair, like +// embed_strip::zoneAtPoint). +struct ZoneBarHit { + int zoneIndex = -1; + ZoneGrab grab = ZoneGrab::kNone; +}; +ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs, + int count, int x, int y); + +// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time +// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note +// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to +// [0,127]. keyWidth is derived from the layout (band width / 128); a zero-width band pins +// the result to startNote (no motion). This is the single arithmetic behind edge-resize, +// body-move (apply to both edges with the SAME delta so the span is preserved), and +// root-marker drag. Pure — rounding is to the nearest key so a half-key drag flips at the +// key centre. Returns startNote unchanged for dxPixels==0. +int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); + +} // namespace reasampler::vst diff --git a/src/vst/note_entry.cpp b/src/vst/note_entry.cpp new file mode 100644 index 0000000..99cb354 --- /dev/null +++ b/src/vst/note_entry.cpp @@ -0,0 +1,113 @@ +// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry. + +#include "note_entry.h" + +#include +#include + +namespace reasampler::vst { + +namespace { +char asciiUpper(char c) { + return static_cast(std::toupper(static_cast(c))); +} + +std::string trim(const std::string& s) { + std::size_t a = 0; + std::size_t b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +int clampNote(long long n) { + if (n < 0) return 0; + if (n > 127) return 127; + return static_cast(n); +} + +// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter. +int letterSemitone(char up) { + switch (up) { + case 'C': return 0; + case 'D': return 2; + case 'E': return 4; + case 'F': return 5; + case 'G': return 7; + case 'A': return 9; + case 'B': return 11; + default: return -1; + } +} + +// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4 +// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name. +std::optional parseNoteName(const std::string& s) { + if (s.empty()) return std::nullopt; + std::size_t i = 0; + const int base = letterSemitone(asciiUpper(s[i])); + if (base < 0) return std::nullopt; // not a letter -> not a note name + ++i; + int semitone = base; + // Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs). + while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) { + // A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is + // an accidental. '#' raises, 'b'/'B' lowers. + if (s[i] == '#') ++semitone; + else --semitone; + ++i; + } + // The octave: an optional sign then digits, running to the end. + if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous) + bool neg = false; + if (s[i] == '+' || s[i] == '-') { + neg = (s[i] == '-'); + ++i; + } + if (i >= s.size()) return std::nullopt; + int octave = 0; + bool anyDigit = false; + for (; i < s.size(); ++i) { + if (!std::isdigit(static_cast(s[i]))) return std::nullopt; + octave = octave * 10 + (s[i] - '0'); + anyDigit = true; + } + if (!anyDigit) return std::nullopt; + if (neg) octave = -octave; + // MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60). + const long long note = static_cast(octave + 1) * 12 + semitone; + return clampNote(note); +} + +std::optional parseInteger(const std::string& s) { + if (s.empty()) return std::nullopt; + std::size_t i = 0; + bool neg = false; + if (s[i] == '+' || s[i] == '-') { + neg = (s[i] == '-'); + ++i; + } + if (i >= s.size()) return std::nullopt; + long long v = 0; + for (; i < s.size(); ++i) { + if (!std::isdigit(static_cast(s[i]))) return std::nullopt; + v = v * 10 + (s[i] - '0'); + if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway + } + if (neg) v = -v; + return clampNote(v); +} +} // namespace + +std::optional parseNoteEntry(const std::string& text) { + const std::string s = trim(text); + if (s.empty()) return std::nullopt; + // Try a plain integer first (the common MIDI-number case); fall back to a note name. + if (std::isdigit(static_cast(s[0])) || s[0] == '+' || + (s[0] == '-' && s.size() > 1 && std::isdigit(static_cast(s[1])))) { + if (auto n = parseInteger(s)) return n; + } + return parseNoteName(s); +} + +} // namespace reasampler::vst diff --git a/src/vst/note_entry.h b/src/vst/note_entry.h new file mode 100644 index 0000000..de3ae0a --- /dev/null +++ b/src/vst/note_entry.h @@ -0,0 +1,33 @@ +// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's +// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The +// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit- +// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control +// or a LICE text-entry idiom) and feeds the committed string here on Enter. +// +// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't +// hit a precise note reliably. This adds a typed field: the user clicks the field, types a +// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI +// note [0,127] and commits via the same off-thread reload as every other edit. +// +// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served): +// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and +// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60 +// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel). +// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than +// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns +// nullopt (the shell keeps the old value + may flash the field). + +#pragma once + +#include +#include + +namespace reasampler::vst { + +// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal +// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is +// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps +// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types. +std::optional parseNoteEntry(const std::string& text); + +} // namespace reasampler::vst diff --git a/src/vst/param_slider.cpp b/src/vst/param_slider.cpp new file mode 100644 index 0000000..a01df57 --- /dev/null +++ b/src/vst/param_slider.cpp @@ -0,0 +1,92 @@ +// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16 +// editor parameter panel. No host types; only the shared Rect + contains(). + +#include "param_slider.h" + +#include + +namespace reasampler::vst { + +std::vector layoutControls(const Rect& panel, + const std::vector& controls) { + std::vector out; + if (controls.empty() || panel.width() <= 0 || panel.height() <= 0) return out; + out.reserve(controls.size()); + + // The label column is clamped so a narrow panel still leaves a control column. + const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width() / 2)); + int rowTop = panel.top; + for (const ControlDesc& d : controls) { + ControlRow r; + r.id = d.id; + r.kind = d.kind; + const int rowBottom = rowTop + kControlRowHeight; + r.row = Rect{panel.left, rowTop, panel.right, rowBottom}; + r.label = Rect{panel.left, rowTop, panel.left + labelW, rowBottom}; + r.control = Rect{panel.left + labelW, rowTop, panel.right, rowBottom}; + out.push_back(r); + rowTop = rowBottom + kControlRowGap; + } + return out; +} + +Rect toggleSegmentRect(const Rect& control, int seg) { + if (seg < 0 || seg >= kToggleSegments) return Rect{}; + const int w = control.width(); + if (w <= 0 || control.height() <= 0) return Rect{}; + const int segW = w / kToggleSegments; + const int left = control.left + seg * segW; + // The last segment absorbs the width remainder so the segments tile the whole control. + const int right = (seg == kToggleSegments - 1) ? control.right : left + segW; + return Rect{left, control.top, right, control.bottom}; +} + +int toggleSegmentHitTest(const Rect& control, int x, int y) { + if (!contains(control, x, y)) return -1; + for (int seg = 0; seg < kToggleSegments; ++seg) { + if (contains(toggleSegmentRect(control, seg), x, y)) return seg; + } + return -1; +} + +Rect sliderTrackRect(const Rect& control) { + // Inset a half-handle at each end so the handle stays fully inside the control at value + // 0 and 1. The handle CENTER ranges across [track.left, track.right]. + const int half = kSliderHandleWidth / 2; + if (control.width() <= kSliderHandleWidth || control.height() <= 0) return Rect{}; + return Rect{control.left + half, control.top, control.right - half, control.bottom}; +} + +Rect sliderHandleRect(const Rect& control, double value) { + const Rect track = sliderTrackRect(control); + if (track.width() <= 0) return Rect{}; + if (value < 0.0) value = 0.0; + if (value > 1.0) value = 1.0; + const int span = track.width(); // handle-center movable span + const int centerX = track.left + static_cast(value * span + 0.5); + const int half = kSliderHandleWidth / 2; + return Rect{centerX - half, control.top, centerX - half + kSliderHandleWidth, + control.bottom}; +} + +double valueAtPoint(const Rect& control, int x) { + const Rect track = sliderTrackRect(control); + const int span = track.width(); + if (span <= 0) return 0.0; + if (x <= track.left) return 0.0; + if (x >= track.right) return 1.0; + return static_cast(x - track.left) / static_cast(span); +} + +int controlAtPoint(const std::vector& rows, int x, int y) { + for (const ControlRow& r : rows) { + if (r.kind == ControlKind::Toggle) { + if (contains(r.control, x, y)) return r.id; + } else { // Slider — the interactive area is the track + if (contains(sliderTrackRect(r.control), x, y)) return r.id; + } + } + return -1; +} + +} // namespace reasampler::vst diff --git a/src/vst/param_slider.h b/src/vst/param_slider.h new file mode 100644 index 0000000..60ebe14 --- /dev/null +++ b/src/vst/param_slider.h @@ -0,0 +1,104 @@ +// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the +// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the +// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The +// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and +// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell +// draws each row (label + track/segments + handle) and routes clicks/drags into these +// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping. +// +// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface +// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR +// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade +// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope +// enable/attack/decay/depth. They are two shapes only — a two-segment TOGGLE and a +// horizontal SLIDER — laid out as a vertical stack of fixed-height rows. This module lays out +// that stack and maps a slider's NORMALIZED value (0..1) to/from its handle pixel; the shell +// converts each control's engine value (frames, seconds, a fraction, a signed semitone +// depth) to/from that 0..1 with its own domain knowledge (this module stays engine-free so it +// tests without the audio core). +// +// It reuses editor_geometry's Rect + contains() (one shared geometry idiom). + +#pragma once + +#include + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// Fixed control-panel metrics, exposed so the shell and tests agree. +inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap) +inline constexpr int kControlRowGap = 4; // vertical gap below each row +inline constexpr int kControlLabelWidth = 92; // the label column at the row's left +inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px) +inline constexpr int kToggleSegments = 2; // a toggle is always two segments + +// A control is one of two shapes. Toggle = a two-segment selector (the active segment +// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value. +enum class ControlKind { Toggle, Slider }; + +// One control the shell places in the panel, in stack order. `id` is the shell's own control +// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the +// shell routes the interaction to the right engine param — this module never interprets it. +struct ControlDesc { + int id = 0; + ControlKind kind = ControlKind::Slider; +}; + +// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect +// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle +// into segments via toggleSegmentRect). `index` is the control's position in the stack. +struct ControlRow { + int id = 0; + ControlKind kind = ControlKind::Slider; + Rect row; // the full row (label column + control column) + Rect label; // the label column at the left + Rect control; // the control column to the right of the label (track / toggle area) +}; + +// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each +// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left +// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A +// row whose top falls past the panel bottom is still returned (the shell clips at paint / +// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty +// control list or a degenerate panel yields an empty vector. Pure. +std::vector layoutControls(const Rect& panel, + const std::vector& controls); + +// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect, +// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width +// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate +// control rect yields an empty rect. Pure. +Rect toggleSegmentRect(const Rect& control, int seg); + +// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a +// miss (outside the control area). Pure. +int toggleSegmentHitTest(const Rect& control, int x, int y); + +// The slider track sub-rect inside a slider control's `control` rect: the control inset so the +// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle +// margin at each end). The handle CENTER ranges across [track.left, track.right] as the value +// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an +// empty rect. Pure. +Rect sliderTrackRect(const Rect& control); + +// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a +// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A +// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint. +Rect sliderHandleRect(const Rect& control, double value); + +// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range). +// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate +// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the +// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones). +double valueAtPoint(const Rect& control, int x); + +// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc +// id) whose interactive area (a Slider's track, a Toggle's whole control area) contains the +// point, or -1 for a miss (a gap, the label column, or outside every row). The FIRST matching +// row wins (rows never overlap, so at most one matches). Pure — the shell's routing entry +// point: on a hit it reads the value (valueAtPoint / toggleSegmentHitTest) and commits. +int controlAtPoint(const std::vector& rows, int x, int y); + +} // namespace reasampler::vst diff --git a/src/vst/pitch_shift.cpp b/src/vst/pitch_shift.cpp new file mode 100644 index 0000000..d957543 --- /dev/null +++ b/src/vst/pitch_shift.cpp @@ -0,0 +1,126 @@ +// pitch_shift — pure implementation. See pitch_shift.h for the contract and the S16-F2 +// route-(b) rationale (WDL drags , so the Preserve DSP is house-native here). +// NO VST3 / REAPER / SWELL / vendor includes; standard library only. +// +// Algorithm: a single delay ring of `window_` frames. The write head advances one frame per +// input sample (source rate → duration preserved). TWO read taps chase the write head, offset +// by half a window; each advances by the shift `ratio_` per frame. A tap that would cross the +// write head wraps by a full window (so it stays a bounded delay behind the writer). The two +// taps are crossfaded by an equal-power window keyed to each tap's distance from the write +// head, so the wrap discontinuity of one tap is masked by the other mid-window — the classic +// two-grain time-domain pitch shifter, no FFT. + +#include "pitch_shift.h" + +#include +#include + +namespace reasampler { + +namespace { + +// A Hann OLA window over a grain phase in [0,1): 0.5(1 - cos(2*pi*phase)). Zero at the grain +// ends (where a tap wraps — the discontinuity), unity mid-grain. Two grains offset by half a +// window PARTITION UNITY (w(p) + w(p+0.5) == 1 for all p), so the two crossfaded taps sum to a +// gain of exactly 1 everywhere — no amplitude ripple across the window, and each tap's wrap +// seam is masked because its window is 0 exactly there. +double hannWeight(double phase) { + while (phase < 0.0) phase += 1.0; + while (phase >= 1.0) phase -= 1.0; + return 0.5 * (1.0 - std::cos(2.0 * 3.14159265358979323846 * phase)); +} + +} // namespace + +void PitchShifter::configure(std::int64_t windowFrames) { + window_ = windowFrames; + if (window_ <= 1) { + // Pass-through: no ring, process() returns input unchanged. + ring_.clear(); + writePos_ = 0; + readPos_ = 0.0; + ratio_ = 1.0; + return; + } + ring_.assign(static_cast(window_), 0.0f); + reset(); +} + +void PitchShifter::reset() { + if (window_ > 1) { + // Zero the ring and seed the read head a half-window behind the writer so the two taps + // (readPos_ and readPos_ + window/2) straddle the writer from the first frame. + std::fill(ring_.begin(), ring_.end(), 0.0f); + writePos_ = 0; + readPos_ = static_cast(window_) / 2.0; + } else { + writePos_ = 0; + readPos_ = 0.0; + } + ratio_ = 1.0; +} + +void PitchShifter::warm() { + if (window_ <= 1) return; // pass-through needs no warm-up + // Push one full window of silence so the taps reach steady state before real audio. + for (std::int64_t i = 0; i < window_; ++i) process(0.0f); +} + +void PitchShifter::setShiftRatio(double ratio) { + if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run taps backward/stall) +} + +AudioSample PitchShifter::process(AudioSample in) { + if (window_ <= 1) return in; // pass-through (unconfigured / degenerate) + + // 1. Write the incoming sample at the write head (source rate). + ring_[static_cast(writePos_)] = in; + + const double w = static_cast(window_); + const double half = w / 2.0; + + // 2. Read the two taps, each a bounded delay behind the writer. tap0 is `readPos_`; tap1 is + // a half-window ahead of it (mod window). Distance-from-writer drives the crossfade so a + // tap near the writer (about to wrap) is faded out while its partner (mid-window) is up. + auto readTap = [&](double pos) -> double { + // Fractional linear interpolation with ring wrap. + double p = pos; + while (p < 0.0) p += w; + while (p >= w) p -= w; + const std::int64_t i0 = static_cast(p); + const double frac = p - static_cast(i0); + std::int64_t i1 = i0 + 1; + if (i1 >= window_) i1 = 0; + const double s0 = static_cast(ring_[static_cast(i0)]); + const double s1 = static_cast(ring_[static_cast(i1)]); + return s0 + (s1 - s0) * frac; + }; + + const double tap0 = readTap(readPos_); + const double tap1 = readTap(readPos_ + half); + + // Distance of tap0 behind the write head, in [0, window). Its crossfade phase is that + // distance over the window; tap1 (half a window offset) gets the complementary phase. + double dist0 = static_cast(writePos_) - readPos_; + while (dist0 < 0.0) dist0 += w; + while (dist0 >= w) dist0 -= w; + const double phase0 = dist0 / w; + + // Hann windows offset by half a grain partition unity, so the two taps sum to gain 1 with + // each tap's wrap seam masked by its window zero. phase0 drives tap0; tap1 (half-window + // offset) is at phase0 + 0.5. + const double g0 = hannWeight(phase0); + const double g1 = hannWeight(phase0 + 0.5); + const double out = tap0 * g0 + tap1 * g1; + + // 3. Advance heads: write head one frame (source rate), read head by the shift ratio. + ++writePos_; + if (writePos_ >= window_) writePos_ = 0; + readPos_ += ratio_; + while (readPos_ >= w) readPos_ -= w; + while (readPos_ < 0.0) readPos_ += w; + + return static_cast(out); +} + +} // namespace reasampler diff --git a/src/vst/pitch_shift.h b/src/vst/pitch_shift.h new file mode 100644 index 0000000..d96ed9c --- /dev/null +++ b/src/vst/pitch_shift.h @@ -0,0 +1,88 @@ +#pragma once +// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve" +// engine's DSP core. Time-domain overlap-add (OLA) with two half-window-offset read taps +// crossfaded to hide the ring-wrap seam. Source is consumed 1:1 and output produced 1:1 +// (duration held); only the PITCH changes — an octave up plays the same wall-clock length +// as the root note, unlike the Varispeed `readPos_ += ratio_` resample path. +// +// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was +// route (a) `WDL_SimplePitchShifter`. But its include chain +// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 -> +// #include ` unconditionally, which CANNOT enter the pure sampler_core module +// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither +// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native +// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same +// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at +// the SHELL, never in the pure core. +// +// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only. +// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core / +// wav_trim do the same). +// +// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio +// thread, at voice allocation). `warm()` pre-fills the ring with silence so steady-state +// latency is reached before the first real sample (no cold-start click). `process()` does +// NO allocation and NO locks — it reads/writes the pre-sized ring only. All state is plain +// value fields, so a voice owning one by value costs a fixed ring buffer per channel. + +#include +#include +#include + +#include "peaks.h" // AudioSample (float) + +namespace reasampler { + +// A per-channel time-domain OLA pitch shifter. One instance transposes ONE channel; a stereo +// voice owns two (or a stereo-aware wrapper) — the algorithm is per-sample and channel-count +// agnostic, matching the S7 "one read head, per-channel value" idiom of the core. +// +// The default-constructed shifter is INERT: with no configure() it passes input through +// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is +// byte-identical to the pre-S16 engine. +class PitchShifter { +public: + // Size the delay ring for `windowFrames` (the OLA grain length) and prepare the two + // read taps a half-window apart. `windowFrames` <= 1 degrades to pass-through (no ring), + // so a degenerate configure never divides by zero or wraps a zero span. Called OFF the + // audio thread (allocates). Resets all running state. A larger window = smoother on large + // transpositions but more latency; the shell picks it from the Preserve quality setting. + void configure(std::int64_t windowFrames); + + // Pre-fill the ring with silence (one full window of zero writes) so the read taps reach + // steady state before the first real sample. Removes the cold-start seam (the S16 "onset + // click absent" requirement) — call once at voice allocation after configure(). No-op when + // unconfigured (pass-through needs no warm-up). + void warm(); + + // The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias. + // 1.0 = no shift (pass-through-equivalent output). Set per frame is fine (cheap); the tap + // advance simply uses the current value. Values <= 0 are ignored (kept at the last valid + // ratio) so a bad input never runs the taps backward or stalls them. + void setShiftRatio(double ratio); + + // Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out). + // RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured + // (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write + // head, reads the two half-window-offset taps advancing at the shift ratio, crossfades + // them by the write-head-relative distance (equal-power), and advances both heads by one. + AudioSample process(AudioSample in); + + // Reset running state to a freshly-warmed-equivalent silence (ring zeroed, heads re-seeded) + // WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window. + void reset(); + + // True once configure() sized a real ring (window > 1). A pass-through shifter is false. + bool configured() const { return window_ > 1; } + + std::int64_t window() const { return window_; } + +private: + std::vector ring_; // delay line, length `window_` (channel-local) + std::int64_t window_ = 0; // OLA grain length in frames; <= 1 = pass-through + std::int64_t writePos_ = 0; // integer write head into the ring (source rate) + double readPos_ = 0.0; // fractional read head (advances at shift ratio) + double ratio_ = 1.0; // current shift ratio (>0) +}; + +} // namespace reasampler diff --git a/src/vst/reaper_bridge.cpp b/src/vst/reaper_bridge.cpp new file mode 100644 index 0000000..5df2429 --- /dev/null +++ b/src/vst/reaper_bridge.cpp @@ -0,0 +1,112 @@ +// reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin. + +#include "reaper_bridge.h" + +#include + +#include "bridge_marshal.h" +#include "capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation) +#include "ext_keys.h" // kProjExtNamespace (shared wire contract) + +// The VST3 base types must be included before REAPER's VST3 interface header, which +// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from +// 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 reaper(context); + if (!reaper) return false; + hostApp_ = reaper.get(); + + // Resolve the ext-state functions by name. getReaperApi returns the same function + // pointers the extension resolves via rec->GetFunc; a null return means the symbol + // is unavailable (very old REAPER) — degrade gracefully. + getProjExtState_ = reinterpret_cast( + reaper->getReaperApi("GetProjExtState")); + enumProjExtState_ = reinterpret_cast( + reaper->getReaperApi("EnumProjExtState")); + // EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call + // persist.cpp uses, so the instrument derives the project directory identically. + enumProjects_ = reinterpret_cast( + reaper->getReaperApi("EnumProjects")); + + return getProjExtState_ != nullptr; +} + +std::optional ReaperBridge::readReasamplerExtState(const std::string& key) { + if (!getProjExtState_ || !hostApp_) return std::nullopt; + + // Fetch the host project (getReaperParent(3) — project). Reads that live "reasampler" + // ext-state against the ACTIVE project the instrument was instantiated in, so it + // follows project switches for free (D6). + auto* reaper = static_cast(hostApp_); + void* proj = reaper->getReaperParent(3); + // A null project is legitimate (e.g. instantiated before a project context exists); + // REAPER treats null as the current project for these calls, so we pass it through + // rather than bailing — but if the read yields nothing the caller sees nullopt. + + // GetProjExtState writes into a caller buffer; the bank blob can be large (many + // samples), so grow the buffer until the value fits rather than risk a silent + // truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The + // return value is the value length; if it fits strictly inside the buffer it is + // complete, else grow and retry up to a 16 MB ceiling. + for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { + std::vector buf(static_cast(cap), '\0'); + const int rv = getProjExtState_(proj, kProjExtNamespace(), key.c_str(), + buf.data(), cap); + if (rv <= 0) return std::nullopt; // absent / empty key + std::string s(buf.data()); + if (static_cast(s.size()) + 1 < cap) { + return decodeGetProjExtState(rv, s); + } + // else: possibly truncated -> grow and retry. + } + return std::nullopt; // pathologically large (>16 MB) — give up rather than loop +} + +std::string ReaperBridge::activeProjectDir() { + if (!enumProjects_) return {}; + // idx=-1 is the current project tab; the out-buffer receives the full .rpp path, + // EMPTY for a never-saved project. Same call + convention as persist.cpp; the pure + // projectDirOfRpp turns the .rpp path into the project directory (parent, forward- + // slashed) and keeps an unsaved project's empty path empty (no default-location + // fallback — the tool's invariant). + std::vector buf(4096, '\0'); + enumProjects_(-1, buf.data(), static_cast(buf.size())); + return projectDirOfRpp(std::string(buf.data())); +} + +} // namespace reasampler::vst diff --git a/src/vst/reaper_bridge.h b/src/vst/reaper_bridge.h new file mode 100644 index 0000000..8c5dd80 --- /dev/null +++ b/src/vst/reaper_bridge.h @@ -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 +#include + +#include "pluginterfaces/base/funknown.h" + +namespace reasampler::vst { + +// Wraps the REAPER host bridge for a single plugin instance. Constructed cheaply; +// connect() must be called with the initialize() context before any read. All reads +// degrade to nullopt (never crash) when the host is not REAPER or a symbol is absent — +// the instrument must load in non-REAPER hosts too, just without live state. +class ReaperBridge { +public: + ReaperBridge() = default; + + // Bind to the host. `context` is the FUnknown* REAPER hands IComponent::initialize. + // Returns true when the REAPER bridge is available (host is REAPER and the ext-state + // API resolved). Safe to call with a null or non-REAPER context — returns false. + bool connect(Steinberg::FUnknown* context); + + // True once connect() found the REAPER host application AND resolved the ext-state + // functions. + bool isConnected() const { return getProjExtState_ != nullptr; } + + // Read a "reasampler" ext-state value by key from the host's active project. + // Returns nullopt when unconnected, when the project can't be resolved, or when the + // key is absent. This is the S1 read-spike entry point. + // + // NOT REAL-TIME SAFE (it allocates a read buffer and calls into REAPER): callers on + // the audio thread MUST NOT invoke it. The S4 instrument reads on the main/UI thread + // and hands a snapshot to the process path (see reasampler_processor.cpp). + std::optional readReasamplerExtState(const std::string& key); + + // The active project's directory (the folder holding its .rpp), forward-slashed, + // no trailing slash — the M4 convention persist uses to place the bank alongside + // the .rpp. Empty for an unsaved project or when unconnected. The instrument + // resolves relative sample paths against this the SAME way persist does + // (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe. + std::string activeProjectDir(); + +private: + // Resolved REAPER API function pointers (by name via getReaperApi). Signatures + // verified against reaper_plugin_functions.h. + 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 diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp new file mode 100644 index 0000000..34ff11a --- /dev/null +++ b/src/vst/reasampler_editor.cpp @@ -0,0 +1,1692 @@ +// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the +// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is +// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults. + +#include "reasampler_editor.h" + +#include +#include +#include +#include +#include + +#include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry +#include "capture_browser.h" +#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "editor_geometry.h" // Rect, contains +#include "ext_keys.h" +#include "keyboard_strip.h" +#include "note_entry.h" // S12 direct numeric note-entry parse +#include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping +#include "peaks.h" // computeEnvelope +#include "reaper_bridge.h" +#include "reasampler_processor.h" +#include "app_version.h" // vstPluginName (channel-derived editor title band, S18) +#include "sample_map.h" +#include "wav_trim.h" // parseWavLayout, extractFloatFrames +#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) + +#ifdef _WIN32 +#include // GET_X_LPARAM / GET_Y_LPARAM +#include // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept + +#include "wdltypes.h" +#include "lice/lice.h" +#endif + +using namespace Steinberg; + +namespace reasampler::vst { + +namespace { +#ifdef _WIN32 +constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; + +// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread +// timer: responsive enough that a recapture/ingest/assign refreshes "within a bounded cadence" +// (the S9 verify criterion) yet cheap — three small ext-state reads per tick, coalescing many +// bumps between ticks into one reload. 500 ms is a deliberate build-time residual: fast enough +// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero). +constexpr UINT_PTR kSyncTimerId = 1; +constexpr UINT kSyncTimerIntervalMs = 500; + +// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry +// is in capture_browser / keyboard_strip). The title band names the plugin + a live +// readout; the toggle band carries the Browser/Zones switch; the setup band (single- +// capture face) hosts the keyboard strip + level readout under the browser. +constexpr int kTitleHeight = 24; +constexpr int kToggleHeight = 22; +constexpr int kSetupHeight = 176; // the single-capture setup surface (labels + waveform + strip) +constexpr int kStripBandHeight = 40; +constexpr int kWaveformHeight = 72; // the S11 waveform band inside the setup surface + +// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as +// the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.) +const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); +const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255); +const LICE_pixel kColCardBg = LICE_RGBA(44, 44, 48, 255); +const LICE_pixel kColCardSelBg = LICE_RGBA(48, 72, 64, 255); +const LICE_pixel kColCardBorder = LICE_RGBA(70, 70, 76, 255); +const LICE_pixel kColCardSelBorder = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColTabBg = LICE_RGBA(36, 36, 40, 255); +const LICE_pixel kColTabActiveBg = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255); +const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255); +const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColWaveBg = LICE_RGBA(24, 24, 26, 255); +const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColStartMarker = LICE_RGBA(230, 200, 120, 255); // start point (amber) +const LICE_pixel kColLoopMarker = LICE_RGBA(120, 170, 230, 255); // loop start/end (blue) +const LICE_pixel kColLoopRegion = LICE_RGBA(120, 170, 230, 60); // loop span fill (faint) +const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255); +const COLORREF kRgbText = RGB(210, 230, 220); +const COLORREF kRgbDim = RGB(140, 150, 146); + +// ANSI path -- string literals must stay ASCII until the Phase L type kit lands. +void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col, + UINT fmt = DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX) { + HDC dc = bmp->getDC(); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, col); + RECT gr{r.left, r.top, r.right, r.bottom}; + DrawTextA(dc, s, -1, &gr, fmt | DT_END_ELLIPSIS); +} + +void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { + drawText(bmp, r, s, col, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); +} + +// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the +// common DAW convention REAPER uses). +std::string noteLabel(int note) { + static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"}; + if (note < 0) note = 0; + if (note > 127) note = 127; + const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4 + return std::string(kNames[note % 12]) + std::to_string(octave); +} + +// Draw a mono peak envelope centered vertically in `r` (mirror of bank_panel::drawThumbnail, +// single channel). Each bin is a vertical line from its min to its max about the midline. +void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) { + if (r.width() <= 0 || r.height() <= 0 || env.empty() || env[0].empty()) return; + const ChannelEnvelope& ch = env[0]; + const int mid = r.top + r.height() / 2; + const int halfH = r.height() / 2; + const int bins = static_cast(ch.size()); + for (int x = 0; x < r.width() && x < bins; ++x) { + const MinMax& mm = ch[static_cast(x)]; + const int yTop = mid - static_cast(mm.max * halfH); + const int yBot = mid - static_cast(mm.min * halfH); + LICE_Line(bmp, r.left + x, yTop, r.left + x, yBot, kColThumb, 1.0f, 0, false); + } +} + +// A display name for a bank sample id from the snapshotted list ("?" if the id no longer +// resolves — e.g. a zone naming a deleted sample). +std::string sampleLabel(const std::vector& samples, const std::string& id) { + for (const SampleChoice& c : samples) { + if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; + } + return "?"; +} + +// The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin +// per horizontal pixel. +int thumbBins(const BrowserLayout& layout) { + return (std::max)(1, cardThumbnailRect(layout, 0).width()); +} +#endif +} // namespace + +ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) + : CPluginView(nullptr), processor_(processor) { + // Default view size — sized to show a couple of card rows + the setup strip. + ViewRect r(0, 0, 560, 400); + setRect(r); +} + +void ReaSamplerEditor::refreshFromBank() { + // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). + thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks + pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source) + if (!processor_) { + samples_.clear(); + banks_.clear(); + visible_.clear(); + selectedId_.clear(); + map_.zones.clear(); + selectedZone_ = -1; + return; + } + auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banksJson ? listSamples(*banksJson) : std::vector{}; + banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; + selectedId_ = processor_->selectedSampleId(); + map_ = processor_->performanceMap(); + channelMode_ = processor_->channelMode(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; + // Drop a filter that names a bank no longer present. + if (!activeFilterBankId_.empty()) { + bool found = false; + for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; + if (!found) activeFilterBankId_.clear(); + } + rebuildVisible(); +} + +void ReaSamplerEditor::rebuildVisible() { + // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search + // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). + visible_.clear(); + for (const SampleChoice& s : samples_) { + const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; + if (!inBank) continue; + const std::string& name = s.displayName.empty() ? s.id : s.displayName; + if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); + } + // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel + // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it + // must not reset the user's scroll here. +} + +#ifdef _WIN32 +// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared +// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes +// Windows the only build target, but the TU must still compile elsewhere). +void ReaSamplerEditor::onSyncTimer() { + // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the + // processor (off the audio thread — the poll itself never touches process()). NEVER while a + // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the + // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. + if (!processor_) return; + if (drag_ != DragKind::kNone) return; // defer past the in-flight edit + + // An open editor marks THIS instance the focused assignment target (the thundering-herd + // policy — only an editor-open instance applies a pending assign; see the handoff). Pass + // true so this instance consumes the request; instances with no editor open do not poll at + // all (the timer is bound to the child window), so they never contend for the request. + const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); + + // Re-snapshot the editor's own view only when something changed (a reload from a bank + // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the + // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM + // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. + if (r.reloaded || r.applied) { + refreshFromBank(); + invalidate(); + } + + // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. + if (dropHintTicks_ > 0) { + --dropHintTicks_; + invalidate(); + } +} +#endif // _WIN32 + +void ReaSamplerEditor::commitAndReload() { + // UI thread only. Publish the edited selection + zones to the processor, then rebuild + // the instrument off the audio thread (reloadFromBank bakes them into the live Keymap). + if (!processor_) return; + processor_->setSelectedSampleId(selectedId_); + processor_->setPerformanceMap(map_); + processor_->reloadFromBank(); +#ifdef _WIN32 + invalidate(); +#endif +} + +ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { + SetupMarkers m; + // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override + // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic + // from the live bank blob (the same path selectSample uses); the override lives in map_. + if (processor_) { + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, selectedId_)) { + if (sel->loop.hasLoop) { + m.hasLoop = true; + m.loopStart = sel->loop.start; + m.loopEnd = sel->loop.end; + } + } + } + } + // The override (loop + start) on a zone for the picked id supersedes the intrinsic. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId != selectedId_) continue; + if (z.loopOverride) { + m.hasLoop = z.loopOverride->hasLoop; + m.loopStart = z.loopOverride->start; + m.loopEnd = z.loopOverride->end; + } + if (z.startPoint) m.start = *z.startPoint; + break; + } + // Default an unset loop's end to the sample length so the loop markers have somewhere sane + // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; + // the markers are still drawn (drag one to CREATE a loop). + if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; + return m; +} + +int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { + // Find-or-append the zone for selectedId_ and write the loop/start override fields. + // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must + // be non-empty; callers are responsible for that guard. + // Returns the zone index (0-based) so callers can update selectedZone_. + SampleLoop loop; + loop.hasLoop = m.hasLoop; + loop.start = m.loopStart; + loop.end = m.loopEnd; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == selectedId_) { + z.loopOverride = loop; + z.startPoint = m.start; + return i; + } + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.loopOverride = loop; + z.startPoint = m.start; + map_.zones.push_back(z); + return static_cast(map_.zones.size()) - 1; +} + +namespace { +// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is +// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span +// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build +// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) +// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a +// wall-clock second). Build-time residual — one place to retune; not persisted. +constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) +constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames) +constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered + +double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } +} // namespace + +std::vector ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const { + std::vector out; + // Always: the two mode toggles. + out.push_back({static_cast(ParamControl::kPlayMode), ControlKind::Toggle}); + out.push_back({static_cast(ParamControl::kPitchEngine), ControlKind::Toggle}); + // Mode-relevant amplitude sliders. + if (play.playMode == PlayMode::Gate) { + out.push_back({static_cast(ParamControl::kAttack), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kHold), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kDecay), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kSustain), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kRelease), ControlKind::Slider}); + } else { // Trigger + out.push_back({static_cast(ParamControl::kTrigLength), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kTrigFadeIn), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kTrigFadeOut), ControlKind::Slider}); + } + // The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on). + out.push_back({static_cast(ParamControl::kPitchEnvEnable), ControlKind::Toggle}); + out.push_back({static_cast(ParamControl::kPitchEnvAttack), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kPitchEnvDecay), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kPitchEnvDepth), ControlKind::Slider}); + return out; +} + +double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { + // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over + // the frames ceiling. Two domains, kept explicit so neither leaks a rate. + const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; + const auto framesToNorm = [](std::int64_t f) { + return clamp01(static_cast(f) / kFadeMaxFrames); + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; + case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; + case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); + case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); + case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds); + case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); + case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds); + case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction); + case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); + case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); + case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; + case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds); + case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds); + case ParamControl::kPitchEnvDepth: + // Signed depth centered at 0.5 (0.5 == 0 semitones). + return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); + default: return 0.0; + } +} + +void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, + int segment) const { + const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; + const auto normToFrames = [](double v) { + return static_cast(clamp01(v) * kFadeMaxFrames + 0.5); + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: + play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; + break; + case ParamControl::kPitchEngine: + play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; + break; + case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break; + case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break; + case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break; + case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; + case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break; + case ParamControl::kTrigLength: + // lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing. + play.trigger.lengthFraction = (std::max)(0.01, clamp01(value)); + break; + case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break; + case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break; + case ParamControl::kPitchEnvEnable: + play.pitchEnv.enabled = (segment == 1); + break; + case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break; + case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break; + case ParamControl::kPitchEnvDepth: + play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; + break; + default: break; + } +} + +void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { + // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, + // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays + // identically to the un-zoned single capture (one chromatic zone) and round-trips through + // the component state; the zone becomes visible if the user opens the Zones panel. The bank + // intrinsic is NEVER written (read-only bank consumer, D-B). + if (selectedId_.empty()) return; + upsertPickedOverride(m); + commitAndReload(); +} + +const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { + auto it = pcmCache_.find(sampleId); + if (it != pcmCache_.end()) return it->second; + + // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so + // resolve the path from the live bank blob (selectSample) and decode via the shared WAV + // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY + // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width- + // independent) — the thumbnail bins this at whatever width, the snap scans it directly. + std::string relativePath; + std::vector mono; + if (processor_) { + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; + } + if (!relativePath.empty()) { + const std::string projectDir = processor_->bridge().activeProjectDir(); + const std::string abs = resolveBankFile(projectDir, relativePath); + std::vector bytes; + std::ifstream f(abs, std::ios::binary | std::ios::ate); + if (f) { + const std::streamoff size = f.tellg(); + if (size > 0) { + f.seekg(0, std::ios::beg); + bytes.resize(static_cast(size)); + if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); + } + } + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + mono = downmixToMono(interleaved, layout.channelCount); + } + } + } + auto ins = pcmCache_.emplace(sampleId, std::move(mono)); + return ins.first->second; +} + +const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { + const std::string key = sampleId + "|" + std::to_string(binCount); + auto it = thumbCache_.find(key); + if (it != thumbCache_.end()) return it->second; + + // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by + // every thumbnail width AND the S11 waveform surface + snap. + const std::vector& mono = monoPcmFor(sampleId); + Envelope env; + if (!mono.empty()) { + env = computeEnvelope(mono, 1, mono.size(), + static_cast((std::max)(1, binCount))); + } + auto ins = thumbCache_.emplace(key, std::move(env)); + return ins.first->second; +} + +ReaSamplerEditor::~ReaSamplerEditor() { +#ifdef _WIN32 + if (childHwnd_) { + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +#endif +} + +tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) { +#ifdef _WIN32 + if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue; +#endif + return kResultFalse; +} + +tresult PLUGIN_API ReaSamplerEditor::canResize() { + return kResultTrue; +} + +#ifdef _WIN32 + +void ReaSamplerEditor::invalidate() { + if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); +} + +void ReaSamplerEditor::attachedToParent() { + HWND parent = static_cast(systemWindow); + if (!parent) return; + + HINSTANCE hInst = + reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); + if (!hInst) hInst = GetModuleHandle(nullptr); + + static bool classRegistered = false; + if (!classRegistered) { + WNDCLASSW wc{}; + wc.lpfnWndProc = &ReaSamplerEditor::wndProc; + wc.hInstance = hInst; + wc.lpszClassName = kChildClassName; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.style = CS_HREDRAW | CS_VREDRAW; + RegisterClassW(&wc); + classRegistered = true; + } + + refreshFromBank(); + + const ViewRect& r = getRect(); + childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, + r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); + if (childHwnd_) { + SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); + // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT + // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show + // the "drop on the panel" affordance instead of the OS bouncing the drop silently. + DragAcceptFiles(childHwnd_, TRUE); + // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's + // lifetime — created here, killed in removedFromParent — so an instance whose editor + // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). + SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr); + // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor + // was closed) or a bank change applies the instant the editor opens, rather than waiting + // up to one timer interval. refreshFromBank above already primed the view; this folds in + // any pending assign/generation so the just-opened editor shows the assigned capture. + onSyncTimer(); + } +} + +void ReaSamplerEditor::removedFromParent() { + if (childHwnd_) { + KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +} + +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + tresult res = CPluginView::onSize(newSize); + if (childHwnd_ && newSize) { + MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); + thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them + } + return res; +} + +// The client bands: title (top), toggle (below title), then the mode content. In the +// browser view the content is the browser grid on top of the single-capture setup band +// (when a capture is picked); in the zones view the content is the zones strip + list. +namespace { +struct EditorBands { + Rect title; + Rect toggleBrowser; // left half of the toggle band + Rect toggleZones; // right half + Rect content; // below the toggle band: the mode's own area +}; +EditorBands computeBands(int w, int h) { + EditorBands b; + const int titleH = (std::min)(kTitleHeight, h); + b.title = Rect{0, 0, w, titleH}; + const int toggleTop = titleH; + const int toggleBot = (std::min)(h, toggleTop + kToggleHeight); + b.toggleBrowser = Rect{0, toggleTop, w / 2, toggleBot}; + b.toggleZones = Rect{w / 2, toggleTop, w, toggleBot}; + b.content = Rect{0, toggleBot, w, h}; + return b; +} + +// The keyboard strip rectangle inside the setup area (single-capture root-drag face). +// `area` is the full setup Rect; the strip is anchored at the bottom with an 8px horizontal +// pad. All three call sites (paintSetup, onMouseDown, onMouseMove) use this single formula. +Rect setupStripArea(const Rect& area) { + constexpr int pad = 8; + const int stripTop = area.bottom - kStripBandHeight; + return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; +} + +// The S11 waveform rectangle inside the setup area: a band above the keyboard strip, below the +// header/hint labels. `area` is the full setup Rect; the waveform is padded 8px horizontally and +// anchored above the strip band. All call sites (paintSetup, onMouseDown, onMouseMove) use this +// single formula so the draw and the hit-test never drift. +Rect setupWaveformArea(const Rect& area) { + constexpr int pad = 8; + const int waveBottom = area.bottom - kStripBandHeight - 6; // 6px gap above the strip + const int waveTop = waveBottom - kWaveformHeight; + return Rect{area.left + pad, waveTop, area.right - pad, waveBottom}; +} + +// The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the +// mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20) +// with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown, +// onMouseMove) use this single formula — the inline arithmetic in onMouseMove was the drift. +Rect zonesStripArea(const EditorBands& bands) { + constexpr int pad = 8; + const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12 + return Rect{bands.content.left + pad, stripTop, bands.content.right - pad, + stripTop + kStripBandHeight}; +} + +// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the +// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw + +// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom so +// the legend top tracks the strip bottom without re-inlining the strip arithmetic here. +Rect noteEntryFieldsArea(const EditorBands& bands) { + const int stripBottom = zonesStripArea(bands).bottom; + const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom + 8) + return Rect{bands.content.left + 8 + 128, top, bands.content.right - 8, top + 18}; +} + +// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal +// segments left-to-right. An out-of-range index yields an empty rect. +Rect noteEntryFieldRect(const Rect& fields, int f) { + if (f < 0 || f > 2 || fields.width() <= 0) return Rect{}; + const int segW = fields.width() / 3; + const int left = fields.left + f * segW + (f > 0 ? 4 : 0); // small inter-field gap + const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW; + return Rect{left, fields.top, right, fields.bottom}; +} + +// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip + +// the one-line selected-zone legend, running to the content bottom. `bands.content` is the +// Zones mode-content area. Both draw + hit-test use this single formula so they never drift. +Rect zonesControlPanel(const EditorBands& bands) { + constexpr int pad = 8; + const Rect strip = zonesStripArea(bands); + const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap + return Rect{bands.content.left + pad, panelTop, bands.content.right - pad, + bands.content.bottom - 4}; +} + +// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's +// header row (same y as the sample-name header, so it reads as "this capture's output mode"). +// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide, +// kSegH tall, side by side. Kept to a small fenced block (S11 owns the waveform region). +constexpr int kChanSegW = 52; +constexpr int kChanSegH = 18; +struct ChannelToggleRects { Rect mono; Rect stereo; }; +ChannelToggleRects channelToggleRects(const Rect& area) { + constexpr int pad = 8; + const int top = area.top + 4; + const int right = area.right - pad; + const Rect stereo{right - kChanSegW, top, right, top + kChanSegH}; + const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH}; + return {mono, stereo}; +} +} // namespace + +void ReaSamplerEditor::paint(HDC hdc) { + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + if (w <= 0 || h <= 0) return; + + LICE_SysBitmap bmp(w, h); + LICE_Clear(&bmp, kColBackground); + + const EditorBands bands = computeBands(w, h); + + // Title band: product name + live readout. + LICE_FillRect(&bmp, bands.title.left, bands.title.top, bands.title.width(), + bands.title.height(), kColTitleBg, 1.0f, 0); + std::string title = reasampler::vstPluginName(); // channel-derived (S18) + if (processor_ && processor_->bridge().isConnected()) { + if (samples_.empty()) title += " [bank empty]"; + else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]"; + else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; + else title += " [" + sampleLabel(samples_, selectedId_) + "]"; + } else { + title += " [host: no bridge]"; + } + Rect titleText{bands.title.left + 8, bands.title.top, bands.title.right - 8, + bands.title.bottom}; + drawText(&bmp, titleText, title.c_str(), kRgbText); + + // Toggle band: Browser | Zones. + const bool inZones = (view_ == View::kZones); + LICE_FillRect(&bmp, bands.toggleBrowser.left, bands.toggleBrowser.top, + bands.toggleBrowser.width(), bands.toggleBrowser.height(), + inZones ? kColTabBg : kColTabActiveBg, 1.0f, 0); + LICE_FillRect(&bmp, bands.toggleZones.left, bands.toggleZones.top, + bands.toggleZones.width(), bands.toggleZones.height(), + inZones ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(&bmp, bands.toggleBrowser, "Browser", kRgbText); + drawTextCentered(&bmp, bands.toggleZones, "Zones", kRgbText); + + if (view_ == View::kZones) { + paintZones(&bmp, w, h); + } else { + paintBrowser(&bmp, w, h); + } + + // S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window. + // It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn + // LAST so it overlays the mode content; decays via onSyncTimer (dropHintTicks_). + if (dropHintTicks_ > 0) { + const int bannerH = (std::min)(kTitleHeight + 8, h); + Rect banner{0, bands.toggleZones.bottom, w, bands.toggleZones.bottom + bannerH}; + LICE_FillRect(&bmp, banner.left, banner.top, banner.width(), banner.height(), + kColTabActiveBg, 1.0f, 0); + drawTextCentered(&bmp, banner, + "Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.", + kRgbText); + } + + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); +} + +void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { + // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from + // a bank filter that hides everything. Either way it is the "pick a capture" empty state. + const char* msg = samples_.empty() + ? "No captures in this project yet - capture audio into the bank to play it here." + : "No captures in this bank filter. Choose another bank tab above."; + // Split the area so the primary line sits centered and the S13 ingest affordance sits just + // below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept + // discoverable here regardless of whether a drop ever lands on THIS window. + Rect primary{area.left, area.top, area.right, area.top + area.height() / 2}; + Rect hint{area.left, primary.bottom, area.right, area.bottom}; + drawTextCentered(bmp, primary, msg, kRgbDim); + drawTextCentered(bmp, + hint, + "To add a sample: drop a file onto the ReaSampler bank panel (the docked window).", + kRgbDim); +} + +void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { + const EditorBands bands = computeBands(w, h); + // When a capture is picked, the setup band takes the bottom; the browser gets the rest. + const bool havePick = !selectedId_.empty(); + const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) + : bands.content.bottom; + const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + + // S12: reserve a type-to-filter search box at the top of the browser area; the tabs + grid + // sit below it. The search box spans the browser width. + const Rect searchBox = searchBoxRect(fullBrowserArea.width()); + const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top, + fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom}; + LICE_FillRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width(), searchAbs.height(), + searchFocused_ ? kColTabActiveBg : kColTabBg, 1.0f, 0); + { + std::string sb = searchQuery_.empty() + ? std::string("Search captures...") + : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); + Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom}; + drawText(bmp, sbText, sb.c_str(), searchQuery_.empty() ? kRgbDim : kRgbText); + } + + const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop}; + + // The browser tabs + card grid, laid out by the pure module over the browser sub-area. + // capture_browser lays out from (0,0); offset the draw by browserArea's origin. + const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); + const int ox = browserArea.left; + const int oy = browserArea.top; + scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); + + // Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights. + const int tabCount = static_cast(banks_.size()) + 1; + for (int i = 0; i < tabCount; ++i) { + Rect t = filterTabRect(bl, tabCount, i); + t = Rect{t.left + ox, t.top + oy, t.right + ox, t.bottom + oy}; + const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; + const bool active = (i == 0) ? activeFilterBankId_.empty() + : (banks_[static_cast(i - 1)].id == activeFilterBankId_); + LICE_FillRect(bmp, t.left, t.top, t.width(), t.height(), + active ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(bmp, t, label.c_str(), kRgbText); + } + + // Cards: only the S12 visible window at the current scroll offset (a bank longer than the + // panel is reachable by wheel/thumb drag). scrolledCardCellRect shifts each cell up by the + // offset; we clip to the grid region so a partially-scrolled row is trimmed at the edges. + const int bins = thumbBins(bl); + const int cardCount = static_cast(visible_.size()); + const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); + for (int i = vr.first; i < vr.last; ++i) { + // The scrolled CELL, then the same gutter/thumbnail/label insets the pure module derives, + // shifted by the scroll offset (they share the cell's top, so subtract the offset). + Rect content = cardContentRect(bl, i); + Rect thumb = cardThumbnailRect(bl, i); + Rect labelR = cardLabelRect(bl, i); + content = Rect{content.left + ox, content.top + oy - scrollOffset_, + content.right + ox, content.bottom + oy - scrollOffset_}; + thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_, + thumb.right + ox, thumb.bottom + oy - scrollOffset_}; + labelR = Rect{labelR.left + ox, labelR.top + oy - scrollOffset_, + labelR.right + ox, labelR.bottom + oy - scrollOffset_}; + + const SampleChoice& s = visible_[static_cast(i)]; + const bool sel = (s.id == selectedId_); + LICE_FillRect(bmp, content.left, content.top, content.width(), content.height(), + sel ? kColCardSelBg : kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1, + sel ? kColCardSelBorder : kColCardBorder, 1.0f, 0); + drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); + + // Name + root/key badge under the thumbnail. + std::string caption = s.displayName.empty() ? s.id : s.displayName; + Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2}; + Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom}; + drawText(bmp, nameR, caption.c_str(), kRgbText); + std::string badge; + if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); + else if (s.key) badge = *s.key; + else badge = "root -"; + drawText(bmp, badgeR, badge.c_str(), kRgbDim); + } + + // S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure + // module (empty when the content fits — the shell simply draws nothing then). Offset by the + // browser origin like every other card rect. + { + const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); + if (thumb.height() > 0) { + LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(), + kColRootMarker, 0.8f, 0); + } + } + + if (havePick) { + paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom}); + } else if (visible_.empty()) { + paintEmptyState(bmp, browserArea); + } +} + +void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { + // The guided single-capture setup: the picked capture's name + root/level, and a + // keyboard strip with its root marker (drag to set root). + LICE_FillRect(bmp, area.left, area.top, area.width(), area.height(), + kColTitleBg, 1.0f, 0); + + // Effective root: the picked sample's rootNote intrinsic (or middle C when unset). + // Read from samples_ (the full unfiltered list) so a bank-filter that hides the + // picked sample's bank doesn't mask its intrinsic root with the C4 default. + int root = 60; + for (const SampleChoice& s : samples_) { + if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; + } + // If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride; + } + + const int pad = 8; + // The mono/stereo toggle sits at the right of the header row; keep the name text clear of it. + const ChannelToggleRects chan = channelToggleRects(area); + Rect headerR{area.left + pad, area.top + 4, chan.mono.left - 8, area.top + 22}; + std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root); + drawText(bmp, headerR, header.c_str(), kRgbText); + + // S7 mono | stereo output-mode toggle. The active segment highlights (kColTabActiveBg), + // the inactive is kColTabBg — the same visual grammar as the Browser/Zones toggle. + const bool isStereo = (channelMode_ == ChannelMode::Stereo); + LICE_FillRect(bmp, chan.mono.left, chan.mono.top, chan.mono.width(), chan.mono.height(), + isStereo ? kColTabBg : kColTabActiveBg, 1.0f, 0); + LICE_FillRect(bmp, chan.stereo.left, chan.stereo.top, chan.stereo.width(), + chan.stereo.height(), isStereo ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(bmp, chan.mono, "Mono", kRgbText); + drawTextCentered(bmp, chan.stereo, "Stereo", kRgbText); + + Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16}; + drawText(bmp, hintR, + "Drag the waveform markers to set start + loop; drag the keyboard to set root.", + kRgbDim); + + // --- S11 waveform surface: the picked capture's envelope + draggable markers ---------- + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + const Rect waveArea = setupWaveformArea(area); + LICE_FillRect(bmp, waveArea.left, waveArea.top, waveArea.width(), waveArea.height(), + kColWaveBg, 1.0f, 0); + if (frames > 0 && waveArea.width() > 0) { + // Envelope at one bin per pixel (full-res view of the decoded PCM, S10 read-only view + // reused). computeEnvelope over the cached mono frames — no new decode. + const int bins = (std::max)(1, waveArea.width()); + const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast(bins)); + drawEnvelope(bmp, waveArea, env); // reuses the thumbnail envelope draw (kColThumb) + + const SetupMarkers m = pickedMarkers(frames); + // Faint loop-region fill between the loop markers (only when a loop is set). + if (m.hasLoop && m.loopEnd > m.loopStart) { + const int lx = frameToX(waveArea, frames, m.loopStart); + const int rx = frameToX(waveArea, frames, m.loopEnd); + if (rx > lx) { + LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(), + kColLoopRegion, 1.0f, 0); + } + } + // The three markers: start (amber), loop start + loop end (blue). Drawn as 2px vertical + // lines the full waveform height. Loop markers dim when no loop is set (the "no loop" + // state — draggable to CREATE one). + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const LICE_pixel markerCols[3] = {kColStartMarker, kColLoopMarker, kColLoopMarker}; + for (int i = 0; i < 3; ++i) { + const int mx = frameToX(waveArea, frames, markerFrames[i]); + const bool loopMarker = (i != 0); + const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; + LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(), markerCols[i], + alpha, 0); + } + } else { + drawTextCentered(bmp, waveArea, "(decoding...)", kRgbDim); + } + + // Keyboard strip with the root marker. + const Rect stripArea = setupStripArea(area); + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int sx = stripArea.left; + const int sy = stripArea.top; + LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(), + kColStripBg, 1.0f, 0); + // Faint per-octave key ticks for orientation. + for (int n = 0; n <= 127; n += 12) { + Rect k = keyRect(sl, n); + LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey, + 1.0f, 0, false); + } + Rect marker = rootMarkerRect(sl, root); + LICE_FillRect(bmp, marker.left + sx, sy, (std::max)(2, marker.width()), stripArea.height(), + kColRootMarker, 1.0f, 0); +} + +void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { + const EditorBands bands = computeBands(w, h); + const int pad = 8; + + // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip + // with one bar per zone. Delete is a small × on the selected zone (keystroke also). + Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, + bands.content.top + 4 + 20}; + LICE_FillRect(bmp, addR.left, addR.top, addR.width(), addR.height(), kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, addR.left, addR.top, addR.width() - 1, addR.height() - 1, + kColCardSelBorder, 1.0f, 0); + drawTextCentered(bmp, addR, "+ Add Zone", kRgbText); + + Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; + if (selectedZone_ >= 0) { + LICE_FillRect(bmp, delR.left, delR.top, delR.width(), delR.height(), kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, delR.left, delR.top, delR.width() - 1, delR.height() - 1, + kColCardSelBorder, 1.0f, 0); + drawTextCentered(bmp, delR, "Delete", kRgbText); + } + + // The zones strip. + const Rect stripArea = zonesStripArea(bands); + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int sx = stripArea.left; + const int sy = stripArea.top; + LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(), + kColStripBg, 1.0f, 0); + for (int n = 0; n <= 127; n += 12) { + Rect k = keyRect(sl, n); + LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey, + 1.0f, 0, false); + } + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + Rect bar = zoneBarRect(sl, z.lowNote, z.highNote); + const bool sel = (i == selectedZone_); + LICE_FillRect(bmp, bar.left + sx, sy, (std::max)(2, bar.width()), stripArea.height(), + sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0); + } + + // A one-line legend of the selected zone below the strip, with three click-to-type numeric + // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it + // (entryField_) and typed text commits via parseNoteEntry on Enter. + const int legendTop = stripArea.bottom + 8; + Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18}; + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + drawText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom}, + sampleLabel(samples_, z.sampleId).c_str(), kRgbText); + // Three fields laid out left-to-right after the sample label. + const Rect fields = noteEntryFieldsArea(bands); + const char* names[3] = {"Low", "High", "Root"}; + const std::string vals[3] = { + noteLabel(z.lowNote), noteLabel(z.highNote), + z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")}; + for (int f = 0; f < 3; ++f) { + const Rect fr = noteEntryFieldRect(fields, f); + const bool editing = (entryField_ == f); + LICE_FillRect(bmp, fr.left, fr.top, fr.width(), fr.height(), + editing ? kColTabActiveBg : kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1, + kColCardBorder, 1.0f, 0); + std::string cap = std::string(names[f]) + ": " + + (editing ? (entryText_ + "_") : vals[f]); + drawText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(), kRgbText); + } + } else if (map_.zones.empty()) { + drawText(bmp, infoR, + "No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim); + } + + // The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger + + // pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the + // single-capture face when the map is empty but a capture is picked (S15-F2 lean: the + // single capture is already a one-zone map — one storage site serves both). + const bool haveControlTarget = + (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) || + (map_.zones.empty() && !selectedId_.empty()); + if (haveControlTarget) { + paintControls(bmp, zonesControlPanel(computeBands(w, h))); + } +} + +// The label + the two toggle-segment captions for a control (member so it can name the private +// ParamControl enum). Segments are only read for a ControlKind::Toggle. +namespace { +struct ControlLabels { const char* label; const char* seg0; const char* seg1; }; +} // namespace + +void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) { + // Resolve the play params: from the selected zone when one is chosen, or from the + // PerformanceZone product defaults when the map is empty but a capture is picked + // (S15-F2 lean: the single-capture face shares the same storage site as a one-zone map; + // see paintZones for the gate that reaches here). + ZonePlaySeconds play; + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + play = map_.zones[static_cast(selectedZone_)].play; + } else if (map_.zones.empty() && !selectedId_.empty()) { + play = PerformanceZone{}.play; // product defaults (Gate + Preserve + tier-0 ADSR) + } else { + return; // no control target + } + const std::vector descs = controlDescs(play); + const std::vector rows = layoutControls(panel, descs); + + const auto labelsFor = [](ParamControl c) -> ControlLabels { + switch (c) { + case ParamControl::kPlayMode: return {"Mode", "Gate", "Trigger"}; + case ParamControl::kPitchEngine: return {"Pitch eng", "Varisp", "Preserve"}; + case ParamControl::kAttack: return {"Attack", "", ""}; + case ParamControl::kHold: return {"Hold", "", ""}; + case ParamControl::kDecay: return {"Decay", "", ""}; + case ParamControl::kSustain: return {"Sustain", "", ""}; + case ParamControl::kRelease: return {"Release", "", ""}; + case ParamControl::kTrigLength: return {"Length %", "", ""}; + case ParamControl::kTrigFadeIn: return {"Fade in", "", ""}; + case ParamControl::kTrigFadeOut: return {"Fade out", "", ""}; + case ParamControl::kPitchEnvEnable: return {"Pitch env", "Off", "On"}; + case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""}; + case ParamControl::kPitchEnvDecay: return {"P.Decay", "", ""}; + case ParamControl::kPitchEnvDepth: return {"P.Depth", "", ""}; + default: return {"", "", ""}; + } + }; + + for (const ControlRow& r : rows) { + if (r.row.top >= panel.bottom) break; // clip at the panel bottom + const ControlLabels lab = labelsFor(static_cast(r.id)); + drawText(bmp, r.label, lab.label, kRgbDim); + const double v = controlValue(r.id, play); + if (r.kind == ControlKind::Toggle) { + const bool seg1 = (v >= 0.5); + const Rect s0 = toggleSegmentRect(r.control, 0); + const Rect s1 = toggleSegmentRect(r.control, 1); + LICE_FillRect(bmp, s0.left, s0.top, s0.width(), s0.height(), + seg1 ? kColTabBg : kColTabActiveBg, 1.0f, 0); + LICE_FillRect(bmp, s1.left, s1.top, s1.width(), s1.height(), + seg1 ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(bmp, s0, lab.seg0, kRgbText); + drawTextCentered(bmp, s1, lab.seg1, kRgbText); + } else { + const Rect track = sliderTrackRect(r.control); + LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, track.width(), 2, + kColStripKey, 1.0f, 0); + const Rect handle = sliderHandleRect(r.control, v); + LICE_FillRect(bmp, handle.left, handle.top + 2, handle.width(), handle.height() - 4, + kColRootMarker, 1.0f, 0); + } + } +} + +// --- Input: the drag-state machine ------------------------------------------- + +void ReaSamplerEditor::onMouseDown(int x, int y) { + if (!processor_) return; + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + const EditorBands bands = computeBands(w, h); + + // Toggle band: switch views. + if (contains(bands.toggleBrowser, x, y)) { view_ = View::kBrowser; invalidate(); return; } + if (contains(bands.toggleZones, x, y)) { view_ = View::kZones; invalidate(); return; } + + if (view_ == View::kBrowser) { + const bool havePick = !selectedId_.empty(); + const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) + : bands.content.bottom; + const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + + // S12 search box (mirror of paintBrowser): a click focuses it; the browser sits below. + const Rect searchBox = searchBoxRect(fullBrowserArea.width()); + const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top, + fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom}; + if (contains(searchAbs, x, y)) { + searchFocused_ = true; + invalidate(); + return; + } + searchFocused_ = false; // any other browser click defocuses the search box + + const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop}; + const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); + const int bx = x - browserArea.left; + const int by = y - browserArea.top; + + // Filter tabs. + const int tabCount = static_cast(banks_.size()) + 1; + const int tab = filterTabHitTest(bl, tabCount, bx, by); + if (tab >= 0) { + activeFilterBankId_ = (tab == 0) ? std::string() + : banks_[static_cast(tab - 1)].id; + rebuildVisible(); + invalidate(); + return; + } + // S12 scrollbar thumb: grab to drag-scroll (checked before cards — the thumb overlays the + // grid's right gutter). scrollThumbRect is empty when the content fits, so this is inert then. + const Rect thumb = scrollThumbRect(bl, static_cast(visible_.size()), scrollOffset_); + if (thumb.height() > 0 && + contains(Rect{thumb.left + browserArea.left, thumb.top + browserArea.top, + thumb.right + browserArea.left, thumb.bottom + browserArea.top}, x, y)) { + drag_ = DragKind::kScrollThumb; + dragStartY_ = y; + dragStartScrollOffset_ = scrollOffset_; + return; + } + // Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). The + // hit-test adds the scroll offset back so a scrolled card maps to the right index. + const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); + if (card >= 0) { + selectedId_ = visible_[static_cast(card)].id; + commitAndReload(); // publishes the pick + reloads; process() plays it repitched + return; + } + // The setup band: the mono/stereo toggle (header row), the S11 waveform markers, + // then the root-marker strip. + if (havePick) { + const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + // S7: a click on a channel-mode segment sets the instance mode (setChannelMode + // re-negotiates the bus + reloads; a no-op set for the already-active mode is ignored + // by the processor). Snapshot the new mode locally so the paint reflects it at once. + const ChannelToggleRects chan = channelToggleRects(area); + if (contains(chan.mono, x, y)) { + channelMode_ = ChannelMode::Mono; + processor_->setChannelMode(ChannelMode::Mono); + invalidate(); + return; + } + if (contains(chan.stereo, x, y)) { + channelMode_ = ChannelMode::Stereo; + processor_->setChannelMode(ChannelMode::Stereo); + invalidate(); + return; + } + + // S11 waveform markers: grab start / loop-start / loop-end to drag. Hit-test the + // waveform band first (it sits above the keyboard strip). markerAtPoint resolves + // which marker under the grab; a miss falls through to the keyboard strip. + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + if (frames > 0) { + const Rect waveArea = setupWaveformArea(area); + const SetupMarkers m = pickedMarkers(frames); + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y); + if (hit >= 0) { + drag_ = DragKind::kWaveMarker; + waveMarker_ = static_cast(hit); + dragStartX_ = x; + dragStartMarkers_ = m; + dragSampleFrames_ = frames; + dragStartMap_ = map_; + return; // no immediate set — the marker only moves once the cursor drags + } + } + + // The setup strip: grab the root marker (drag to set the picked capture's root). + const Rect stripArea = setupStripArea(area); + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); + if (note >= 0) { + drag_ = DragKind::kRootMarker; + dragStartX_ = x; + dragStartRoot_ = note; + dragStartMap_ = map_; + // A click sets the root immediately (drag then refines); the override lives on + // a one-zone map entry for the picked capture (D-B, never written to the bank). + onMouseMove(x, y); // apply the click position as the first delta==0 set + return; + } + } + return; + } + + // Zones view. + const int pad = 8; + Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, + bands.content.top + 4 + 20}; + if (contains(addR, x, y)) { + // Add a full-keyboard zone for the picked capture (or the first visible sample as a + // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id + // already exists, select it rather than appending a duplicate (mirrors the upsert the + // root-marker drag path already performs, preventing overlapping identical zones). + std::string seed = !selectedId_.empty() ? selectedId_ + : (!visible_.empty() ? visible_.front().id : std::string()); + if (seed.empty()) return; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) { + selectedZone_ = i; + invalidate(); + return; + } + } + PerformanceZone z; + z.sampleId = seed; + z.lowNote = 0; + z.highNote = 127; + map_.zones.push_back(z); + selectedZone_ = static_cast(map_.zones.size()) - 1; + commitAndReload(); + return; + } + Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; + if (selectedZone_ >= 0 && contains(delR, x, y)) { + map_.zones.erase(map_.zones.begin() + selectedZone_); + selectedZone_ = -1; + commitAndReload(); + return; + } + + // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the + // selected zone's root. + const Rect stripArea = zonesStripArea(bands); + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int lx = x - stripArea.left; + const int ly = y - stripArea.top; + + std::vector lows, highs; + lows.reserve(map_.zones.size()); + highs.reserve(map_.zones.size()); + for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); } + const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(), + highs.empty() ? nullptr : highs.data(), + static_cast(map_.zones.size()), lx, ly); + if (hit.zoneIndex >= 0) { + selectedZone_ = hit.zoneIndex; + const PerformanceZone& z = map_.zones[static_cast(hit.zoneIndex)]; + dragStartX_ = x; + dragStartLow_ = z.lowNote; + dragStartHigh_ = z.highNote; + dragStartMap_ = map_; + switch (hit.grab) { + case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break; + case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break; + case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break; + default: drag_ = DragKind::kNone; break; + } + invalidate(); + return; + } + // A bare key-click inside the strip sets the selected zone's root override. + if (contains(stripArea, x, y) && selectedZone_ >= 0 && + selectedZone_ < static_cast(map_.zones.size())) { + const int note = keyAtPoint(sl, lx, ly); + if (note >= 0) { + map_.zones[static_cast(selectedZone_)].rootOverride = note; + commitAndReload(); + } + return; + } + + // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a + // zone is selected. entryText_ starts empty (the user types the full value). + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const Rect fields = noteEntryFieldsArea(bands); + for (int f = 0; f < 3; ++f) { + if (contains(noteEntryFieldRect(fields, f), x, y)) { + entryField_ = f; + entryText_.clear(); + invalidate(); + return; + } + } + } + entryField_ = -1; // a click elsewhere in the Zones view cancels an in-progress entry + + // The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab + // starts a live drag (commit on release). Reachable for an explicit zone selection OR for + // the single-capture face when the map is empty but a capture is picked (S15-F2 lean). + // In the empty-map+picked case, auto-create a full-keyboard zone for selectedId_ on first + // control interaction (same path as "+ Add Zone"), then apply the control — the zone is + // committed as part of the control edit. + if (selectedZone_ < 0 && map_.zones.empty() && !selectedId_.empty()) { + // Synthesize a probe layout with the product defaults to see if the click is in the + // panel before committing to creating the zone. + const Rect panel = zonesControlPanel(bands); + const ZonePlaySeconds defaultPlay = PerformanceZone{}.play; + const std::vector probeDescs = controlDescs(defaultPlay); + const std::vector probeRows = layoutControls(panel, probeDescs); + if (controlAtPoint(probeRows, x, y) >= 0) { + // The click lands in the control panel — materialize the zone now. + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + map_.zones.push_back(z); + selectedZone_ = 0; + // Fall through to the control handler below which will process the click. + } + } + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + const Rect panel = zonesControlPanel(bands); + const std::vector descs = controlDescs(z.play); + const std::vector rows = layoutControls(panel, descs); + const int id = controlAtPoint(rows, x, y); + if (id >= 0) { + // Find the row to know its kind + control rect. + for (const ControlRow& r : rows) { + if (r.id != id) continue; + if (r.kind == ControlKind::Toggle) { + const int seg = toggleSegmentHitTest(r.control, x, y); + if (seg >= 0) { + applyControl(id, z.play, 0.0, seg); + commitAndReload(); // a toggle is a discrete, final edit + } + } else { + // Grab the slider: set the value at the grab x immediately, then live-drag. + drag_ = DragKind::kParamSlider; + dragParamId_ = id; + dragParamPanel_ = panel; + dragStartMap_ = map_; + applyControl(id, z.play, valueAtPoint(r.control, x), 0); + invalidate(); // live feedback; commit on WM_LBUTTONUP + } + break; + } + } + } +} + +void ReaSamplerEditor::onMouseMove(int x, int y) { + if (drag_ == DragKind::kNone) return; + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + const EditorBands bands = computeBands(w, h); + const int dx = x - dragStartX_; + + if (drag_ == DragKind::kRootMarker) { + // The single-capture root strip lives in the setup band. + const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); + const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + const Rect stripArea = setupStripArea(area); + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int note = resolveDragNote(sl, dragStartRoot_, dx); + // The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone), + // so setting the single capture's root materializes a full-keyboard zone carrying the + // override. This plays identically to the un-zoned single-capture path (one chromatic + // zone over the whole keyboard) and round-trips through the v3 component state; the + // zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a + // repeated drag edits the same zone rather than stacking duplicates. + // Upsert the root override on the picked id; track the zone index so the control panel + // stays visible after the zone is materialized on the single-capture face (fix: without + // setting selectedZone_ here, selectedZone_==-1 with a non-empty map hides controls). + bool found = false; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == selectedId_) { + z.rootOverride = note; + selectedZone_ = i; + found = true; + break; + } + } + if (!found) { + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.rootOverride = note; + map_.zones.push_back(z); + selectedZone_ = static_cast(map_.zones.size()) - 1; + } + invalidate(); // live feedback; the commit lands on WM_LBUTTONUP + return; + } + + if (drag_ == DragKind::kWaveMarker) { + // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap + // it against the decoded PCM, apply the inter-marker clamps, and write the override live. + const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); + const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + const Rect waveArea = setupWaveformArea(area); + const std::int64_t frames = dragSampleFrames_; + if (frames <= 0) return; + + // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab). + const int idx = static_cast(waveMarker_); + const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, + dragStartMarkers_.loopEnd}; + std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx); + + // Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware + // requirement). Pure over the cached mono frames — no host types, no file I/O. + const std::vector& pcm = monoPcmFor(selectedId_); + if (!pcm.empty()) { + newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), + newFrame); + } + + // Build the edited marker set from the snapshot, moving only the grabbed marker, then + // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop. + SetupMarkers m = dragStartMarkers_; + if (waveMarker_ == WaveMarker::kStart) { + m.start = newFrame; + } else if (waveMarker_ == WaveMarker::kLoopStart) { + m.loopStart = (std::min)(newFrame, m.loopEnd); + m.hasLoop = true; + } else { // kLoopEnd + m.loopEnd = (std::max)(newFrame, m.loopStart); + m.hasLoop = true; + } + if (m.start < 0) m.start = 0; + if (m.start > frames - 1) m.start = frames - 1; + + // Upsert the override on the picked id (mirror of the root-marker path); commit lands on + // release, this is live feedback. Set selectedZone_ so the control panel stays visible + // after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty + // map hides controls after the first marker drag on the single-capture face). + selectedZone_ = upsertPickedOverride(m); + invalidate(); + return; + } + + if (drag_ == DragKind::kScrollThumb) { + // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card + // window recomputes at paint from scrollOffset_. The browser sub-area matches paintBrowser + // when a capture is picked (the setup band takes the bottom). + const int dyThumb = y - dragStartY_; + const bool havePick = !selectedId_.empty(); + const int setupTop = havePick + ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) + : bands.content.bottom; + const BrowserLayout bl = layoutBrowser(bands.content.width(), setupTop - bands.content.top); + scrollOffset_ = thumbDragToOffset(bl, static_cast(visible_.size()), + dragStartScrollOffset_, dyThumb); + invalidate(); + return; + } + + if (drag_ == DragKind::kParamSlider) { + // S12/S15/S16: re-lay the panel and map x -> value against the grabbed control's live + // track rect (the panel geometry is stable during the drag; re-laying keeps the value + // mapping exact even if a mode toggle changed the row set — it did not, mid-drag). + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + const std::vector descs = controlDescs(z.play); + const std::vector rows = layoutControls(dragParamPanel_, descs); + for (const ControlRow& r : rows) { + if (r.id == dragParamId_) { + applyControl(dragParamId_, z.play, valueAtPoint(r.control, x), 0); + break; + } + } + invalidate(); + return; + } + + // Zone edits: recompute the grabbed field(s) against the pure resolver, live. + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + const Rect stripArea = zonesStripArea(bands); + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (drag_ == DragKind::kZoneLow) { + z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote); + } else if (drag_ == DragKind::kZoneHigh) { + z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote); + } else if (drag_ == DragKind::kZoneBody) { + // Move the whole span: apply the SAME delta to both edges so the span is preserved, + // clamping so neither edge escapes [0,127] (the span shifts, never shrinks). + const int newLow = resolveDragNote(sl, dragStartLow_, dx); + const int newHigh = resolveDragNote(sl, dragStartHigh_, dx); + const int span = dragStartHigh_ - dragStartLow_; + if (newLow < 0) { z.lowNote = 0; z.highNote = span; } + else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; } + else { z.lowNote = newLow; z.highNote = newHigh; } + } + invalidate(); +} + +void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) { + if (drag_ == DragKind::kNone) return; + const DragKind kind = drag_; + drag_ = DragKind::kNone; + // A scrollbar drag is transient UI (no map change) — repaint but do NOT reload. Every other + // drag is a coherent map edit: publish the in-flight map + reload off-thread on release. + if (kind == DragKind::kScrollThumb) { + invalidate(); + return; + } + commitAndReload(); +} + +void ReaSamplerEditor::onMouseWheel(int delta) { + // S12 browser scroll (only in the browser view). One wheel notch (WHEEL_DELTA==120) scrolls + // roughly one card row; the offset is clamped at paint (the layout/panel height is known + // there). A positive delta (wheel up) scrolls toward the top (smaller offset). + if (view_ != View::kBrowser) return; + const int rows = delta / 120; + if (rows == 0) return; + scrollOffset_ -= rows * kBrowserCardHeight; + if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content + invalidate(); +} + +void ReaSamplerEditor::onSearchChar(unsigned int ch) { + // S12 numeric note-entry (Zones view): a focused low/high/root field accumulates keystrokes + // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when + // focused, owns the keystrokes). + if (view_ == View::kZones && entryField_ >= 0) { + if (ch == 13) { // Enter: parse + commit + if (auto note = parseNoteEntry(entryText_)) { + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote); + else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote); + else z.rootOverride = *note; + commitAndReload(); + } + } + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 27) { // Escape cancels + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 8) { // backspace + if (!entryText_.empty()) entryText_.pop_back(); + invalidate(); + } else if (ch >= 32 && ch < 127) { + entryText_.push_back(static_cast(ch)); + invalidate(); + } + return; + } + + // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace + // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search). + if (view_ != View::kBrowser || !searchFocused_) return; + if (ch == 8) { // backspace + if (!searchQuery_.empty()) searchQuery_.pop_back(); + } else if (ch == 27) { // escape clears + defocuses + searchQuery_.clear(); + searchFocused_ = false; + } else if (ch >= 32 && ch < 127) { + searchQuery_.push_back(static_cast(ch)); + } else { + return; // ignore other control chars + } + scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list + rebuildVisible(); + invalidate(); +} + +void ReaSamplerEditor::onFilesDropped(int droppedCount) { + // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact + // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff + // decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a + // timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing + // at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs + // each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0. + (void)droppedCount; // count is informational; the banner text is drop-count-agnostic + dropHintTicks_ = 6; +#ifdef _WIN32 + invalidate(); +#endif +} + +LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, + LPARAM lParam) { + auto* self = + reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); + switch (msg) { + case WM_PAINT: { + PAINTSTRUCT ps{}; + HDC hdc = BeginPaint(hwnd, &ps); + if (self) self->paint(hdc); + EndPaint(hwnd, &ps); + return 0; + } + case WM_LBUTTONDOWN: + if (self) { + SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child + SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) + self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + } + return 0; + case WM_MOUSEMOVE: + if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_MOUSEWHEEL: + // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. + if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); + return 0; + case WM_CHAR: + // S12 type-to-filter search keystrokes (only acted on when the search box is focused). + if (self) self->onSearchChar(static_cast(wParam)); + return 0; + case WM_GETDLGCODE: + // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). + return DLGC_WANTCHARS | DLGC_WANTARROWS; + case WM_LBUTTONUP: + if (self) { + self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + ReleaseCapture(); + } + return 0; + case WM_CAPTURECHANGED: + // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its + // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset + // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. + // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. + if (self && self->drag_ != DragKind::kNone) { + // A scrollbar drag is transient (no map mutation + dragStartMap_ was not + // snapshotted for it) — reset the drag state only, never touch map_. Every + // map-editing drag rolls its live mutation back to the pre-grab snapshot. + if (self->drag_ != DragKind::kScrollThumb) self->map_ = self->dragStartMap_; + self->drag_ = DragKind::kNone; + self->invalidate(); + } + return 0; + case WM_DROPFILES: { + // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT + // read/ingest the paths (the instrument never ingests — the relay to the extension is + // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner. + HDROP drop = reinterpret_cast(wParam); + const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); + DragFinish(drop); + if (self) self->onFilesDropped(static_cast(count)); + return 0; + } + case WM_TIMER: + if (self && wParam == kSyncTimerId) self->onSyncTimer(); + return 0; + case WM_ERASEBKGND: + return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase + default: + return DefWindowProcW(hwnd, msg, wParam, lParam); + } +} + +#else // non-Windows: not a build target (D5), but keep the TU compilable. + +void ReaSamplerEditor::attachedToParent() {} +void ReaSamplerEditor::removedFromParent() {} +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + return CPluginView::onSize(newSize); +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h new file mode 100644 index 0000000..68b57b4 --- /dev/null +++ b/src/vst/reasampler_editor.h @@ -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 +#include +#include +#include + +#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 +#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& 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 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 samples_; // every bank sample, bank order + std::vector banks_; // the named banks, for the filter tab strip + std::vector visible_; // samples_ narrowed by the active bank filter + std::string selectedId_; // the single-capture pick ("" = empty state) + PerformanceMap map_; // the opt-in zones (empty = no zones) + 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 thumbCache_; + + // --- Decoded mono-PCM cache (S11; id -> full-res frames) ------------------------------ + // Keyed by id (width-independent, unlike thumbCache_). Feeds the waveform envelope binning + // + the zero-crossing snap. Cleared alongside thumbCache_ on refresh so a re-captured or + // deleted sample does not show/snap against stale PCM. + std::unordered_map> pcmCache_; +}; + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp new file mode 100644 index 0000000..fe67a34 --- /dev/null +++ b/src/vst/reasampler_embed.cpp @@ -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 +#include + +#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 on Windows), which +// reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type. +#include "wdltypes.h" + +// REAPER's embed message/bitmap contract (vendored). REAPER_FXEMBED_IBitmap is an alias of +// LICE_IBitmap, and the WM_* / DrawInfo / SizeHints definitions live here. +#include "reaper_plugin_fx_embed.h" + +#ifdef _WIN32 +// LICE — the same drawing stack the IPlugView editor and bank_panel use. REAPER hands us a +// LICE bitmap; we draw into it with the same calls, then return (REAPER blits it). +#include "lice/lice.h" +#endif + +using namespace Steinberg; + +// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperUIEmbedInterface::iid; some +// TU must DEFINE it. This is the only place that answers queryInterface for it, so the +// definition lives with its sole use (mirrors reaper_bridge.cpp doing this for +// IReaperHostApplication). +DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface) + +namespace reasampler::vst { + +namespace { +#ifdef _WIN32 +// Palette — mirrored from reasampler_editor.cpp so the inline strip reads as the same tool. +const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); +const LICE_pixel kColZone = LICE_RGBA(44, 44, 48, 255); +const LICE_pixel kColZoneSel = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColZoneBorder = LICE_RGBA(20, 20, 22, 255); +const LICE_pixel kColLevelBg = LICE_RGBA(20, 20, 22, 255); +const LICE_pixel kColLevelFill = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColEmpty = LICE_RGBA(70, 70, 74, 255); +const COLORREF kRgbText = RGB(210, 230, 220); + +// A short display name for a bank sample id, from the snapshotted list (the editor's helper, +// duplicated small rather than shared across the shell/pure boundary). +std::string sampleLabel(const std::vector& samples, const std::string& id) { + for (const SampleChoice& c : samples) { + if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; + } + return "?"; +} +#endif + +// Project the instrument's performance map into the strip's minimal zone shape (key ranges +// only). Pure projection — kept here (shell side) because it reads PerformanceMap, a shell +// type; embed_strip stays free of it. +std::vector toEmbedZones(const PerformanceMap& map) { + std::vector out; + out.reserve(map.zones.size()); + for (const PerformanceZone& z : map.zones) out.push_back(EmbedZone{z.lowNote, z.highNote}); + return out; +} + +} // namespace + +tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) { + QUERY_INTERFACE(iid, obj, FUnknown::iid, IReaperUIEmbedInterface) + QUERY_INTERFACE(iid, obj, IReaperUIEmbedInterface::iid, IReaperUIEmbedInterface) + *obj = nullptr; + return kNoInterface; +} + +void ReaSamplerEmbed::refresh() { + if (!processor_) { + samples_.clear(); + map_.zones.clear(); + selectedZone_ = -1; + return; + } + auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banks ? listSamples(*banks) : std::vector{}; + map_ = processor_->performanceMap(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; +} + +void ReaSamplerEmbed::maybeRefresh() { + if (!processor_) { refresh(); return; } // clears state; cheap + + // The performance map is a cheap in-process accessor (mutex + copy), and the editor may + // have edited zones with NO bank-content change — always re-snapshot it so a zone edit + // reflects immediately. + map_ = processor_->performanceMap(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; + + // The EXPENSIVE part is the bank-blob bridge read (samples_). Gate it on the S9 bank- + // generation stamp (a small ext-state read): only re-read the bank when the generation + // changed since the last paint (a recapture / ingest / remove), or on the first paint + // (lastSeenBankGeneration_ == -1). A pre-S9 project reads generation 0; the first paint + // folds it and subsequent idle paints skip the bank read entirely. + std::int64_t currentGen = lastSeenBankGeneration_; + if (auto rawGen = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBankGenKey)) { + currentGen = parseBankGeneration(*rawGen); + } else if (lastSeenBankGeneration_ < 0) { + currentGen = 0; // unprimed + no stamp (pre-S9): treat as generation 0 for the first read + } + // 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{}; + 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(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(bitmap); + auto* di = reinterpret_cast(drawInfo); + if (!bmp || !di) return false; + const int w = di->width; + const int h = di->height; + if (w <= 0 || h <= 0) return false; + + // Re-read live state each paint (UI thread) so the strip reflects keymap edits + bank + // changes without its own timer — REAPER repaints the embed surface on its cadence. 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(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[i]; + const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote); + if (r.width() <= 0) continue; + const bool sel = (i == selectedZone_); + LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(), + sel ? kColZoneSel : kColZone, 1.0f, 0); + LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, kColZoneBorder, + 1.0f, 0); + // Label the segment with the sample name when it is wide enough to read. + if (r.width() >= 24) { + HDC dc = bmp->getDC(); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, kRgbText); + RECT gr{r.left + 3, r.top, r.right - 2, r.bottom}; + const std::string label = sampleLabel(samples_, z.sampleId); + DrawTextA(dc, label.c_str(), -1, &gr, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + } + } + } + + // The level band: a static background here; a live activity level is a later refinement + // (the processor would publish a peak the UI thread reads). Draw the empty band so the + // strip's geometry is complete and the DAW-verify sees the band lifecycle now. + if (layout.levelBand.height() > 0) { + LICE_FillRect(bmp, layout.levelBand.left, layout.levelBand.top, + layout.levelBand.width(), layout.levelBand.height(), kColLevelBg, 1.0f, + 0); + const double level = processor_ ? processor_->embedActivityLevel() : 0.0; + const Rect fill = levelFillRect(layout, level); + if (fill.width() > 0) { + LICE_FillRect(bmp, fill.left, fill.top, fill.width(), fill.height(), + kColLevelFill, 1.0f, 0); + } + } + + return true; +} + +bool ReaSamplerEmbed::onMouseDown(TPtrInt drawInfo) { + auto* di = reinterpret_cast(drawInfo); + if (!di || di->width <= 0 || di->height <= 0) return false; + refresh(); + const EmbedLayout layout = layoutEmbed(di->width, di->height); + const std::vector zones = toEmbedZones(map_); + const int hit = zoneAtPoint(layout, zones.data(), static_cast(zones.size()), + di->mouse_x, di->mouse_y); + if (hit == selectedZone_) return false; // no change -> no redraw + selectedZone_ = hit; + return true; +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_embed.h b/src/vst/reasampler_embed.h new file mode 100644 index 0000000..33a017c --- /dev/null +++ b/src/vst/reasampler_embed.h @@ -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 +#include +#include + +#include "pluginterfaces/base/funknown.h" + +#include "sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects) + +// REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is +// pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host +// interface header. Its iid is DEFINEd (DEF_CLASS_IID) in reasampler_embed.cpp. +namespace Steinberg { +#include "reaper_vst3_interfaces.h" +} // namespace Steinberg + +namespace reasampler::vst { + +class ReaSamplerProcessor; + +// Implements IReaperUIEmbedInterface. Lifetime is OWNED by the processor (the processor +// holds the sole unique_ptr and hands out AddRef'd references from queryInterface); the +// back-pointer to the processor is therefore always valid while this lives. +class ReaSamplerEmbed : public Steinberg::IReaperUIEmbedInterface { +public: + explicit ReaSamplerEmbed(ReaSamplerProcessor* processor) : processor_(processor) {} + + // The one embed entry point. Routes each REAPER_FXEMBED_WM_* message; see the header + // note above for the per-message contract. UI thread only. + Steinberg::TPtrInt embed_message(int msg, Steinberg::TPtrInt parm2, + Steinberg::TPtrInt parm3) override; + + // FUnknown: this object's lifetime is owned by the processor, not the host refcount, so + // AddRef/release are no-ops (the processor's unique_ptr governs destruction) and + // queryInterface answers only FUnknown + IReaperUIEmbedInterface. This mirrors how the + // SDK's OBJ refcount would otherwise churn; here the owning processor guarantees the + // object outlives every borrowed reference REAPER holds during embedding. + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, + void** obj) override; + Steinberg::uint32 PLUGIN_API addRef() override { return 1000; } + Steinberg::uint32 PLUGIN_API release() override { return 1000; } + +private: +#ifdef _WIN32 + // Draw the current strip into REAPER's supplied LICE bitmap. Returns true if it drew. + bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo); + // Handle a mouse-down inside the strip: map to a zone and select it (S6: selection at + // most — no new editing semantics). Returns true if the selection changed (the caller + // then asks REAPER to invalidate). + bool onMouseDown(Steinberg::TPtrInt drawInfo); +#endif + + // Snapshot the live bank + the instrument's performance map for the next paint, exactly + // as the editor's refreshSampleList does (bridge read + processor accessors, UI thread). + void refresh(); + + // The S9 dirty-guard over refresh() (the S6 flagged follow-up): read the cheap bank- + // generation stamp; do the EXPENSIVE bank-blob bridge read (refresh()) only when the + // generation changed since the last paint (or on the first paint) — the strip re-read + // per paint was wasteful now that a generation counter exists. The performance map (a + // cheap in-process accessor, edited by the editor independently of bank content) is + // ALWAYS refreshed so a zone edit still reflects immediately. UI thread only. + void maybeRefresh(); + + ReaSamplerProcessor* processor_ = nullptr; + // The bank generation last folded into samples_ (S9 dirty-guard). -1 forces the first + // maybeRefresh() to do a full read (no generation can be negative — parseBankGeneration + // yields >= 0 — so -1 is an "unprimed" sentinel distinct from a real generation 0). + std::int64_t lastSeenBankGeneration_ = -1; + // Snapshotted for the current paint (refreshed each paint off the audio thread). + std::vector samples_; + PerformanceMap map_; + // 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 diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp new file mode 100644 index 0000000..c14f7ce --- /dev/null +++ b/src/vst/reasampler_processor.cpp @@ -0,0 +1,610 @@ +// reasampler_processor.cpp — see reasampler_processor.h. + +#include "reasampler_processor.h" + +#include +#include +#include +#include +#include +#include + +#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 readFileBytes(const std::string& path) { + std::vector bytes; + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return bytes; + const std::streamoff size = f.tellg(); + if (size <= 0) return bytes; + f.seekg(0, std::ios::beg); + bytes.resize(static_cast(size)); + if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); + return bytes; +} + +// 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 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 bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (!layout.valid) return std::nullopt; + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, + static_cast(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(new ReaSamplerProcessor()); +} + +// Out-of-line so unique_ptr sees the complete type here. +ReaSamplerProcessor::~ReaSamplerProcessor() = default; + +tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { + // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for + // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed + // shell. We own the shell (unique_ptr); the borrowed reference is valid because the + // processor outlives it. All other iids fall through to the SDK's queryInterface. + if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { + if (!embed_) embed_ = std::make_unique(this); + embed_->addRef(); + *obj = static_cast(embed_.get()); + return kResultOk; + } + return SingleComponentEffect::queryInterface(iid, obj); +} + +tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { + tresult result = SingleComponentEffect::initialize(context); + if (result != kResultOk) return result; + + // 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 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 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 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 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 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 lock(assignMarkerMutex_); + state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker + } + const std::vector bytes = serializeComponentState(state_out); + if (!bytes.empty()) { + const tresult wr = state->write(const_cast(bytes.data()), + static_cast(bytes.size()), nullptr); + if (wr != kResultOk) return wr; + } + return kResultOk; +} + +std::string ReaSamplerProcessor::selectedSampleId() { + std::lock_guard lock(selectionMutex_); + return selectedSampleId_; +} + +void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { + std::lock_guard lock(selectionMutex_); + selectedSampleId_ = id; +} + +PerformanceMap ReaSamplerProcessor::performanceMap() { + std::lock_guard lock(performanceMutex_); + return performanceMap_; +} + +void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { + std::lock_guard lock(performanceMutex_); + performanceMap_ = map; +} + +ChannelMode ReaSamplerProcessor::channelMode() { + std::lock_guard lock(channelModeMutex_); + return channelMode_; +} + +void ReaSamplerProcessor::applyOutputArrangement(ChannelMode mode) { + // Set the single output bus's SpeakerArrangement to the mode's arrangement so getBusInfo / + // getBusArrangement report the right channel count. The default getBusArrangement (from the + // base) reads back exactly what we store here. No re-negotiation — the caller drives that. + BusList* outs = getBusList(kAudio, kOutput); + if (!outs || outs->empty()) return; + if (auto* bus = FCast(outs->at(0))) { + bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo + : SpeakerArr::kMono); + } +} + +void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { + { + std::lock_guard lock(channelModeMutex_); + if (channelMode_ == mode) return; // no-op: don't churn the bus / re-negotiate + channelMode_ = mode; + } + // The mode changed: repoint the output bus and ask the host to re-negotiate I/O so REAPER's + // routing follows (mono<->stereo). restartComponent is a main/UI-thread call; setChannelMode + // is driven from the editor, so this is safe. Then reload so the next block decodes the new + // channel count into the LoadedInstrument (off-thread, RT path untouched). + applyOutputArrangement(mode); + if (componentHandler) componentHandler->restartComponent(kIoChanged); + reloadFromBank(); +} + +tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( + SpeakerArrangement* inputs, int32 numIns, + SpeakerArrangement* outputs, int32 numOuts) { + // The instrument has ONE canonical arrangement per its channel mode (S7). We take NO audio + // input, so any inputs are rejected. For the single output bus: accept (kResultTrue) only + // when the host proposes exactly the mode's arrangement; otherwise reject (kResultFalse) but + // KEEP the mode's arrangement (per the VST3 contract, a plug-in that can't honor a proposal + // keeps a valid arrangement of its own). getBusArrangement then still reports the mode's + // channel count, so the host adapts its routing to us rather than forcing our channel count. + if (numIns < 0 || numOuts < 0) return kInvalidArgument; + if (numIns > 0) return kResultFalse; // no audio input bus to arrange + + const SpeakerArrangement want = + channelMode() == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono; + applyOutputArrangement(channelMode()); // keep the bus pinned to the mode's arrangement + if (numOuts == 1 && outputs && outputs[0] == want) return kResultTrue; + return kResultFalse; +} + +std::string ReaSamplerProcessor::reloadFromBank() { + // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so + // the retired-slot free is single-writer. This mutex is NEVER taken on the audio + // thread — process() only touches the atomic. + std::lock_guard lock(reloadMutex_); + + // Mint this reload's generation number first so we can stamp the built instrument + // with it before publishing. Under reloadMutex_ no other reload races here. + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + + // 1. Read the live bank + resolve the project dir over the bridge (allocates, + // calls REAPER — fine here, off-thread). + std::optional banksJson = + bridge_.readReasamplerExtState(kProjExtBanksKey); + const std::string projectDir = bridge_.activeProjectDir(); + // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). + // Read once under its mutex, off the audio thread, before the decode loop. + const ChannelMode mode = channelMode(); + + std::string resolvedId; + std::unique_ptr built; + + 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 decoded; + std::vector kept; + decoded.reserve(resolved.zones.size()); + kept.reserve(resolved.zones.size()); + for (const ResolvedZone& rz : resolved.zones) { + std::optional pcm = + decodeRelative(projectDir, rz.relativePath, 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 sel = + selectSample(*banksJson, selectedSampleId()); + if (sel) { + std::optional 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( + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; + built = std::make_unique( + 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(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 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 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(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(frames)); + } + // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). + for (int32 ch = 2; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; + if (a0 > peak) peak = a0; + if (a1 > peak) peak = a1; + } + embedPeak_.store(peak, std::memory_order_relaxed); + } else if (ch0) { + // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; + // the replicate is defensive for a host that still hands >1 channel on a mono bus). + for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; + if (inst) { + inst->engine.render(ch0, static_cast(frames)); + } + 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 diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h new file mode 100644 index 0000000..fa7ff2e --- /dev/null +++ b/src/vst/reasampler_processor.h @@ -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 +#include +#include +#include +#include +#include + +#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(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 0–1 in normal use). Remaining entries drain at setActive(false) / + // terminate(), when the host guarantees process is stopped. + std::atomic live_{nullptr}; + std::atomic reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process) + std::atomic processGeneration_{0}; // generation last seen by process (written on audio thread, read off-thread) + struct GraveyardEntry { + std::uint64_t displacedAt = 0; // reloadGeneration_ value when this was displaced + std::unique_ptr instrument; + }; + std::vector graveyard_; // drained on reclaim + setActive(false) + terminate + std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access + + // The 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 embed_; + + // The per-block mono peak (0..1+) the audio thread stores relaxed; the embed strip's + // level indicator reads it via embedActivityLevel(). Advisory only — a plain atomic, + // no ordering coupling, never guarded by a lock the audio thread touches. + std::atomic embedPeak_{0.f}; +}; + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_vst.h b/src/vst/reasampler_vst.h new file mode 100644 index 0000000..b6343f5 --- /dev/null +++ b/src/vst/reasampler_vst.h @@ -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 diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp new file mode 100644 index 0000000..5f07b99 --- /dev/null +++ b/src/vst/sample_map.cpp @@ -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 // std::min +#include // assert +#include // std::memcpy +#include // 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 selectSample(const std::string& banksJson, + const std::string& sampleId) { + // POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short- + // circuit before parsing — no stored id resolves to nothing to play by design. + if (sampleId.empty()) return std::nullopt; + if (banksJson.empty()) return std::nullopt; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return std::nullopt; // malformed -> nothing to play (never throw) + + // Search every bank (pool first, then named — banks() is ordinal order) for the + // stored id. A sample lives in exactly one bank, so first hit wins. + 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 listSamples(const std::string& banksJson) { + std::vector out; + if (banksJson.empty()) return out; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; + for (const Bank& b : book->banks()) { + for (const Sample& s : b.index.all()) { + out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id}); + } + } + return out; +} + +std::vector listBanks(const std::string& banksJson) { + std::vector out; + if (banksJson.empty()) return out; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; + for (const Bank& b : book->banks()) { + out.push_back(BankChoice{b.id, b.displayName}); + } + return out; +} + +std::vector downmixToMono(const std::vector& interleaved, + int channelCount) { + std::vector out; + if (channelCount <= 0 || interleaved.empty()) return out; + const std::size_t stride = static_cast(channelCount); + const std::size_t frames = interleaved.size() / stride; + out.resize(frames); + const double inv = 1.0 / static_cast(channelCount); + for (std::size_t f = 0; f < frames; ++f) { + double acc = 0.0; + const std::size_t base = f * stride; + for (std::size_t c = 0; c < stride; ++c) { + acc += static_cast(interleaved[base + c]); + } + out[f] = static_cast(acc * inv); + } + return out; +} + +std::vector extractChannel(const std::vector& interleaved, + int channelCount, int which) { + std::vector out; + if (channelCount <= 0 || interleaved.empty()) return out; + const std::size_t stride = static_cast(channelCount); + // Clamp the requested channel into the source's range: a channel past the last one reads + // the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono). + std::size_t ch = which < 0 ? 0 : static_cast(which); + if (ch >= stride) ch = stride - 1; + const std::size_t frames = interleaved.size() / stride; + out.resize(frames); + for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch]; + return out; +} + +DecodedZonePcm decodeChannels(const std::vector& interleaved, + int sourceChannels, ChannelMode mode, int sampleRate) { + 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(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(f + 0.5); + }; + ZonePlayParams out; + out.playMode = stored.playMode; + out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds); + out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds); + out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds); + out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time + out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds); + out.trigger = stored.trigger; // source-frame / fraction, unchanged + out.pitchEngine = stored.pitchEngine; + out.pitchEnv.enabled = stored.pitchEnv.enabled; + out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds); + out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds); + out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time + return out; +} + +Keymap buildTier0Keymap(std::vector frames, int sampleRate, + int rootNote, const SampleLoop& loop, + std::vector framesR, const 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 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& zones, + const std::vector& decoded) { + Keymap km; + const std::size_t n = std::min(zones.size(), decoded.size()); + for (std::size_t i = 0; i < n; ++i) { + // An unreadable/empty WAV drops just this zone (not the whole map). + if (decoded[i].monoFrames.empty()) continue; + SampleData data; + data.frames = decoded[i].monoFrames; + // 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& out, std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); +} + +// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as +// two's-complement u64, mirroring the u32 signed-int idiom above). +void putU64le(std::vector& out, std::uint64_t v) { + for (int b = 0; b < 8; ++b) out.push_back(static_cast((v >> (b * 8)) & 0xFF)); +} + +std::uint64_t asU64(std::int64_t v) { return static_cast(v); } + +// 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& bytes; + std::size_t pos = 0; + bool ok = true; + + explicit ByteReader(const std::vector& b) : bytes(b) {} + + std::uint32_t u32() { + if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; } + const std::uint32_t v = static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + pos += 4; + return v; + } + std::uint8_t u8() { + if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; } + return bytes[pos++]; + } + std::string str(std::uint32_t len) { + if (!ok || pos + len > bytes.size()) { ok = false; return {}; } + std::string s(reinterpret_cast(bytes.data() + pos), len); + pos += len; + return s; + } + // Signed ints go on the wire as u32 two's-complement (fixed 32-bit width). + int i32() { return static_cast(static_cast(u32())); } + + std::uint64_t u64() { + if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; } + std::uint64_t v = 0; + for (int b = 0; b < 8; ++b) + v |= static_cast(bytes[pos + static_cast(b)]) << (b * 8); + pos += 8; + return v; + } + // Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width). + std::int64_t i64() { return static_cast(u64()); } + + // Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields + // 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob + // as "no marker" and falls through to the (also-guarded) v1 count read. + std::uint32_t peekU32() const { + if (!ok || pos + 4 > bytes.size()) return 0; + return static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + } +}; + +// Append the zones payload — the shared body of the performance blob and the component blob, +// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion +// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail +// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes +// the zone count so any reader can detect the record shape independently of the envelope version +// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip +// through EITHER envelope with no envelope bump. +void putZonesPayload(std::vector& out, const PerformanceMap& map) { + putU32le(out, kZonesFormatMarker); + putU32le(out, kZonesPayloadVersion); + putU32le(out, static_cast(map.zones.size())); + for (const PerformanceZone& z : map.zones) { + putU32le(out, static_cast(z.sampleId.size())); + out.insert(out.end(), z.sampleId.begin(), z.sampleId.end()); + putU32le(out, static_cast(static_cast(z.lowNote))); + putU32le(out, static_cast(static_cast(z.highNote))); + out.push_back(z.rootOverride ? 1 : 0); + if (z.rootOverride) { + putU32le(out, + static_cast(static_cast(*z.rootOverride))); + } + // 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(r.i64()) / liftRate; + z.play.trigger.lengthFraction = bitsToDouble(r.u64()); + z.play.trigger.fadeInFrames = r.i64(); + z.play.trigger.fadeOutFrames = r.i64(); + z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; + z.play.pitchEnv.enabled = (r.u8() != 0); + z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / liftRate; + z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / liftRate; + z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); + } else if (secondsPlay) { + // Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source + // 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 serializePerformance(const PerformanceMap& map) { + std::vector out; + putU32le(out, kPerformanceStateVersion); + putZonesPayload(out, map); + return out; +} + +PerformanceMap deserializePerformance(const std::vector& bytes, + double projectRate) { + // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 + // blob is encountered with an invalid rate — the calller guarantees a real rate before use. + PerformanceMap map; + ByteReader r(bytes); + const std::uint32_t version = r.u32(); + 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 serializeComponentState(const ComponentState& state) { + std::vector out; + putU32le(out, kComponentStateVersion); + // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. + out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0); + // v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE + // two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that + // stops at the mode byte is a strict prefix (see the v4 lift below). + putU64le(out, asU64(state.lastConsumedAssignGeneration)); + // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — + // unlike the v1 selection blob where the id ran to end-of-stream). + putU32le(out, static_cast(state.selectionId.size())); + out.insert(out.end(), state.selectionId.begin(), state.selectionId.end()); + putZonesPayload(out, state.map); + return out; +} + +ComponentState deserializeComponentState(const std::vector& bytes, + double projectRate) { + // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. See readZonesPayload for the guard. + ComponentState out; + ByteReader r(bytes); + const std::uint32_t version = r.u32(); + 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 serializeSelection(const std::string& sampleId) { + std::vector out; + out.resize(4 + sampleId.size()); + const std::uint32_t v = kSelectionStateVersion; + out[0] = static_cast(v & 0xFF); + out[1] = static_cast((v >> 8) & 0xFF); + out[2] = static_cast((v >> 16) & 0xFF); + out[3] = static_cast((v >> 24) & 0xFF); + std::memcpy(out.data() + 4, sampleId.data(), sampleId.size()); + return out; +} + +std::string deserializeSelection(const std::vector& bytes) { + if (bytes.size() < 4) return {}; // no version tag -> no selection + const std::uint32_t v = static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8) | + (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); + if (v != kSelectionStateVersion) return {}; // unknown version -> ignore + return std::string(reinterpret_cast(bytes.data() + 4), + bytes.size() - 4); +} + +} // namespace reasampler diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h new file mode 100644 index 0000000..fac8cf4 --- /dev/null +++ b/src/vst/sample_map.h @@ -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 +#include +#include +#include + +#include "bank_book.h" // BankBook::deserialize (shared bank JSON parse) +#include "sampler_core.h" // Keymap, SampleData, SampleLoop +#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) + +namespace reasampler { + +// The bank sample this instance is bound to, distilled from the live "banks" blob: +// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank +// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet. +struct SelectedSample { + std::string relativePath; // project-relative; the shell resolves it (M4 convention) + int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty + SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty +}; + +// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks" +// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank +// project). `sampleId` is this instance's stored selection. +// +// Precedence, all pure: +// * empty / malformed banksJson -> nullopt (nothing to play) +// * sampleId 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 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 rootNote; + std::optional key; + std::string bankId; +}; +std::vector listSamples(const std::string& banksJson); + +// One bank the filter tab strip offers: its stable id + display name, in ordinal order +// (pool first). The browser prepends an "All" tab (no id) shell-side. Empty for an empty / +// malformed blob. Pure projection over the shared parse. +struct BankChoice { + std::string id; + std::string displayName; +}; +std::vector listBanks(const std::string& banksJson); + +// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields: +// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per +// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0, +// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve +// their source channel count, so a stereo (or N-channel) capture is folded to a single +// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is +// the least-surprising, no-clip default — a centered mono source stays unity, and a +// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride +// in -> empty out. Pure. +std::vector downmixToMono(const std::vector& interleaved, + int channelCount); + +// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is +// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the +// source's last channel reads the last channel, so a mono source asked for channel 1 yields +// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure. +std::vector extractChannel(const std::vector& interleaved, + int channelCount, int which); + +// --- 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 frames, int sampleRate, + int rootNote, const SampleLoop& loop, + std::vector 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 rootOverride; // instrument-owned override; absent -> bank intrinsic + std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic + std::optional startPoint; // instrument-owned initial read frame; absent -> 0 + + // S15/S16 per-zone play parameters (play mode + AHDSR + 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 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 zones; + std::vector droppedSampleIds; +}; + +// Resolve a performance map against the live "banks" ext-state blob. Pure: shared +// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank +// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride, +// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends +// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result (the shell +// then falls back to Tier-0 — see reloadFromBank). +ResolvedPerformance resolvePerformance(const std::string& banksJson, + const PerformanceMap& map); + +// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the +// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One +// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is +// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order +// is preserved so first-match overlap resolution matches the map's authored order. A zone +// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the +// map). Empty zones in -> empty Keymap (silence). +struct DecodedZonePcm { + std::vector monoFrames; // 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 framesR; // channel 1 (R); EMPTY for a mono decode +}; +Keymap buildZonedKeymap(const std::vector& zones, + const std::vector& decoded); + +// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding +// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's +// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode. +// * MONO mode -> downmix to one channel (the existing policy: average all source +// channels). framesR EMPTY. A mono or stereo source both collapse. +// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered). +// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels +// takes channels 0 and 1 (documented; the sampler's stereo image is +// the first two channels — no surround fold). +// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone +// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here. +DecodedZonePcm decodeChannels(const std::vector& interleaved, + int sourceChannels, ChannelMode mode, int sampleRate); + +// --- Performance-map instance state (VST3 setState/getState) ----------------- +// +// The performance map is the instrument's OWN state (D-B), serialized to the VST3 +// 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 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& 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 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& 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 serializeSelection(const std::string& sampleId); + +// The selected-sample id parsed back from IBStream bytes (setState). Unknown version, +// too-short, or empty -> "" (graceful no-selection). +std::string deserializeSelection(const std::vector& bytes); + +} // namespace reasampler diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp new file mode 100644 index 0000000..afb3361 --- /dev/null +++ b/src/vst/sampler_core.cpp @@ -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 + +namespace reasampler { + +// --------------------------------------------------------------------------- +// pitchRatio +// --------------------------------------------------------------------------- + +double pitchRatio(int note, int rootNote) { + // Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0. + return std::pow(2.0, static_cast(note - rootNote) / 12.0); +} + +// --------------------------------------------------------------------------- +// Keymap +// --------------------------------------------------------------------------- + +ZoneResolution Keymap::resolve(int note, int velocity) const { + (void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1. + for (std::size_t i = 0; i < zones.size(); ++i) { + const KeyZone& z = zones[i]; + if (note >= z.lowNote && note <= z.highNote) { + return ZoneResolution{true, i}; + } + } + return ZoneResolution{false, 0}; +} + +Keymap Keymap::singleSampleChromatic(SampleData sample) { + const int root = sample.rootNote; + Keymap km; + km.samples.push_back(std::move(sample)); + KeyZone zone; + zone.lowNote = 0; + zone.highNote = 127; + zone.rootNote = root; + zone.sampleIndex = 0; + km.zones.push_back(zone); + return km; +} + +// --------------------------------------------------------------------------- +// AdsrEnvelope +// --------------------------------------------------------------------------- + +void AdsrEnvelope::noteOn() { + stage_ = Stage::Attack; + level_ = 0.0; + framesInStage_ = 0; +} + +void AdsrEnvelope::noteOff() { + if (stage_ == Stage::Idle || stage_ == Stage::Finished || + stage_ == Stage::Release) { + return; // already released / not sounding. + } + // Release from the CURRENT level — release-before-sustain releases from the + // partial attack/decay level, not from sustainLevel. + releaseFrom_ = level_; + stage_ = Stage::Release; + framesInStage_ = 0; +} + +double AdsrEnvelope::tick() { + switch (stage_) { + case Stage::Idle: + case Stage::Finished: + level_ = 0.0; + return 0.0; + + case Stage::Attack: { + if (params_.attackFrames <= 0) { + level_ = 1.0; + } else { + level_ = static_cast(framesInStage_) / + static_cast(params_.attackFrames); + if (level_ > 1.0) level_ = 1.0; + } + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.attackFrames) { + // 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(framesInStage_) / + static_cast(params_.decayFrames); + level_ = 1.0 + (params_.sustainLevel - 1.0) * t; + } + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.decayFrames) { + stage_ = Stage::Sustain; + framesInStage_ = 0; + level_ = params_.sustainLevel; + } + return out; + } + + case Stage::Sustain: + level_ = params_.sustainLevel; + return level_; + + case Stage::Release: { + if (params_.releaseFrames <= 0) { + level_ = 0.0; + stage_ = Stage::Finished; + return 0.0; + } + const double t = static_cast(framesInStage_) / + static_cast(params_.releaseFrames); + level_ = releaseFrom_ * (1.0 - t); + if (level_ < 0.0) level_ = 0.0; + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.releaseFrames) { + stage_ = Stage::Finished; + level_ = 0.0; + } + return out; + } + } + return 0.0; // unreachable; silences a warning. +} + +// --------------------------------------------------------------------------- +// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function. +// --------------------------------------------------------------------------- + +void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames, + std::int64_t fadeOutFrames, FadeCurve curve) { + playLength_ = playLengthFrames > 0 ? playLengthFrames : 0; + curve_ = curve; + finished_ = (playLength_ <= 0); + + // Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A + // negative fade is treated as 0. When both fades together exceed the play length, shrink + // the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp), + // then the fade-in — never letting either go negative or the sum exceed the span. + std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0; + std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0; + if (fi > playLength_) fi = playLength_; + if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_ + fadeIn_ = fi; + fadeOut_ = fo; +} + +double TriggerEnvelope::amplitudeAt(double sourceOffset) { + if (finished_ || sourceOffset < 0.0 || + sourceOffset >= static_cast(playLength_)) { + // At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd. + if (sourceOffset >= static_cast(playLength_)) finished_ = true; + return 0.0; + } + + // Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_). + // Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length). + // The offset is fractional (the read head is fractional under repitch), so the ramps are + // smooth rather than stepped. + double amp = 1.0; + const double foStart = static_cast(playLength_ - fadeOut_); + if (fadeIn_ > 0 && sourceOffset < static_cast(fadeIn_)) { + const double phase = sourceOffset / static_cast(fadeIn_); // 0..1 + amp = (curve_ == FadeCurve::EqualPower) + ? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): 0->1 constant power + : phase; + } else if (fadeOut_ > 0 && sourceOffset >= foStart) { + const double phase = (sourceOffset - foStart) / static_cast(fadeOut_); // 0..1 + amp = (curve_ == FadeCurve::EqualPower) + ? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power + : (1.0 - phase); + } + return amp; +} + +// --------------------------------------------------------------------------- +// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled. +// --------------------------------------------------------------------------- + +double PitchEnvelope::tick() { + if (!params_.enabled) return 0.0; + + const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0; + const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0; + const double peak = params_.peakSemitones; + + double offset; + if (pos_ < a) { + // Attack: 0 -> peak over attackFrames (rise into the peak). + offset = peak * (static_cast(pos_) / static_cast(a)); + } else if (pos_ < a + d) { + // Decay: peak -> 0 over decayFrames (settle to base pitch). + const double t = static_cast(pos_ - a) / static_cast(d); + offset = peak * (1.0 - t); + } else { + offset = 0.0; // past attack+decay: at base pitch forever. + } + ++pos_; + return offset; +} + +// --------------------------------------------------------------------------- +// Voice +// --------------------------------------------------------------------------- + +void Voice::presizePreserveShifters(std::int64_t windowFrames) { + // OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs + // no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. + shiftL_.configure(windowFrames); + shiftR_.configure(windowFrames); +} + +void Voice::start(int note, int velocity, const SampleData& sample, int rootNote) { + active_ = true; + releasing_ = false; + amplitudeDone_ = false; + note_ = note; + // MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively. + int v = velocity; + if (v < 0) v = 0; + if (v > 127) v = 127; + velocityGain_ = static_cast(v) / 127.0; + baseRatio_ = pitchRatio(note, rootNote); + sample_ = &sample; + + const ZonePlayParams& p = sample.play; + playMode_ = p.playMode; + pitchEngine_ = p.pitchEngine; + + // Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp + // into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than + // starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0. + const std::int64_t frameCount = static_cast(sample.frames.size()); + std::int64_t start = sample.startFrame; + if (start < 0 || start >= frameCount) start = 0; + readPos_ = static_cast(start); + 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( + static_cast(span) * frac + 0.5); // round + if (playLen < 0) playLen = 0; + if (playLen > span) playLen = span; + playEnd_ = start + playLen; + trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames, + kDefaultFadeCurve); + } + + // --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). --- + pitchEnv_.configure(p.pitchEnv); + pitchEnv_.noteOn(); + + // --- Preserve engine (S16): reset + pre-warm the ALREADY-SIZED per-channel shifters. The + // rings were allocated off-thread by presizePreserveShifters (the engine calls it at + // construction), so this RT-safe path only zeroes state (reset) and runs a silence pass + // (warm) to settle the OLA taps before the first output frame — NO allocation here. + // Varispeed voices never touch the shifters (advanceFrame checks configured()), so a + // Varispeed instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. --- + if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { + shiftL_.reset(); + shiftL_.warm(); + if (sample.channelCount() == 2 && shiftR_.configured()) { + shiftR_.reset(); + shiftR_.warm(); + } + } + ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine. +} + +void Voice::release() { + if (!active_) return; + // TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length. + if (playMode_ == PlayMode::Trigger) return; + releasing_ = true; + env_.noteOff(); +} + +double Voice::tickAmplitude() { + double amp; + if (playMode_ == PlayMode::Gate) { + // AHDSR is wall-clock (one tick per output frame), independent of the read rate. + amp = env_.tick(); + if (env_.finished()) amplitudeDone_ = true; + } else { + // Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades + // land on the same source frames under either engine's read rate. The voice ALSO frees on + // readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders. + amp = trigEnv_.amplitudeAt(readPos_ - static_cast(startFrame_)); + if (trigEnv_.finished()) amplitudeDone_ = true; + } + return amp; +} + +AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { + // Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap, + // bracketing indices, interpolation partner) is computed ONCE and applied identically to + // every channel — only the PCM value read differs. The 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& pcm = sample_->frames; + const std::int64_t frameCount = static_cast(pcm.size()); + // Read the second channel only for a genuinely stereo sample; a mono sample plays + // dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case. + const bool haveR = stereo && sample_->channelCount() == 2; + const std::vector& pcmR = haveR ? sample_->framesR : pcm; + + // Loop-aware sustain (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(loop.end - loop.start); + while (readPos_ >= static_cast(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(playEnd_); + // Ran off the sample end with no usable loop -> voice is done. + if (triggerRanOff || readPos_ >= static_cast(frameCount)) { + active_ = false; + if (stereo) outR = 0.0f; + return 0.0f; + } + + // Linear interpolation between the two bracketing SOURCE frames. For the loop case, the + // second point wraps to loopStart so the seam is continuous. + const std::int64_t i0 = static_cast(readPos_); + const double frac = readPos_ - static_cast(i0); + 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(pcm[i0]) : 0.0) + + ((i1ok ? static_cast(pcm[i1]) : 0.0) - + (i0ok ? static_cast(pcm[i0]) : 0.0)) * frac; + double srcR = 0.0; + if (stereo) { + srcR = (i0ok ? static_cast(pcmR[i0]) : 0.0) + + ((i1ok ? static_cast(pcmR[i1]) : 0.0) - + (i0ok ? static_cast(pcmR[i0]) : 0.0)) * frac; + } + + // The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0) + // this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read + // (no per-frame transcendental), byte-identical to pre-S16. + const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0); + + double outL, outRlocal = 0.0; + if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { + // PRESERVE: read the source at unity rate (duration held) and TRANSPOSE the output by + // 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to the shift amount, not the + // read rate — pitch bends, duration unchanged (S16 contract). + const double shift = baseRatio_ * envFactor; + shiftL_.setShiftRatio(shift); + const double shiftedL = static_cast(shiftL_.process(static_cast(srcL))); + outL = shiftedL * gain; + if (stereo) { + if (shiftR_.configured()) { + // Genuine stereo: an independent shifter transposes channel 1. Each shifter is + // process()'d EXACTLY ONCE per output frame (never twice — that would advance its + // heads twice and corrupt the OLA state). + shiftR_.setShiftRatio(shift); + outRlocal = + static_cast(shiftR_.process(static_cast(srcR))) * gain; + } else { + // Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted + // value from srcL (== srcR since pcmR aliases pcm); mirror it to R. Do NOT call + // shiftL_.process again this frame. + outRlocal = shiftedL * gain; + } + } + // Preserve advances the read head at the SOURCE rate (duration preserved). + ratio_ = 1.0; + } else { + // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch + // envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the + // envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical). + outL = srcL * gain; + if (stereo) outRlocal = srcR * gain; + ratio_ = baseRatio_ * envFactor; + } + + if (stereo) outR = static_cast(outRlocal); + + readPos_ += ratio_; + + if (amplitudeDone_) { + active_ = false; + } + return static_cast(outL); +} + +AudioSample Voice::renderFrame() { + AudioSample discard = 0.0f; + return advanceFrame(/*stereo=*/false, discard); +} + +void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) { + r = 0.0f; + l = advanceFrame(/*stereo=*/true, r); +} + +// --------------------------------------------------------------------------- +// 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& 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 diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h new file mode 100644 index 0000000..3027f78 --- /dev/null +++ b/src/vst/sampler_core.h @@ -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 +#include +#include + +#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 frames; // channel 0 PCM (mono, or L of a stereo sample) + std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample + int sampleRate = 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 samples; + std::vector zones; + + // Resolves (note, velocity) to a zone. First zone (in order) whose [low,high] + // contains `note` wins. velocity is accepted now (Tier-2 seam) but does not + // affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains + // the note. + ZoneResolution resolve(int note, int velocity) const; + + // Convenience: build the Tier-0 degenerate keymap — one sample mapped + // chromatically across the whole keyboard from its own root note. + static Keymap singleSampleChromatic(SampleData sample); +}; + +// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`: +// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0, +// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed. +double pitchRatio(int note, int rootNote); + +// --------------------------------------------------------------------------- +// 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& out, std::size_t frameCount); + + // Count of currently active voices (for tests / diagnostics). + std::size_t activeVoiceCount() const; + + std::size_t maxVoices() const { return voices_.size(); } + + static constexpr std::size_t kNoVoice = static_cast(-1); + +private: + // Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen + // one per the documented policy. Always returns a valid index (maxVoices >= 1). + std::size_t allocateVoice(); + + // Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on + // (cheap: bounded by maxVoices) rather than maintained as a running tally. + std::size_t activePreserveVoices() const; + + std::vector voices_; + 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 diff --git a/src/vst/vst_entry.cpp b/src/vst/vst_entry.cpp new file mode 100644 index 0000000..66151e5 --- /dev/null +++ b/src/vst/vst_entry.cpp @@ -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 diff --git a/src/vst/waveform_view.cpp b/src/vst/waveform_view.cpp new file mode 100644 index 0000000..2b6d70c --- /dev/null +++ b/src/vst/waveform_view.cpp @@ -0,0 +1,99 @@ +// waveform_view.cpp — see waveform_view.h. Pure math; no host types. + +#include "waveform_view.h" + +#include +#include // std::abs (int overload) + +namespace reasampler::vst { + +namespace { + +std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) { + if (f < 0) return 0; + if (f > frameCount) return frameCount; + return f; +} + +} // namespace + +int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) { + const int w = std::max(0, area.width()); + if (frameCount <= 0 || w <= 0) return area.left; + const std::int64_t f = clampFrame(frame, frameCount); + // Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line + // visually centered on its frame; the divide is exact rational (multiply first). + const std::int64_t num = f * static_cast(w) + frameCount / 2; + return area.left + static_cast(num / frameCount); +} + +std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) { + const int w = std::max(0, area.width()); + if (frameCount <= 0 || w <= 0) return 0; + if (x <= area.left) return 0; + if (x >= area.right) return frameCount; + const std::int64_t dx = static_cast(x - area.left); + // Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw + // agree at bin granularity. + const std::int64_t num = dx * frameCount + static_cast(w) / 2; + return clampFrame(num / static_cast(w), frameCount); +} + +int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, + int count, int x, int y) { + if (count <= 0 || frames == nullptr) return -1; + if (!contains(area, x, y)) return -1; + for (int i = 0; i < count; ++i) { + const int mx = frameToX(area, frameCount, frames[i]); + if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i; + } + return -1; +} + +std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, + int dxPixels) { + const std::int64_t start = clampFrame(startFrame, frameCount); + if (dxPixels == 0) return start; + const int w = std::max(0, area.width()); + if (frameCount <= 0 || w <= 0) return start; // no room to move + // Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame). + const std::int64_t magnitude = + (static_cast(std::abs(dxPixels)) * frameCount + + static_cast(w) / 2) / + static_cast(w); + const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude; + return clampFrame(start + shift, frameCount); +} + +std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, + std::int64_t target) { + if (pcm == nullptr || frames < 2) return clampFrame(target, frames > 0 ? frames - 1 : 0); + // Clamp target into a valid sample index [0, frames). + std::int64_t t = target; + if (t < 0) t = 0; + if (t > frames - 1) t = frames - 1; + + // A crossing lives at frame i (1 <= i < frames) when sign(pcm[i-1]) != sign(pcm[i]) OR + // pcm[i] == 0. isCrossing(i) tests exactly that. We fan out from t: at each distance d we + // probe t-d before t+d, so an equidistant tie resolves to the LOWER frame (deterministic). + auto isCrossing = [&](std::int64_t i) -> bool { + if (i < 1 || i >= frames) return false; + const AudioSample a = pcm[i - 1]; + const AudioSample b = pcm[i]; + if (b == 0.0f) return true; // a sample on zero is its own crossing + return (a < 0.0f) != (b < 0.0f); // sign change between i-1 and i + }; + + if (isCrossing(t)) return t; + for (std::int64_t d = 1; d < frames; ++d) { + const std::int64_t lo = t - d; + if (lo >= 1 && isCrossing(lo)) return lo; // lower side wins the tie + const std::int64_t hi = t + d; + if (hi < frames && isCrossing(hi)) return hi; + // Stop once both probes have run off both ends — no crossing anywhere. + if (lo < 1 && hi >= frames) break; + } + return t; // no sign change in the whole buffer -> keep the raw (clamped) target +} + +} // namespace reasampler::vst diff --git a/src/vst/waveform_view.h b/src/vst/waveform_view.h new file mode 100644 index 0000000..4831cbf --- /dev/null +++ b/src/vst/waveform_view.h @@ -0,0 +1,83 @@ +// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11 +// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror +// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap +// arithmetic lives here, unit-tested outside the DAW, while the editor shell +// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it. +// +// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal +// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start, +// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately +// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a +// different marker set (start + %-length end + fades) without reworking the machinery. +// +// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab +// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's +// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps), +// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame. +// +// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so +// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio +// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do +// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded. + +#pragma once + +#include + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "peaks.h" // AudioSample (float), the mono PCM the snap scans + +namespace reasampler::vst { + +// The width (px) of a marker's grab region either side of its x line: a grab within this many +// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab +// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay +// distinguishable. +inline constexpr int kMarkerGrabWidth = 5; + +// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.left, +// frame frameCount -> area.right. A frame is clamped to [0, frameCount] before mapping, so an +// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a +// zero-width area pins every frame to area.left (a degenerate, non-inverting result). Pure. +int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame); + +// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to +// [0, frameCount]. A point left of area.left yields 0; right of area.right yields frameCount. +// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips +// to the same frame at bin granularity). +std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x); + +// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at +// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is +// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker +// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring +// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or +// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like +// keyboard_strip::zoneBarAtPoint. +int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, + int count, int x, int y); + +// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time +// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the +// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth), +// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to +// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag; +// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this +// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame +// for dxPixels == 0. +std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, + int dxPixels); + +// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the +// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames) +// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing +// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and +// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to +// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or +// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller +// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the +// decoded PCM the shell already holds; no host types, no file I/O. +std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, + std::int64_t target); + +} // namespace reasampler::vst diff --git a/tests/test_app_version.cpp b/tests/test_app_version.cpp index 4f05eab..1b5a312 100644 --- a/tests/test_app_version.cpp +++ b/tests/test_app_version.cpp @@ -74,6 +74,40 @@ static void testChannelDerivedIdentityStrings() { } } +static void testVstIdentityStringsForkByChannel() { + // S18: the VST3 instrument's on-disk name and display name fork from the SAME one channel + // bit as the extension's idents above. Stable values are BYTE-IDENTICAL to pre-S18 — any + // drift in the stable branch orphans a saved instance's on-disk reference / mislabels the + // FX browser. These are the accessors vst_entry.cpp's factory, the editor title, and the + // embed label all source; each branch is the assertion the OTHER config's build would fail. + if (isBeta()) { + CHECK(vstOutputName() == "reasampler_9000_beta"); + CHECK(vstPluginName() == "ReaSampler 9000 beta"); + } else { + CHECK(vstOutputName() == "reasampler_9000"); + CHECK(vstPluginName() == "ReaSampler 9000"); + } + // The on-disk name must match the CMake OUTPUT_NAME fork (REASAMPLER_VST_OUTPUT_NAME): a + // divergence between this accessor and the artifact name would ship a binary whose + // self-identification disagrees with its filename. (The CMake side is the authoritative + // artifact name; this pins the in-binary derivation to the same two literals.) + CHECK(vstOutputName() == (isBeta() ? "reasampler_9000_beta" : "reasampler_9000")); +} + +static void testVstIdentityAndDataNamespaceShareOneChannel() { + // The S18 pairing invariant at the SEAM: the VST's plugin identity (its display/output + // names) and the DATA namespace its bridge reads (extStateNamespace(), what ext_keys + // delegates to) must resolve to the SAME channel — a beta-named plugin reading the stable + // namespace, or vice versa, is precisely the split the invariant forbids. Both forks fan + // out from the one isBeta() bit, so this assertion fails if EITHER fork regressed + // independently (a beta output name paired with the stable namespace trips the beta arm). + const bool identityIsBeta = + (vstPluginName() == "ReaSampler 9000 beta") && (vstOutputName() == "reasampler_9000_beta"); + const bool dataIsBeta = (extStateNamespace() == "reasampler_beta"); + CHECK(identityIsBeta == dataIsBeta); // identity and data agree on the channel + CHECK(identityIsBeta == isBeta()); // and both agree with the compiled bit +} + static void testChannelQualifiedIdAndNameComposition() { // The two composition helpers the shells funnel through. A representative shipped id // (CAPTURE_TRACK) and phrase must compose to the exact channel-qualified strings — this @@ -205,6 +239,8 @@ int main() { testVersionConstantRendersExactString(); testChannelDerivedRendering(); testChannelDerivedIdentityStrings(); + testVstIdentityStringsForkByChannel(); + testVstIdentityAndDataNamespaceShareOneChannel(); testChannelQualifiedIdAndNameComposition(); testStampClassifiesAsStampedOnOwnChannel(); testParseWellFormed(); diff --git a/tests/test_assignment_request.cpp b/tests/test_assignment_request.cpp new file mode 100644 index 0000000..9dfe12c --- /dev/null +++ b/tests/test_assignment_request.cpp @@ -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 +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- round-trip -------------------------------------------------------------- + +static void testRoundTrip() { + AssignmentRequest req; + req.bankId = "{12345678-1234-1234-1234-1234567890AB}"; + req.sampleId = "cap-1700000000-kick.wav"; + req.generation = 1700000123; + + const std::string wire = encodeAssignmentRequest(req); + auto back = decodeAssignmentRequest(wire); + CHECK(back.has_value()); + CHECK(*back == req); + // Re-encoding the decoded value is byte-stable (deterministic encoder). + CHECK(encodeAssignmentRequest(*back) == wire); +} + +// The pool bank id and an empty-ish generation must round-trip too (generation 0 is the +// documented pre-S9 default; an assign still carries a real stamp, but 0 must be legal). +static void testRoundTripPoolAndZeroGeneration() { + AssignmentRequest req; + req.bankId = "pool"; + req.sampleId = "s1"; + req.generation = 0; + + auto back = decodeAssignmentRequest(encodeAssignmentRequest(req)); + CHECK(back.has_value()); + CHECK(back->bankId == "pool"); + CHECK(back->sampleId == "s1"); + CHECK(back->generation == 0); +} + +// Ids carrying the wire's own metacharacters (':' the length delimiter, digits that +// could be misread as a length, the magic-tag bytes) must survive whole — the whole +// reason for length-prefixing over a delimiter-split format. +static void testRoundTripAdversarialIds() { + AssignmentRequest req; + req.bankId = "12:34:has-colons"; // ':' is the length delimiter + req.sampleId = "rsassign1-lookalike-99"; // embeds the magic tag + req.generation = -42; // negative is representable + + auto back = decodeAssignmentRequest(encodeAssignmentRequest(req)); + CHECK(back.has_value()); + CHECK(*back == req); + CHECK(back->bankId == "12:34:has-colons"); + CHECK(back->sampleId == "rsassign1-lookalike-99"); + CHECK(back->generation == -42); +} + +// Empty ids are structurally valid on the wire (length 0) and must round-trip — the +// decoder must not conflate an empty field with a parse failure. +static void testRoundTripEmptyFields() { + AssignmentRequest req; + req.bankId = ""; + req.sampleId = ""; + req.generation = 7; + + auto back = decodeAssignmentRequest(encodeAssignmentRequest(req)); + CHECK(back.has_value()); + CHECK(*back == req); +} + +// A large generation (past 32-bit) must not truncate — the field is int64. +static void testLargeGeneration() { + AssignmentRequest req; + req.bankId = "b"; + req.sampleId = "s"; + req.generation = 9007199254740993LL; // > 2^53, > INT32_MAX + + auto back = decodeAssignmentRequest(encodeAssignmentRequest(req)); + CHECK(back.has_value()); + CHECK(back->generation == 9007199254740993LL); +} + +// --- malformed / tolerant parse ---------------------------------------------- + +static void testMalformedParse() { + // Absence / total garbage — the reader maps these to "no pending request". + CHECK(!decodeAssignmentRequest("").has_value()); + CHECK(!decodeAssignmentRequest("not a request").has_value()); + // Wrong magic tag. + CHECK(!decodeAssignmentRequest("rsprov1" "1:b1:s1:7").has_value()); + // Magic only, no fields. + CHECK(!decodeAssignmentRequest("rsassign1").has_value()); + // Truncated mid-record (missing the generation field). + CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s1").has_value()); + // A length that runs past the end. + CHECK(!decodeAssignmentRequest("rsassign1" "99:short").has_value()); + // Non-numeric length token. + CHECK(!decodeAssignmentRequest("rsassign1" "x:pool2:s11:7").has_value()); + // A non-numeric generation field. + CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s13:abc").has_value()); + // A bare "-" generation. + CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s11:-").has_value()); +} + +// Trailing garbage after a well-formed record must be rejected — a partial/padded blob +// is not a valid request, and the reader must never accept the prefix and ignore the rest. +static void testTrailingGarbageRejected() { + AssignmentRequest req; + req.bankId = "pool"; + req.sampleId = "s1"; + req.generation = 7; + const std::string wire = encodeAssignmentRequest(req); + + // The clean value parses. + CHECK(decodeAssignmentRequest(wire).has_value()); + // The same value with any trailing byte does not. + CHECK(!decodeAssignmentRequest(wire + "X").has_value()); + CHECK(!decodeAssignmentRequest(wire + "0:").has_value()); +} + +// --- 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; +} diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index 53aaf22..5de98db 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -36,6 +36,8 @@ static Sample fullSample(const std::string& seed) { s.captureTimeSigNum = 6; // L7 F1 meter stamp (non-4/4 to prove it round-trips) s.captureTimeSigDenom = 8; s.key = "F#m"; + s.rootNote = 60; // Phase S seam field (present) + s.loop = LoopPoints{4096, 65536}; // Phase S seam field (present) s.levels = {-0.3, -12.7, -14.2}; s.clipped = true; s.tier = Tier::Archive; @@ -86,11 +88,18 @@ static void testFullFieldRoundTrip() { CHECK(minMeter && minMeter->captureTimeSigNum == 0 && minMeter->captureTimeSigDenom == 0); CHECK(full && full->provenance.has_value()); CHECK(full && full->provenance->fxChainSnapshot == ""); + // Phase S seam fields survive round-trip exactly. + CHECK(full && full->rootNote.has_value() && *full->rootNote == 60); + CHECK(full && full->loop.has_value()); + CHECK(full && full->loop && full->loop->start == 4096 && full->loop->end == 65536); const Sample* min = back->query("min-b"); CHECK(min && !min->key.has_value()); CHECK(min && !min->provenance.has_value()); CHECK(min && min->trackGuids.empty()); + // Seam fields absent on the minimal sample and stay absent. + CHECK(min && !min->rootNote.has_value()); + CHECK(min && !min->loop.has_value()); } } @@ -417,6 +426,128 @@ static void testEnumRangeValidation() { CHECK(!r2.has_value()); } +// S2 test case 2: a legacy Sample JSON — written before the Phase S seam fields +// existed, so it has NO "rootNote" or "loop" keys at all — parses to clean empty +// optionals (no loss, no migration) and re-serializes without inventing values. +// (The parser's forward-compat unknown-key skipping is what makes the reverse case +// — new keys ignored by an old parser — safe too; here we test old-JSON→new-parser.) +static void testLegacyJsonDefaults() { + const char* legacy = + "{\"samples\":[{\"id\":\"leg1\",\"relativePath\":\"bank/leg.wav\"," + "\"displayName\":\"legacy\"," + "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":1.5,\"endSeconds\":2.5," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":2,\"sampleRate\":48000," + "\"lengthSeconds\":1.0,\"lengthBeats\":0.0,\"captureTempo\":120.0," + "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-leg1\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"; + auto r = BankIndex::deserialize(legacy); + CHECK(r.has_value()); + if (r) { + const Sample* s = r->query("leg1"); + CHECK(s != nullptr); + CHECK(s && !s->rootNote.has_value()); // clean default, not a guessed value + CHECK(s && !s->loop.has_value()); + + // Re-serialize is lossless: parsing it again yields an equal index. This + // proves the absent fields did not silently gain values on the way out. + std::string out = r->serialize(); + auto again = BankIndex::deserialize(out); + CHECK(again.has_value()); + CHECK(again && *again == *r); + if (again) { + const Sample* s2 = again->query("leg1"); + CHECK(s2 && !s2->rootNote.has_value()); + CHECK(s2 && !s2->loop.has_value()); + } + } +} + +// S2 test case 4: boundary values for the seam fields are representable and +// round-trip. rootNote 0 and 127 (the MIDI edges); loopStart == loopEnd (a valid +// zero-length marker); a loop whose end sits at the file's last frame. Also asserts +// the deserialize-boundary validation rules reject out-of-range input rather than +// storing a bogus value. +static void testSeamFieldBoundaries() { + // rootNote at both MIDI edges + equal-and-end-anchored loop points round-trip. + BankIndex idx; + Sample lo = minimalSample("lo"); lo.contentHash = "h-lo"; + lo.rootNote = 0; + lo.loop = LoopPoints{0, 0}; // zero-length marker at frame 0 + Sample hi = minimalSample("hi"); hi.contentHash = "h-hi"; + hi.rootNote = 127; + hi.loop = LoopPoints{100, 100}; // start == end elsewhere + Sample end = minimalSample("end"); end.contentHash = "h-end"; + end.loop = LoopPoints{0, 9223372036854775807LL}; // end at max int64 frame + CHECK(idx.add(lo) == AddResult::Added); + CHECK(idx.add(hi) == AddResult::Added); + CHECK(idx.add(end) == AddResult::Added); + + auto back = BankIndex::deserialize(idx.serialize()); + CHECK(back.has_value()); + CHECK(back && *back == idx); + if (back) { + CHECK(back->query("min-lo")->rootNote == 0); + CHECK(back->query("min-hi")->rootNote == 127); + // Named local: a brace-init with a comma inside CHECK(...) would be parsed + // as two macro arguments by the preprocessor. + const LoopPoints zeroLen{0, 0}; + CHECK(back->query("min-lo")->loop == zeroLen); + CHECK(back->query("min-end")->loop->end == 9223372036854775807LL); + } + + // Validation rule (chosen for this design, surfaced in the handoff): + // rootNote must be 0..127; loop must satisfy 0 <= start <= end. + // Out-of-range input is rejected at the deserialize boundary (nullopt), mirroring + // the existing enum-range and integer-overflow rejections — never clamped. + const char* head = + "{\"samples\":[{\"id\":\"bad\",\"relativePath\":\"bank/b.wav\"," + "\"displayName\":\"\",\"sourceMode\":0," + "\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100," + "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0," + "\"key\":null,"; + const char* tail = + "\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-bad\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"; + + CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value()); + CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value()); + CHECK(!BankIndex::deserialize( + std::string(head) + "\"loop\":{\"start\":10,\"end\":5}," + tail).has_value()); // start > end + CHECK(!BankIndex::deserialize( + std::string(head) + "\"loop\":{\"start\":-1,\"end\":5}," + tail).has_value()); // negative start +} + +// S2 test case 3: the seam-field addition is purely additive — dedup-by-hash, tier +// moves/filtering, and BankIndex ordering are byte-for-byte unchanged by the +// presence (or absence) of rootNote/loop. Two samples differing ONLY in seam fields +// but sharing a content hash still collapse; a seam-populated sample tiers exactly +// like any other. +static void testSeamFieldsAdditiveInvariant() { + BankIndex idx; + Sample a = fullSample("z"); // has rootNote + loop populated + CHECK(idx.add(a) == AddResult::Added); + + // Same hash, seam fields cleared — dedup keys off contentHash only, so this + // still collapses. Seam fields do NOT enter the dedup identity. + Sample dup = fullSample("z2"); + dup.contentHash = a.contentHash; + dup.rootNote.reset(); + dup.loop.reset(); + CHECK(idx.add(dup) == AddResult::Collapsed); + CHECK(idx.size() == 1); + + // Tier move on a seam-populated sample behaves exactly as before. + CHECK(idx.query("id-z")->tier == Tier::Archive); + CHECK(idx.moveTier("id-z", Tier::Scratch)); + CHECK(idx.query("id-z")->tier == Tier::Scratch); + CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields +} + int main() { testFullFieldRoundTrip(); testDedupByHash(); @@ -430,6 +561,9 @@ int main() { testUnicodeEscapeDecoding(); testIntegerOverflow(); testEnumRangeValidation(); + testLegacyJsonDefaults(); + testSeamFieldBoundaries(); + testSeamFieldsAdditiveInvariant(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; diff --git a/tests/test_bank_sync.cpp b/tests/test_bank_sync.cpp new file mode 100644 index 0000000..cdcad2a --- /dev/null +++ b/tests/test_bank_sync.cpp @@ -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 +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- parseBankGeneration ----------------------------------------------------- + +static void testParseAbsentAndMalformed() { + // Absent / empty -> generation 0 (the pre-S9 default; a project with no stamp). + CHECK(parseBankGeneration("") == 0); + CHECK(parseBankGeneration("") == kBankGenerationAbsent); + // Malformed -> 0, never a crash, never a partial value. + CHECK(parseBankGeneration("abc") == 0); + CHECK(parseBankGeneration("12x") == 0); // trailing garbage rejects whole + CHECK(parseBankGeneration("x12") == 0); // leading garbage + CHECK(parseBankGeneration("1 2") == 0); // embedded space + CHECK(parseBankGeneration("+5") == 0); // sign rejected + CHECK(parseBankGeneration("-5") == 0); // negative rejected + CHECK(parseBankGeneration(" 5") == 0); // leading space + CHECK(parseBankGeneration("5.0") == 0); // decimal point +} + +static void testParseValid() { + CHECK(parseBankGeneration("0") == 0); + CHECK(parseBankGeneration("1") == 1); + CHECK(parseBankGeneration("42") == 42); + CHECK(parseBankGeneration("00042") == 42); // leading zeros are still digits -> 42 + CHECK(parseBankGeneration("9007199254740993") == 9007199254740993LL); // > 2^53 +} + +static void testParseOverflow() { + // A 19-digit int64-max is fine; anything past it rejects to 0 (never wraps). + CHECK(parseBankGeneration("9223372036854775807") == 9223372036854775807LL); // INT64_MAX + CHECK(parseBankGeneration("9223372036854775808") == 0); // INT64_MAX + 1 -> reject + CHECK(parseBankGeneration("99999999999999999999") == 0); // 20 nines -> reject +} + +static void testFormatRoundTrip() { + CHECK(formatBankGeneration(0) == "0"); + CHECK(formatBankGeneration(1) == "1"); + CHECK(formatBankGeneration(123456789) == "123456789"); + // Round-trips: format then parse yields the original for the valid domain. + for (std::int64_t g : {std::int64_t{0}, std::int64_t{1}, std::int64_t{7}, + std::int64_t{9007199254740993LL}}) { + CHECK(parseBankGeneration(formatBankGeneration(g)) == g); + } +} + +// --- bankGenerationChanged --------------------------------------------------- + +static void testGenerationChanged() { + CHECK(!bankGenerationChanged(0, 0)); // pre-S9 idle: no stamp seen, no stamp now + CHECK(bankGenerationChanged(0, 1)); // first bump after a pre-S9 baseline -> change + CHECK(bankGenerationChanged(5, 6)); // normal increment + CHECK(!bankGenerationChanged(6, 6)); // idle poll (coalesced): no change + CHECK(bankGenerationChanged(6, 3)); // a project switch/reload can lower it -> change +} + +// --- consumeDecision --------------------------------------------------------- + +static AssignmentRequest makeReq(const std::string& bank, const std::string& sample, + std::int64_t gen) { + AssignmentRequest r; + r.bankId = bank; + r.sampleId = sample; + r.generation = gen; + return r; +} + +// Rule 1a: no pending request -> nothing to do, marker unchanged. +static void testNoRequest() { + const auto d = consumeDecision(std::nullopt, /*lastConsumed*/ 5, + /*resolves*/ true, /*isFocusedTarget*/ true); + CHECK(!d.apply); + CHECK(d.consumedGeneration == 5); // marker held +} + +// Rule 1b: a request no newer than what we already consumed (re-open case) -> no re-apply. +static void testNotNewerNotReapplied() { + // The persisted marker equals the request generation: the user already got this assign, + // possibly changed away from it. It MUST NOT re-apply on re-open. + const auto same = consumeDecision(makeReq("b", "s", 100), 100, true, true); + CHECK(!same.apply); + CHECK(same.consumedGeneration == 100); // unchanged + // An older request (a stale value lingering) is likewise ignored. + const auto older = consumeDecision(makeReq("b", "s", 90), 100, true, true); + CHECK(!older.apply); + CHECK(older.consumedGeneration == 100); +} + +// Rule 2: a NEW request but this instance is not the target -> do not apply AND do not +// advance the marker (must stay eligible if focus later lands here — no thundering herd). +static void testNonTargetStaysEligible() { + const auto d = consumeDecision(makeReq("b", "s", 200), /*lastConsumed*/ 100, + /*resolves*/ true, /*isFocusedTarget*/ false); + CHECK(!d.apply); + CHECK(d.consumedGeneration == 100); // marker NOT advanced -> still eligible later +} + +// Rule 3: a NEW request, target, but unresolvable -> DROP silently. Marker advances so it +// is never re-evaluated, but no selection change (assignment_request.h reader requirement). +static void testUnresolvableDroppedSilently() { + const auto d = consumeDecision(makeReq("b", "deleted-sample", 200), + /*lastConsumed*/ 100, /*resolves*/ false, + /*isFocusedTarget*/ true); + CHECK(!d.apply); // no selection change + CHECK(d.consumedGeneration == 200); // consumed-as-seen: never re-evaluated + CHECK(d.sampleId.empty()); // nothing to apply +} + +// Rule 4: a NEW request, target, resolvable -> APPLY selection + advance the marker. +static void testAppliedWhenNewTargetResolvable() { + const auto d = consumeDecision(makeReq("bank-7", "cap-42", 200), + /*lastConsumed*/ 100, /*resolves*/ true, + /*isFocusedTarget*/ true); + CHECK(d.apply); + CHECK(d.bankId == "bank-7"); + CHECK(d.sampleId == "cap-42"); + CHECK(d.consumedGeneration == 200); +} + +// Re-assigning the SAME sample id under a NEW generation must re-apply (the generation is +// the disambiguator; a recapture/re-drop of the same id is a fresh assign, not a no-op). +static void testSameIdNewGenerationReapplies() { + // First consume at gen 100. + const auto first = consumeDecision(makeReq("b", "s", 100), 50, true, true); + CHECK(first.apply); + CHECK(first.consumedGeneration == 100); + // Same id, higher generation, marker now at 100 -> applies again. + const auto second = consumeDecision(makeReq("b", "s", 150), 100, true, true); + CHECK(second.apply); + CHECK(second.sampleId == "s"); + CHECK(second.consumedGeneration == 150); +} + +// A fresh instance (lastConsumed == 0) applies a first assign — the default marker must not +// swallow the first request. +static void testFreshInstanceAppliesFirst() { + const auto d = consumeDecision(makeReq("b", "s", 1), 0, true, true); + CHECK(d.apply); + CHECK(d.consumedGeneration == 1); +} + +// 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; +} diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp new file mode 100644 index 0000000..8c6abbb --- /dev/null +++ b/tests/test_bridge_marshal.cpp @@ -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 + +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; +} diff --git a/tests/test_browser_scroll.cpp b/tests/test_browser_scroll.cpp new file mode 100644 index 0000000..9448f52 --- /dev/null +++ b/tests/test_browser_scroll.cpp @@ -0,0 +1,179 @@ +// Standalone tests for reasampler::vst::browser_scroll — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure editor tests: assert the S12 scroll-window + +// scrollbar-thumb + type-to-filter-search geometry LAYERED over the S10 capture_browser. +// +// Covers: scrollContentHeight (ceil rows * card height, 0 for no cards); scrollMaxOffset (0 +// when content fits, else content-visible); clampScrollOffset pinning to [0,max]; visibleCardRange +// windowing (top rows only, scrolled window, empty when scrolled past the end); scrolledCardCellRect +// shifting a cell up by the offset; scrollThumbRect (empty when it fits, proportional height + +// position, minimum height, at-max pins to the track bottom); thumbDragToOffset as the position +// inverse (a full-track drag reaches max, round-trips); searchBoxRect; nameMatchesQuery +// (case-insensitive substring, empty-query identity, no-match); filterNameIndices preserving order +// and returning every index for an empty query. + +#include "../src/vst/browser_scroll.h" + +#include +#include +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A layout wide enough for a few columns and tall enough to show a few rows. +static BrowserLayout wideLayout() { return layoutBrowser(560, 300); } + +// --- content / max / clamp ---------------------------------------------------- + +static void testContentHeight() { + const BrowserLayout L = wideLayout(); + CHECK(scrollContentHeight(L, 0) == 0); + // One card -> one row -> one card height. + CHECK(scrollContentHeight(L, 1) == kBrowserCardHeight); + // columns+1 cards -> two rows. + CHECK(scrollContentHeight(L, L.columns + 1) == 2 * kBrowserCardHeight); + // Exactly `columns` cards -> one row. + CHECK(scrollContentHeight(L, L.columns) == kBrowserCardHeight); +} + +static void testMaxOffsetFitsAndOverflows() { + const BrowserLayout L = wideLayout(); + // A single row fits within the 300px area -> no scroll. + CHECK(scrollMaxOffset(L, L.columns) == 0); + // Many rows overflow -> max = content - gridHeight. + const int many = L.columns * 20; + const int expect = scrollContentHeight(L, many) - L.grid.height(); + CHECK(scrollMaxOffset(L, many) == expect); + CHECK(expect > 0); +} + +static void testClamp() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const int maxOff = scrollMaxOffset(L, many); + CHECK(clampScrollOffset(L, many, -50) == 0); + CHECK(clampScrollOffset(L, many, maxOff + 500) == maxOff); + CHECK(clampScrollOffset(L, many, maxOff / 2) == maxOff / 2); +} + +// --- visible window ----------------------------------------------------------- + +static void testVisibleRangeTop() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const VisibleRange vr = visibleCardRange(L, many, 0); + CHECK(vr.first == 0); + // At offset 0, the last visible row is the one containing (gridH-1). + const int expectedLastRow = (L.grid.height() - 1) / kBrowserCardHeight + 1; + CHECK(vr.last == expectedLastRow * L.columns); +} + +static void testVisibleRangeScrolled() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + // Scroll one full card row down. + const VisibleRange vr = visibleCardRange(L, many, kBrowserCardHeight); + CHECK(vr.first == L.columns); // the first row scrolled off the top +} + +static void testVisibleRangeEmptyWhenNoCards() { + const BrowserLayout L = wideLayout(); + const VisibleRange vr = visibleCardRange(L, 0, 0); + CHECK(vr.first == 0 && vr.last == 0); +} + +static void testScrolledCellShiftsUp() { + const BrowserLayout L = wideLayout(); + const Rect base = cardCellRect(L, 3); + const Rect shifted = scrolledCardCellRect(L, 3, 40); + CHECK(shifted.top == base.top - 40); + CHECK(shifted.bottom == base.bottom - 40); + CHECK(shifted.left == base.left); +} + +// --- scrollbar thumb ---------------------------------------------------------- + +static void testThumbEmptyWhenFits() { + const BrowserLayout L = wideLayout(); + CHECK(scrollThumbRect(L, L.columns, 0).height() == 0); // one row fits -> no thumb +} + +static void testThumbProportionalAndClamped() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const Rect atTop = scrollThumbRect(L, many, 0); + CHECK(atTop.height() > 0); + CHECK(atTop.top == L.grid.top); // at offset 0 the thumb starts at the track top + CHECK(atTop.width() == kScrollbarWidth); + CHECK(atTop.right == L.grid.right); + // At max offset, the thumb bottom reaches the grid bottom (pinned to the end). + const int maxOff = scrollMaxOffset(L, many); + const Rect atMax = scrollThumbRect(L, many, maxOff); + CHECK(atMax.bottom == L.grid.top + L.grid.height()); +} + +static void testThumbDragIsInverse() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const int maxOff = scrollMaxOffset(L, many); + // A zero drag holds the start offset. + CHECK(thumbDragToOffset(L, many, 0, 0) == 0); + // A large positive drag pins to max; a large negative drag pins to 0. + CHECK(thumbDragToOffset(L, many, 0, 100000) == maxOff); + CHECK(thumbDragToOffset(L, many, maxOff, -100000) == 0); + // Dragging the thumb by the whole track span from top reaches (near) max. + const Rect thumb = scrollThumbRect(L, many, 0); + const int trackSpan = L.grid.height() - thumb.height(); + const int off = thumbDragToOffset(L, many, 0, trackSpan); + CHECK(off >= maxOff - 2 && off <= maxOff); +} + +// --- search ------------------------------------------------------------------- + +static void testSearchBoxRect() { + const Rect r = searchBoxRect(200); + CHECK(r.left == 0 && r.top == 0 && r.right == 200 && r.height() == kSearchBoxHeight); + CHECK(searchBoxRect(0).width() == 0); +} + +static void testNameMatch() { + CHECK(nameMatchesQuery("Kick Drum 01", "")); // empty query matches all + CHECK(nameMatchesQuery("Kick Drum 01", "drum")); // case-insensitive substring + CHECK(nameMatchesQuery("Kick Drum 01", "KICK")); + CHECK(!nameMatchesQuery("Kick Drum 01", "snare")); + CHECK(!nameMatchesQuery("ab", "abc")); // query longer than name +} + +static void testFilterIndices() { + std::vector names{"Kick", "Snare", "Kick Sub", "Hat"}; + // Empty query -> every index, in order. + const std::vector all = filterNameIndices(names, ""); + CHECK(all.size() == 4 && all[0] == 0 && all[3] == 3); + // "kick" -> indices 0 and 2, order preserved. + const std::vector kicks = filterNameIndices(names, "kick"); + CHECK(kicks.size() == 2 && kicks[0] == 0 && kicks[1] == 2); + // No match -> empty. + CHECK(filterNameIndices(names, "zzz").empty()); +} + +int main() { + testContentHeight(); + testMaxOffsetFitsAndOverflows(); + testClamp(); + testVisibleRangeTop(); + testVisibleRangeScrolled(); + testVisibleRangeEmptyWhenNoCards(); + testScrolledCellShiftsUp(); + testThumbEmptyWhenFits(); + testThumbProportionalAndClamped(); + testThumbDragIsInverse(); + testSearchBoxRect(); + testNameMatch(); + testFilterIndices(); + + if (g_fail == 0) std::printf("browser_scroll: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_capture_browser.cpp b/tests/test_capture_browser.cpp new file mode 100644 index 0000000..9f8a7a0 --- /dev/null +++ b/tests/test_capture_browser.cpp @@ -0,0 +1,203 @@ +// Standalone tests for reasampler::vst::capture_browser — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests (embed_strip / editor_geometry): assert +// the capture-first browser's card-grid + bank-filter-tab layout and hit-testing directly. +// +// Covers: layoutBrowser splitting an area into the tab strip + card grid and deriving the +// column count; a tiny/zero area (no inversion, columns >= 1); cardCellRect / cardContentRect +// / cardThumbnailRect / cardLabelRect tiling row-major across columns with the gutter inset +// and the thumbnail band above the label; cardHitTest landing on the card content (and MISSING +// in the inter-card gutter, past the last card, and on the tab strip); filterTabRect dividing +// the strip into equal segments with the last tab absorbing the remainder; filterTabHitTest +// hitting each tab and missing off-strip. + +#include "../src/vst/capture_browser.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- layoutBrowser ------------------------------------------------------------ + +static void testLayoutNormalArea() { + // Wide enough for several columns of the fixed-width card. + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(L.tabStrip.left == 0 && L.tabStrip.top == 0 && L.tabStrip.right == 560); + CHECK(L.tabStrip.height() == kBrowserTabHeight); + // The grid starts right below the tab strip and fills the rest, contiguous. + CHECK(L.grid.top == L.tabStrip.bottom); + CHECK(L.grid.bottom == 300 && L.grid.right == 560); + // columns = grid.width() / cardWidth (>= 1). + CHECK(L.columns == 560 / kBrowserCardWidth); + CHECK(L.columns >= 1); +} + +static void testLayoutNarrowAreaSingleColumn() { + // Narrower than one card: still a single column, no inversion. + const BrowserLayout L = layoutBrowser(kBrowserCardWidth - 10, 200); + CHECK(L.columns == 1); + CHECK(L.grid.width() >= 0); + CHECK(L.tabStrip.height() == kBrowserTabHeight); +} + +static void testLayoutZeroArea() { + const BrowserLayout L = layoutBrowser(0, 0); + CHECK(L.tabStrip.width() == 0 && L.tabStrip.height() == 0); + CHECK(L.grid.width() == 0); + CHECK(L.columns == 1); // never zero (avoids a divide-by-zero in card layout) +} + +static void testLayoutTinyHeightClampsTabStrip() { + // A height below the tab band: the tab strip clamps to the area, the grid is empty. + const BrowserLayout L = layoutBrowser(560, kBrowserTabHeight - 6); + CHECK(L.tabStrip.height() == kBrowserTabHeight - 6); + CHECK(L.grid.height() <= 0); // no room left for cards +} + +// --- card rects --------------------------------------------------------------- + +static void testCardCellsTileRowMajor() { + const BrowserLayout L = layoutBrowser(560, 300); + const int cols = L.columns; + // Card 0 is top-left of the grid. + const Rect c0 = cardCellRect(L, 0); + CHECK(c0.left == L.grid.left && c0.top == L.grid.top); + CHECK(c0.width() == kBrowserCardWidth && c0.height() == kBrowserCardHeight); + // Card 1 is one card-width to the right, same row. + const Rect c1 = cardCellRect(L, 1); + CHECK(c1.left == L.grid.left + kBrowserCardWidth); + CHECK(c1.top == c0.top); + // The first card of the SECOND row wraps back to the left, one card-height down. + const Rect wrap = cardCellRect(L, cols); + CHECK(wrap.left == L.grid.left); + CHECK(wrap.top == L.grid.top + kBrowserCardHeight); +} + +static void testCardCellNegativeIndex() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect r = cardCellRect(L, -1); + CHECK(r.left == 0 && r.top == 0 && r.right == 0 && r.bottom == 0); +} + +static void testCardContentInsetByGutter() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect cell = cardCellRect(L, 0); + const Rect content = cardContentRect(L, 0); + CHECK(content.left == cell.left + kBrowserCardGutter); + CHECK(content.top == cell.top + kBrowserCardGutter); + CHECK(content.right == cell.right - kBrowserCardGutter); + CHECK(content.bottom == cell.bottom - kBrowserCardGutter); +} + +static void testThumbnailAboveLabel() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect content = cardContentRect(L, 0); + const Rect thumb = cardThumbnailRect(L, 0); + const Rect label = cardLabelRect(L, 0); + // Thumbnail is the top band of the content; the label is the remainder below it, contiguous. + CHECK(thumb.left == content.left && thumb.right == content.right); + CHECK(thumb.top == content.top); + CHECK(thumb.height() == kBrowserThumbHeight); + CHECK(label.top == thumb.bottom); + CHECK(label.bottom == content.bottom); + CHECK(label.left == content.left && label.right == content.right); +} + +// --- cardHitTest -------------------------------------------------------------- + +static void testCardHitCenterOfCard() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect content = cardContentRect(L, 3); + const int cx = content.left + content.width() / 2; + const int cy = content.top + content.height() / 2; + CHECK(cardHitTest(L, 12, cx, cy) == 3); +} + +static void testCardHitMissesGutter() { + const BrowserLayout L = layoutBrowser(560, 300); + // A point in the gutter between the content and the cell edge (top-left corner of cell 0) + // is a miss — only the card CONTENT counts. + const Rect cell = cardCellRect(L, 0); + CHECK(cardHitTest(L, 12, cell.left, cell.top) == -1); +} + +static void testCardHitMissesPastLastCard() { + const BrowserLayout L = layoutBrowser(560, 300); + // Only 2 cards exist; a point on where card 5 WOULD be is a miss. + const Rect content = cardContentRect(L, 5); + const int cx = content.left + content.width() / 2; + const int cy = content.top + content.height() / 2; + CHECK(cardHitTest(L, 2, cx, cy) == -1); +} + +static void testCardHitMissesTabStrip() { + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(cardHitTest(L, 12, 10, L.tabStrip.top + 2) == -1); +} + +static void testCardHitZeroCards() { + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(cardHitTest(L, 0, 20, 40) == -1); +} + +// --- filter tabs -------------------------------------------------------------- + +static void testFilterTabsTileStrip() { + const BrowserLayout L = layoutBrowser(560, 300); + const int n = 4; // "All" + 3 banks + const Rect t0 = filterTabRect(L, n, 0); + const Rect tLast = filterTabRect(L, n, n - 1); + CHECK(t0.left == L.tabStrip.left); + // Adjacent tabs share an exact edge (no gap). + CHECK(filterTabRect(L, n, 0).right == filterTabRect(L, n, 1).left); + CHECK(filterTabRect(L, n, 1).right == filterTabRect(L, n, 2).left); + // The last tab reaches the strip's right edge exactly (absorbs the remainder). + CHECK(tLast.right == L.tabStrip.right); + // All tabs share the strip's height. + CHECK(t0.top == L.tabStrip.top && t0.bottom == L.tabStrip.bottom); +} + +static void testFilterTabOutOfRange() { + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(filterTabRect(L, 3, -1).width() == 0); + CHECK(filterTabRect(L, 3, 3).width() == 0); + CHECK(filterTabRect(L, 0, 0).width() == 0); +} + +static void testFilterTabHit() { + const BrowserLayout L = layoutBrowser(560, 300); + const int n = 3; + for (int i = 0; i < n; ++i) { + const Rect t = filterTabRect(L, n, i); + const int cx = t.left + t.width() / 2; + const int cy = t.top + t.height() / 2; + CHECK(filterTabHitTest(L, n, cx, cy) == i); + } + // Below the strip (in the grid) -> no tab. + CHECK(filterTabHitTest(L, n, 20, L.grid.top + 4) == -1); +} + +int main() { + testLayoutNormalArea(); + testLayoutNarrowAreaSingleColumn(); + testLayoutZeroArea(); + testLayoutTinyHeightClampsTabStrip(); + testCardCellsTileRowMajor(); + testCardCellNegativeIndex(); + testCardContentInsetByGutter(); + testThumbnailAboveLabel(); + testCardHitCenterOfCard(); + testCardHitMissesGutter(); + testCardHitMissesPastLastCard(); + testCardHitMissesTabStrip(); + testCardHitZeroCards(); + testFilterTabsTileStrip(); + testFilterTabOutOfRange(); + testFilterTabHit(); + + if (g_fail == 0) std::printf("capture_browser: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_drag_out.cpp b/tests/test_drag_out.cpp index a02af47..ac8420a 100644 --- a/tests/test_drag_out.cpp +++ b/tests/test_drag_out.cpp @@ -84,6 +84,54 @@ static void testOffsetPanelRect() { CHECK(decideGesture(100, 19, p, s) == DragGesture::OsDrag); // just above origin } +// --- S17 InstrumentDrop gesture (single-capture over REAPER UI) --------------- +// +// M11 REGRESSION GUARD (load-bearing): every M11 case above uses DragState{true, true}, +// which leaves singleCapture=overReaperUi=false — so an M11-era payload outside the client +// rect still decides OsDrag exactly as before. The tests above ARE the M11 non-regression +// proof; these add the new middle case. + +// A SINGLE-capture drag that has left the panel but is still over REAPER's own UI is an +// instrument drop (heading for a track's FX button), NOT an OS drag. +static void testSingleCaptureOverReaperUiIsInstrumentDrop() { + DragState s{/*dragging=*/true, /*hasArmedSamples=*/true, + /*singleCapture=*/true, /*overReaperUi=*/true}; + CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::InstrumentDrop); // right of panel + CHECK(decideGesture(-5, 150, kPanel, s) == DragGesture::InstrumentDrop); // left of panel + CHECK(decideGesture(200, 400, kPanel, s) == DragGesture::InstrumentDrop); // below +} + +// InstrumentDrop is an OUTSIDE-only refinement: the same single-capture state INSIDE the +// client rect is still the unchanged Internal bank-to-bank drag (invariant #4). +static void testSingleCaptureInsidePanelStaysInternal() { + DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/true}; + CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal); +} + +// A single-capture drag that has left REAPER ENTIRELY (overReaperUi=false) falls through to +// OsDrag — the M11 OS drag-out to Explorer/another DAW, unchanged. This is the boundary +// refinement's other half: leaving the client rect no longer immediately means OS-bound. +static void testSingleCaptureOffReaperIsOsDrag() { + DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/false}; + CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag); +} + +// A MULTI-capture drag over REAPER's UI is REJECTED for InstrumentDrop (the S17 open-question +// lean): it is NOT a single instrument placement, so it falls through to OsDrag even while +// over REAPER's UI — the multi-file drag-out is the natural gesture for a multi payload. +static void testMultiCaptureOverReaperUiIsOsDrag() { + DragState s{true, true, /*singleCapture=*/false, /*overReaperUi=*/true}; + CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag); +} + +// Not-dragging / no-armed-samples still short-circuits to None regardless of the S17 fields. +static void testS17FieldsIgnoredWhenNotDragging() { + CHECK(decideGesture(500, 150, kPanel, + DragState{false, true, true, true}) == DragGesture::None); + CHECK(decideGesture(500, 150, kPanel, + DragState{true, false, true, true}) == DragGesture::None); +} + // --- Path-list assembly ------------------------------------------------------- static ResolvedSample ok(const std::string& p) { return ResolvedSample{p, true}; } @@ -180,6 +228,12 @@ int main() { testReentryReturnsInternal(); testOffsetPanelRect(); + testSingleCaptureOverReaperUiIsInstrumentDrop(); + testSingleCaptureInsidePanelStaysInternal(); + testSingleCaptureOffReaperIsOsDrag(); + testMultiCaptureOverReaperUiIsOsDrag(); + testS17FieldsIgnoredWhenNotDragging(); + testSinglePath(); testMultiPreservesOrder(); testDedupeSamePath(); diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp new file mode 100644 index 0000000..2d363bf --- /dev/null +++ b/tests/test_editor_geometry.cpp @@ -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 + +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; +} diff --git a/tests/test_embed_strip.cpp b/tests/test_embed_strip.cpp new file mode 100644 index 0000000..4ce3c22 --- /dev/null +++ b/tests/test_embed_strip.cpp @@ -0,0 +1,157 @@ +// Standalone tests for reasampler::vst::embed_strip — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests (editor_geometry et al.): assert the +// embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly. +// +// Covers: layoutEmbed splitting a normal area into keymap + level band, a tiny area +// (band yields to the keymap minimum, no inversion), and a zero area (all empty); +// zoneSegmentRect mapping the 128-key span linearly, tiling adjacent zones seamlessly, +// clamping out-of-range/inverted notes; zoneAtPoint hitting the covering zone, first-match +// on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list; +// levelFillRect clamping 0..1 and its endpoints. + +#include "../src/vst/embed_strip.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- layoutEmbed -------------------------------------------------------------- + +static void testLayoutNormalArea() { + // A comfortable inline strip: keymap band on top, thin level band pinned to the bottom. + const EmbedLayout L = layoutEmbed(300, 40); + CHECK(L.keymap.left == 0 && L.keymap.top == 0 && L.keymap.right == 300); + CHECK(L.levelBand.left == 0 && L.levelBand.right == 300); + // Level band is the fixed height at the very bottom; keymap fills the rest, contiguous. + CHECK(L.levelBand.height() == kEmbedLevelBandHeight); + CHECK(L.levelBand.bottom == 40); + CHECK(L.keymap.bottom == L.levelBand.top); + CHECK(L.keymap.height() == 40 - kEmbedLevelBandHeight); +} + +static void testLayoutTinyAreaKeepsKeymap() { + // A very short area: the level band must yield so the keymap keeps its minimum, and no + // rect inverts. + const EmbedLayout L = layoutEmbed(300, 8); + CHECK(L.keymap.height() >= 0); + CHECK(L.levelBand.height() >= 0); + CHECK(L.keymap.bottom == L.levelBand.top); + CHECK(L.levelBand.bottom == 8); + // The keymap is not starved below its floor when the area allows it. + CHECK(L.keymap.height() >= kEmbedKeymapMinHeight || 8 < kEmbedKeymapMinHeight); +} + +static void testLayoutZeroArea() { + const EmbedLayout L = layoutEmbed(0, 0); + CHECK(L.keymap.width() <= 0 && L.keymap.height() <= 0); + CHECK(L.levelBand.width() <= 0 && L.levelBand.height() <= 0); + // Negative dimensions clamp to a zero-area, non-inverted rect. + const EmbedLayout N = layoutEmbed(-50, -50); + CHECK(N.keymap.right >= N.keymap.left && N.keymap.bottom >= N.keymap.top); +} + +// --- zoneSegmentRect ---------------------------------------------------------- + +static void testZoneSegmentFullSpan() { + // A zone covering the whole keyboard spans the entire keymap band width. + const EmbedLayout L = layoutEmbed(256, 40); + const Rect r = zoneSegmentRect(L, 0, 127); + CHECK(r.left == L.keymap.left); + CHECK(r.right == L.keymap.right); + CHECK(r.top == L.keymap.top && r.bottom == L.keymap.bottom); +} + +static void testAdjacentZonesTileSeamlessly() { + // 256px band, 128 keys -> 2px/key. Zones 0..59 and 60..127 must abut with no gap or + // overlap: the low zone's right == the high zone's left. + const EmbedLayout L = layoutEmbed(256, 40); + const Rect lo = zoneSegmentRect(L, 0, 59); + const Rect hi = zoneSegmentRect(L, 60, 127); + CHECK(lo.left == L.keymap.left); + CHECK(hi.right == L.keymap.right); + CHECK(lo.right == hi.left); // seamless tile — the load-bearing assertion + CHECK(lo.right == L.keymap.left + 60 * 2); // 60 keys * 2px +} + +static void testZoneSegmentClampsBadNotes() { + const EmbedLayout L = layoutEmbed(256, 40); + // Out-of-range notes clamp into the band; an inverted zone (low > high) collapses to a + // zero-or-positive-width rect, never inverts. + const Rect over = zoneSegmentRect(L, -10, 200); + CHECK(over.left == L.keymap.left && over.right == L.keymap.right); + const Rect inv = zoneSegmentRect(L, 100, 20); + CHECK(inv.right >= inv.left); +} + +// --- zoneAtPoint -------------------------------------------------------------- + +static void testZoneAtPointHits() { + const EmbedLayout L = layoutEmbed(256, 40); + const EmbedZone zones[2] = {{0, 59}, {60, 127}}; + // A point inside the low zone's segment resolves to zone 0; inside the high zone, 1. + const Rect lo = zoneSegmentRect(L, 0, 59); + const Rect hi = zoneSegmentRect(L, 60, 127); + const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + CHECK(zoneAtPoint(L, zones, 2, lo.left + 1, yMid) == 0); + CHECK(zoneAtPoint(L, zones, 2, hi.right - 1, yMid) == 1); +} + +static void testZoneAtPointFirstMatchOnOverlap() { + const EmbedLayout L = layoutEmbed(256, 40); + // Two overlapping zones; the FIRST in order must win the contested keys. + const EmbedZone zones[2] = {{0, 127}, {40, 80}}; + const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + const Rect contested = zoneSegmentRect(L, 40, 80); + CHECK(zoneAtPoint(L, zones, 2, contested.left + 1, yMid) == 0); // zone 0 wins +} + +static void testZoneAtPointMisses() { + const EmbedLayout L = layoutEmbed(256, 40); + const EmbedZone zones[1] = {{60, 72}}; // a narrow zone; most keys uncovered + const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + // A key left of the zone is uncovered -> -1. + CHECK(zoneAtPoint(L, zones, 1, L.keymap.left + 1, yMid) == -1); + // A point in the level band (below the keymap) is off the keymap -> -1. + CHECK(zoneAtPoint(L, zones, 1, L.levelBand.left + 4, L.levelBand.top) == -1); + // Empty / null list -> -1. + CHECK(zoneAtPoint(L, zones, 0, L.keymap.left + 1, yMid) == -1); + CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.left + 1, yMid) == -1); +} + +// --- levelFillRect ------------------------------------------------------------ + +static void testLevelFillClamps() { + const EmbedLayout L = layoutEmbed(200, 40); + // Zero / negative -> empty. + CHECK(levelFillRect(L, 0.0).width() <= 0); + CHECK(levelFillRect(L, -1.0).width() <= 0); + // Full / over-full -> the whole band width. + CHECK(levelFillRect(L, 1.0).width() == L.levelBand.width()); + CHECK(levelFillRect(L, 5.0).width() == L.levelBand.width()); + // Half -> ~half the band, pinned to the band's left and vertical extent. + const Rect half = levelFillRect(L, 0.5); + CHECK(half.left == L.levelBand.left); + CHECK(half.top == L.levelBand.top && half.bottom == L.levelBand.bottom); + CHECK(half.width() == L.levelBand.width() / 2); +} + +int main() { + testLayoutNormalArea(); + testLayoutTinyAreaKeepsKeymap(); + testLayoutZeroArea(); + testZoneSegmentFullSpan(); + testAdjacentZonesTileSeamlessly(); + testZoneSegmentClampsBadNotes(); + testZoneAtPointHits(); + testZoneAtPointFirstMatchOnOverlap(); + testZoneAtPointMisses(); + testLevelFillClamps(); + + if (g_fail == 0) std::printf("embed_strip: all tests passed\n"); + else std::printf("embed_strip: %d FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_instrument_drop.cpp b/tests/test_instrument_drop.cpp new file mode 100644 index 0000000..79bbcab --- /dev/null +++ b/tests/test_instrument_drop.cpp @@ -0,0 +1,119 @@ +// Standalone tests for reasampler::instrument_drop — no REAPER, no VST3 SDK, no framework. +// The S17 drop-and-load blob-construction contract: the extension builds a vst_chunk blob +// whose bytes are EXACTLY what ReaSampler 9000's own setState (deserializeComponentState) +// accepts, with the dragged capture pre-selected. The round-trip proof (build -> base64 +// decode -> the instrument's OWN reader -> assert the capture selected) IS the cross-artifact +// contract guard — the same pattern assignment_request_tests uses for its wire format. + +#include "../src/instrument_drop.h" +#include "../src/vst/sample_map.h" // deserializeComponentState — the instrument's OWN reader + +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A representative project rate for the reader (the legacy-v3 conversion parameter; our v5 +// blob never consumes it, but the reader signature requires a positive rate). +static constexpr double kRate = 48000.0; + +// THE contract test: a blob built for a capture id decodes — through the instrument's OWN +// reader — to a ComponentState with THAT id selected, no zones, default mono. If this fails, +// the extension would inject bytes the instrument's setState rejects and the drop would load +// a silent/wrong instance. +static void testBlobRoundTripsThroughInstrumentReader() { + const std::string id = "cap-7f3a-guid"; + const std::string b64 = buildInstrumentDropChunk(id); + CHECK(!b64.empty()); + + const std::vector bytes = decodeBase64(b64); + CHECK(!bytes.empty()); + // The base64 must decode to EXACTLY the pre-encode state bytes (no corruption). + CHECK(bytes == instrumentDropStateBytes(id)); + + const ComponentState cs = deserializeComponentState(bytes, kRate); + CHECK(cs.selectionId == id); // the capture IS selected — the whole point + CHECK(cs.map.zones.empty()); // a drop selects one capture, authors no zones + CHECK(cs.channelMode == ChannelMode::Mono); // fresh-instance default + CHECK(cs.lastConsumedAssignGeneration == 0); // fresh instance, no consumed assign +} + +// A GUID-shaped id with bytes that would trip a naive delimiter-based encoder round-trips +// whole (the length-prefixed component-state framing + base64 carry arbitrary bytes). +static void testGuidLikeIdRoundTrips() { + const std::string id = "{9A2F0C11-4B6E-4D01-8F3A-0011223344FF}"; + const std::vector bytes = decodeBase64(buildInstrumentDropChunk(id)); + const ComponentState cs = deserializeComponentState(bytes, kRate); + CHECK(cs.selectionId == id); +} + +// An empty id yields the empty-state blob: it still decodes cleanly to {"", no zones} — the +// S10 silent empty state. (The shell guards against dropping nothing; the pure contract holds.) +static void testEmptyIdYieldsEmptyState() { + const std::vector bytes = decodeBase64(buildInstrumentDropChunk("")); + CHECK(!bytes.empty()); // still a versioned envelope, just an empty selection + const ComponentState cs = deserializeComponentState(bytes, kRate); + CHECK(cs.selectionId.empty()); + CHECK(cs.map.zones.empty()); +} + +// Deterministic: the same id always produces the same blob (no time/random in the path). +static void testDeterministic() { + CHECK(buildInstrumentDropChunk("abc") == buildInstrumentDropChunk("abc")); + CHECK(buildInstrumentDropChunk("abc") != buildInstrumentDropChunk("abd")); +} + +// --- base64 codec unit coverage (the encode side the shell actually ships) ----- + +static std::vector b(std::initializer_list v) { + std::vector out; + for (int x : v) out.push_back(static_cast(x)); + return out; +} + +// Known RFC-4648 vectors, incl. every padding case (0/1/2 trailing bytes). +static void testBase64KnownVectors() { + CHECK(encodeBase64(b({})) == ""); + CHECK(encodeBase64(b({'f'})) == "Zg=="); + CHECK(encodeBase64(b({'f', 'o'})) == "Zm8="); + CHECK(encodeBase64(b({'f', 'o', 'o'})) == "Zm9v"); + CHECK(encodeBase64(b({'f', 'o', 'o', 'b'})) == "Zm9vYg=="); + CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a'})) == "Zm9vYmE="); + CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a', 'r'})) == "Zm9vYmFy"); +} + +// encode -> decode is identity across every residue class + all-byte values. +static void testBase64RoundTripAllBytes() { + for (int len = 0; len <= 300; ++len) { + std::vector in; + for (int i = 0; i < len; ++i) in.push_back(static_cast((i * 37 + 11) & 0xFF)); + CHECK(decodeBase64(encodeBase64(in)) == in); + } +} + +// Malformed decode inputs return empty (never throw / never UB): bad length, illegal char, +// misplaced padding. +static void testBase64DecodeRejectsMalformed() { + CHECK(decodeBase64("Zg=").empty()); // length not a multiple of 4 + CHECK(decodeBase64("Zm9v!ba=").empty()); // illegal char '!' + CHECK(decodeBase64("Z===").empty()); // illegal char in v1 position + CHECK(decodeBase64("Zg==Zg==").empty()); // interior padding (pad before the final quad) +} + +int main() { + testBlobRoundTripsThroughInstrumentReader(); + testGuidLikeIdRoundTrips(); + testEmptyIdYieldsEmptyState(); + testDeterministic(); + testBase64KnownVectors(); + testBase64RoundTripAllBytes(); + testBase64DecodeRejectsMalformed(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp new file mode 100644 index 0000000..65e6880 --- /dev/null +++ b/tests/test_keyboard_strip.cpp @@ -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 + +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; +} diff --git a/tests/test_note_entry.cpp b/tests/test_note_entry.cpp new file mode 100644 index 0000000..4990001 --- /dev/null +++ b/tests/test_note_entry.cpp @@ -0,0 +1,73 @@ +// Standalone tests for reasampler::vst::note_entry — no VST3, no REAPER, no framework. +// Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note. +// +// Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the +// C4==60 convention (C-1==0, sharps + flats, negative octaves); out-of-range values CLAMPING to +// [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt; +// the integer path taking precedence over the note-name path for a leading digit. + +#include "../src/vst/note_entry.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static void testPlainIntegers() { + CHECK(parseNoteEntry("60") == 60); + CHECK(parseNoteEntry("0") == 0); + CHECK(parseNoteEntry("127") == 127); + CHECK(parseNoteEntry(" 64 ") == 64); // surrounding whitespace ignored + CHECK(parseNoteEntry("+5") == 5); +} + +static void testIntegerClamps() { + CHECK(parseNoteEntry("200") == 127); // over-range clamps to the ceiling + CHECK(parseNoteEntry("-10") == 0); // under-range clamps to the floor + CHECK(parseNoteEntry("99999") == 127); +} + +static void testNoteNames() { + // C4 == 60 (MIDI 0 == C-1). + CHECK(parseNoteEntry("C4") == 60); + CHECK(parseNoteEntry("c4") == 60); // case-insensitive + CHECK(parseNoteEntry("A4") == 69); // A4 = 69 (concert A) + CHECK(parseNoteEntry("C-1") == 0); // lowest MIDI note + CHECK(parseNoteEntry("G9") == 127); // G9 = 127 +} + +static void testAccidentals() { + CHECK(parseNoteEntry("C#4") == 61); + CHECK(parseNoteEntry("Db4") == 61); // enharmonic of C#4 + CHECK(parseNoteEntry("F#3") == 54); + CHECK(parseNoteEntry("Bb3") == 58); // Bb3 = 58 +} + +static void testNoteNameClamps() { + CHECK(parseNoteEntry("C10") == 127); // above the range clamps + CHECK(parseNoteEntry("C-5") == 0); // below the range clamps +} + +static void testRejects() { + CHECK(parseNoteEntry("") == std::nullopt); + CHECK(parseNoteEntry(" ") == std::nullopt); + CHECK(parseNoteEntry("hello") == std::nullopt); + CHECK(parseNoteEntry("C") == std::nullopt); // a bare letter with no octave is ambiguous + CHECK(parseNoteEntry("H4") == std::nullopt); // H is not a note letter + CHECK(parseNoteEntry("+") == std::nullopt); +} + +int main() { + testPlainIntegers(); + testIntegerClamps(); + testNoteNames(); + testAccidentals(); + testNoteNameClamps(); + testRejects(); + + if (g_fail == 0) std::printf("note_entry: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_param_slider.cpp b/tests/test_param_slider.cpp new file mode 100644 index 0000000..7887c9d --- /dev/null +++ b/tests/test_param_slider.cpp @@ -0,0 +1,204 @@ +// Standalone tests for reasampler::vst::param_slider — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure editor tests (capture_browser / keyboard_strip): +// assert the S12/S15/S16 control-surface layout, toggle-segment split + hit-test, slider +// value<->pixel mapping (round-trip + clamping + endpoints), and point->control routing. +// +// Covers: layoutControls stacking rows top-down with the label column + control column and the +// inter-row gap; an empty list / degenerate panel yielding nothing; toggleSegmentRect splitting +// a toggle into two tiling segments (last absorbs the remainder) + toggleSegmentHitTest; +// sliderTrackRect insetting a half-handle at each end; sliderHandleRect at value 0/0.5/1 and +// out-of-range clamping; valueAtPoint mapping x back to 0..1 (endpoints saturate) as the inverse +// of the handle position; controlAtPoint routing a point to the right control id (toggle whole +// area vs slider track) and MISSING in the label column, a row gap, and off-panel. + +#include "../src/vst/param_slider.h" + +#include +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static bool approx(double a, double b) { return (a - b) < 1e-9 && (b - a) < 1e-9; } + +// --- layoutControls ----------------------------------------------------------- + +static void testLayoutStacksRows() { + const Rect panel{0, 100, 300, 400}; + std::vector ctl{ + {1, ControlKind::Toggle}, + {2, ControlKind::Slider}, + {3, ControlKind::Slider}, + }; + const std::vector rows = layoutControls(panel, ctl); + CHECK(rows.size() == 3); + // Row 0 sits at the panel top; each subsequent row is one row-height + gap below. + CHECK(rows[0].row.top == 100); + CHECK(rows[0].row.bottom == 100 + kControlRowHeight); + CHECK(rows[1].row.top == rows[0].row.bottom + kControlRowGap); + CHECK(rows[2].row.top == rows[1].row.bottom + kControlRowGap); + // Ids + kinds carried through in order. + CHECK(rows[0].id == 1 && rows[0].kind == ControlKind::Toggle); + CHECK(rows[1].id == 2 && rows[1].kind == ControlKind::Slider); + // Label column then control column, contiguous, spanning the panel width. + CHECK(rows[0].label.left == panel.left); + CHECK(rows[0].control.left == rows[0].label.right); + CHECK(rows[0].control.right == panel.right); + CHECK(rows[0].label.width() == kControlLabelWidth); +} + +static void testLayoutEmptyAndDegenerate() { + CHECK(layoutControls(Rect{0, 0, 300, 300}, {}).empty()); + std::vector ctl{{1, ControlKind::Slider}}; + CHECK(layoutControls(Rect{0, 0, 0, 0}, ctl).empty()); + CHECK(layoutControls(Rect{0, 0, 300, 0}, ctl).empty()); +} + +static void testLayoutNarrowPanelClampsLabel() { + // A panel narrower than 2*labelWidth clamps the label column to half so a control column + // survives. + const Rect panel{0, 0, 100, 200}; + const std::vector rows = layoutControls(panel, {{1, ControlKind::Slider}}); + CHECK(rows.size() == 1); + CHECK(rows[0].label.width() <= panel.width() / 2 + 1); + CHECK(rows[0].control.width() > 0); +} + +// --- toggle ------------------------------------------------------------------- + +static void testToggleSegmentsTile() { + const Rect control{100, 0, 300, 22}; // width 200 + const Rect s0 = toggleSegmentRect(control, 0); + const Rect s1 = toggleSegmentRect(control, 1); + CHECK(s0.left == 100 && s0.right == 200); + CHECK(s1.left == 200 && s1.right == 300); // last absorbs remainder -> reaches control.right + // Out of range. + CHECK(toggleSegmentRect(control, 2).width() == 0); + CHECK(toggleSegmentRect(control, -1).width() == 0); +} + +static void testToggleSegmentRemainderInLast() { + const Rect control{0, 0, 201, 22}; // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder) + CHECK(toggleSegmentRect(control, 0).width() == 100); + CHECK(toggleSegmentRect(control, 1).right == 201); +} + +static void testToggleHitTest() { + const Rect control{100, 0, 300, 22}; + CHECK(toggleSegmentHitTest(control, 150, 10) == 0); + CHECK(toggleSegmentHitTest(control, 250, 10) == 1); + CHECK(toggleSegmentHitTest(control, 50, 10) == -1); // left of control + CHECK(toggleSegmentHitTest(control, 150, 40) == -1); // below control +} + +// --- slider ------------------------------------------------------------------- + +static void testSliderTrackInsetsHalfHandle() { + const Rect control{100, 0, 300, 22}; + const Rect track = sliderTrackRect(control); + CHECK(track.left == control.left + kSliderHandleWidth / 2); + CHECK(track.right == control.right - kSliderHandleWidth / 2); + // A control too narrow for a handle yields an empty track. + CHECK(sliderTrackRect(Rect{0, 0, kSliderHandleWidth - 1, 22}).width() == 0); +} + +static void testSliderHandleAtEndpointsAndMid() { + const Rect control{100, 0, 300, 22}; + const Rect track = sliderTrackRect(control); + const int half = kSliderHandleWidth / 2; + // Value 0 -> handle centered at track.left. + const Rect h0 = sliderHandleRect(control, 0.0); + CHECK(h0.left + half == track.left); + // Value 1 -> handle centered at track.right. + const Rect h1 = sliderHandleRect(control, 1.0); + CHECK(h1.left + half == track.right); + // Value 0.5 -> centered at the track middle. + const Rect hm = sliderHandleRect(control, 0.5); + CHECK(hm.left + half == track.left + track.width() / 2); +} + +static void testSliderHandleClampsOutOfRange() { + const Rect control{0, 0, 200, 22}; + CHECK(sliderHandleRect(control, -0.5).left == sliderHandleRect(control, 0.0).left); + CHECK(sliderHandleRect(control, 5.0).left == sliderHandleRect(control, 1.0).left); +} + +static void testValueAtPointEndpointsSaturate() { + const Rect control{100, 0, 300, 22}; + const Rect track = sliderTrackRect(control); + CHECK(approx(valueAtPoint(control, track.left - 20), 0.0)); + CHECK(approx(valueAtPoint(control, track.left), 0.0)); + CHECK(approx(valueAtPoint(control, track.right + 20), 1.0)); + CHECK(approx(valueAtPoint(control, track.right), 1.0)); +} + +static void testValueAtPointIsHandleInverse() { + // Round-trip: a value -> handle center -> valueAtPoint recovers (within one pixel quantum). + const Rect control{50, 0, 450, 22}; // wide track for pixel resolution + const Rect track = sliderTrackRect(control); + for (double v : {0.1, 0.25, 0.5, 0.75, 0.9}) { + const Rect h = sliderHandleRect(control, v); + const int centerX = h.left + kSliderHandleWidth / 2; + const double back = valueAtPoint(control, centerX); + CHECK(back >= v - 0.01 && back <= v + 0.01); + CHECK(centerX >= track.left && centerX <= track.right); + } +} + +static void testValueAtPointDegenerateTrack() { + CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0)); +} + +// --- controlAtPoint routing --------------------------------------------------- + +static void testControlAtPointRoutes() { + const Rect panel{0, 0, 300, 400}; + std::vector ctl{ + {10, ControlKind::Toggle}, + {20, ControlKind::Slider}, + }; + const std::vector rows = layoutControls(panel, ctl); + // A point in the toggle's control area routes to the toggle id. + const Rect tctl = rows[0].control; + CHECK(controlAtPoint(rows, (tctl.left + tctl.right) / 2, (tctl.top + tctl.bottom) / 2) == 10); + // A point on the slider's track routes to the slider id. + const Rect strack = sliderTrackRect(rows[1].control); + CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2, + (strack.top + strack.bottom) / 2) == 20); +} + +static void testControlAtPointMisses() { + const Rect panel{0, 0, 300, 400}; + const std::vector rows = + layoutControls(panel, {{10, ControlKind::Toggle}, {20, ControlKind::Slider}}); + // The label column is not interactive. + CHECK(controlAtPoint(rows, rows[0].label.left + 2, rows[0].label.top + 4) == -1); + // The gap between rows is a miss. + const int gapY = rows[0].row.bottom + kControlRowGap / 2; + CHECK(controlAtPoint(rows, 200, gapY) == -1); + // Off-panel below. + CHECK(controlAtPoint(rows, 200, 5000) == -1); +} + +int main() { + testLayoutStacksRows(); + testLayoutEmptyAndDegenerate(); + testLayoutNarrowPanelClampsLabel(); + testToggleSegmentsTile(); + testToggleSegmentRemainderInLast(); + testToggleHitTest(); + testSliderTrackInsetsHalfHandle(); + testSliderHandleAtEndpointsAndMid(); + testSliderHandleClampsOutOfRange(); + testValueAtPointEndpointsSaturate(); + testValueAtPointIsHandleInverse(); + testValueAtPointDegenerateTrack(); + testControlAtPointRoutes(); + testControlAtPointMisses(); + + if (g_fail == 0) std::printf("param_slider: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp new file mode 100644 index 0000000..aca05d6 --- /dev/null +++ b/tests/test_pitch_shift.cpp @@ -0,0 +1,187 @@ +// Standalone tests for reasampler::PitchShifter — the S16 Preserve-engine DSP core. No VST3, +// no REAPER, no vendor, no test framework. The compile-time proof it does NOT drag the WDL +// chain is the CMake target linking only pitch_shift (+ peaks). +// +// Covers (PLAN.md S16 / CONTEXT.md §Pitch engine modes — Preserve): +// 1. duration invariance — N inputs yield N outputs at every shift ratio (the load-bearing +// Preserve property: a transposed render is the SAME frame length as the un-transposed one). +// 2. unity pass-through fidelity — ratio 1.0 reproduces the input closely (a shifter at unity +// must not mangle the signal). +// 3. transpose direction — an octave-up shift raises the observed pitch (period shortens), an +// octave-down lowers it (period lengthens), measured on a synthesized sine. +// 4. RT discipline surrogate — after configure()+warm() (the off-thread setup), a long +// process() run never resizes the ring (checked via window() constancy) and never returns +// NaN/inf; pass-through (unconfigured) returns input verbatim. + +#include "../src/vst/pitch_shift.h" + +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; } + +constexpr double kPi = 3.14159265358979323846; + +// A sine of `cycles` periods over `frames` frames. +static std::vector sine(std::size_t frames, double cycles) { + std::vector s(frames); + for (std::size_t i = 0; i < frames; ++i) { + s[i] = static_cast(std::sin(2.0 * kPi * cycles * + static_cast(i) / static_cast(frames))); + } + return s; +} + +// Average spacing between positive-going zero crossings (the observed period). +static double observedPeriod(const std::vector& out, std::size_t from) { + std::vector up; + for (std::size_t i = from + 1; i < out.size(); ++i) { + if (out[i - 1] <= 0.0f && out[i] > 0.0f) up.push_back(i); + } + if (up.size() < 2) return 0.0; + double sum = 0.0; + for (std::size_t i = 1; i < up.size(); ++i) sum += static_cast(up[i] - up[i - 1]); + return sum / static_cast(up.size() - 1); +} + +// --- 1. Duration invariance across shift ratios. --- +static void testDurationInvariance() { + // The core Preserve property: whatever the shift ratio, one input frame yields one output + // frame. So a shifter fed N frames produces exactly N frames — a transposed render is the + // same length as an un-transposed one (unlike Varispeed, where an octave up halves length). + const std::size_t n = 4000; + const std::vector in = sine(n, 40.0); + const double ratios[] = {0.5, 1.0, 2.0, std::pow(2.0, 7.0 / 12.0)}; + for (double r : ratios) { + PitchShifter ps; + ps.configure(2205); // ~50 ms @ 44.1k + ps.warm(); + ps.setShiftRatio(r); + std::size_t produced = 0; + for (std::size_t i = 0; i < n; ++i) { + const AudioSample o = ps.process(in[i]); + (void)o; + ++produced; // exactly one output per input, unconditionally. + } + CHECK(produced == n); // duration held at every ratio. + } +} + +// --- 2. Unity pass-through fidelity. --- +static void testUnityRoughlyReproduces() { + // At ratio 1.0 the shifter should reproduce the input's PITCH faithfully (the OLA taps run + // in lockstep with the writer). Amplitude/phase warble is allowed (basic OLA), but the + // observed period must match the source period within a small tolerance past the warm-up. + const std::size_t n = 8000; + const double cycles = 40.0; + const double nativePeriod = static_cast(n) / cycles; // 200 + const std::vector in = sine(n, cycles); + PitchShifter ps; + ps.configure(2205); + ps.warm(); + ps.setShiftRatio(1.0); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); + // Measure past the initial half-window latency region. + const double p = observedPeriod(out, 3000); + CHECK(p > 0.0); + CHECK(approx(p, nativePeriod, nativePeriod * 0.10)); // within 10% of source period +} + +// --- 3. Transpose direction: up shortens the period, down lengthens it. --- +static void testTransposeDirection() { + const std::size_t n = 12000; + const double cycles = 60.0; + const double nativePeriod = static_cast(n) / cycles; // 200 + const std::vector in = sine(n, cycles); + + // Octave up: output period ~ half the source period (higher pitch). + { + PitchShifter ps; + ps.configure(2205); + ps.warm(); + ps.setShiftRatio(2.0); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); + const double p = observedPeriod(out, 4000); + CHECK(p > 0.0); + CHECK(approx(p, nativePeriod / 2.0, nativePeriod * 0.15)); // period halves + } + // Octave down: output period ~ double the source period (lower pitch). + { + PitchShifter ps; + ps.configure(2205); + ps.warm(); + ps.setShiftRatio(0.5); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); + const double p = observedPeriod(out, 4000); + CHECK(p > 0.0); + CHECK(approx(p, nativePeriod * 2.0, nativePeriod * 0.30)); // period doubles + } +} + +// --- 4. RT discipline surrogate + pass-through. --- +static void testRtDisciplineAndPassthrough() { + // Unconfigured shifter passes input through verbatim (a Varispeed voice never allocates one). + { + PitchShifter ps; + CHECK(!ps.configured()); + CHECK(ps.process(0.37f) == 0.37f); // exact pass-through + CHECK(ps.process(-0.9f) == -0.9f); + } + // Configured: the window is fixed at configure() and never changes across a long run (no + // per-frame Resize), and no output is NaN/inf (numerically well-behaved OLA). + { + PitchShifter ps; + ps.configure(1024); + ps.warm(); + const std::int64_t w = ps.window(); + CHECK(w == 1024); + ps.setShiftRatio(std::pow(2.0, 5.0 / 12.0)); + const std::vector in = sine(20000, 100.0); + for (std::size_t i = 0; i < in.size(); ++i) { + const AudioSample o = ps.process(in[i]); + CHECK(std::isfinite(o)); + } + CHECK(ps.window() == w); // window unchanged -> ring never resized mid-run + } + // A non-positive shift ratio is ignored (keeps the last valid ratio) — never stalls/reverses. + { + PitchShifter ps; + ps.configure(512); + ps.warm(); + ps.setShiftRatio(1.0); + ps.setShiftRatio(-2.0); // ignored + ps.setShiftRatio(0.0); // ignored + for (int i = 0; i < 2000; ++i) CHECK(std::isfinite(ps.process(0.5f))); + } + // Degenerate window (<= 1) stays pass-through even after configure. + { + PitchShifter ps; + ps.configure(1); + CHECK(!ps.configured()); + CHECK(ps.process(0.25f) == 0.25f); + } +} + +int main() { + testDurationInvariance(); + testUnityRoughlyReproduces(); + testTransposeDirection(); + testRtDisciplineAndPassthrough(); + + if (g_fail == 0) { + std::printf("all pitch_shift tests passed\n"); + return 0; + } + std::printf("%d pitch_shift check(s) failed\n", g_fail); + return 1; +} diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp new file mode 100644 index 0000000..f5e28fb --- /dev/null +++ b/tests/test_sample_map.cpp @@ -0,0 +1,1488 @@ +// Standalone tests for reasampler::sample_map — no VST3, no REAPER, no test framework. +// Same fast assert loop as the sibling pure tests. This module is the S4 mapping heart: +// bank blob -> selected sample (through the SHARED bank_book JSON parse), interleaved -> +// mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap build, and the +// selected-sample instance-state (de)serialization. +// +// Every assertion is written to FAIL if the mapping were wrong: the bank blobs are built +// by serializing a real BankBook (so we exercise the shared parse, not a fixture string), +// and the selection / downmix / keymap / state values are checked against independently +// computed expectations. +// +// Covers: selectSample by-id hit (across pool + named banks), the S10 policy reversal +// (empty / stale id -> SILENCE nullopt, not the first sample), empty & malformed blob -> +// nullopt, zero-samples -> nullopt, rootNote/loop intrinsic threading incl. the middle-C +// default; listSamples ordinal order + the card metadata (rootNote/key/bankId) + empty/ +// malformed; listBanks ordinal order (pool first) + empty/malformed; downmixToMono mono +// passthrough / stereo average / 3-ch average / zero-stride / empty; buildTier0Keymap single +// full-keyboard zone with the root + loop + rate threaded and rate defaulting; selection +// state round-trip + empty id + wrong-version / truncated -> ""; component state (v3) +// round-trip + v1/v2 back-compat lift + empty/unknown -> empty. +// wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave- +// stride contract across the seam (that the byte stride wav_trim reports matches the +// channel-count stride downmixToMono divides by). + +#include "../src/vst/sample_map.h" + +#include +#include +#include +#include +#include + +#include "../src/bank_book.h" +#include "../src/bank_model.h" + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// Build a Sample with the fields sample_map reads. Relative path is required by +// BankIndex::add (relative-only invariant); a content hash is set so dedup does not +// collapse distinct entries. +static Sample makeSample(const std::string& id, const std::string& name, + const std::string& rel, std::optional root) { + Sample s; + s.id = id; + s.displayName = name; + s.relativePath = rel; + s.contentHash = "hash-" + id; + s.rootNote = root; + return s; +} + +// A serialized BankBook: the pool carries `poolSamples`, and one named bank "Drums" +// carries `drumSamples`. Returns the JSON the instrument would read from ext-state. +static std::string bookJson(const std::vector& poolSamples, + const std::vector& drumSamples) { + BankBook book; + for (const Sample& s : poolSamples) book.pool().index.add(s); + if (!drumSamples.empty()) { + book.createBank("drums-id", "Drums"); + BankIndex* di = book.index("drums-id"); + for (const Sample& s : drumSamples) di->add(s); + } + return book.serialize(); +} + +// --- selectSample ------------------------------------------------------------- + +static void testSelectByIdHit() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + // A sample in the NAMED bank resolves by id (search spans every bank). + auto sel = selectSample(json, "b"); + CHECK(sel.has_value()); + CHECK(sel && sel->relativePath == "reasampler_bank/b.wav"); + CHECK(sel && sel->rootNote == 38); +} + +static void testSelectEmptyIdIsSilence() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + // POLICY REVERSAL (S10): no stored selection resolves to SILENCE (nullopt), NOT the + // bank's first sample. A fresh instance plays nothing and shows the "pick a capture" + // empty state — the deliberate reversal of the S4 first-sample auto-play. + auto sel = selectSample(json, ""); + CHECK(!sel.has_value()); +} + +static void testSelectUnknownIdIsSilence() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {}); + // A stale stored id (deleted/moved-out sample) resolves to SILENCE, not a substituted + // first sample — the editor reflects the missing pick with its empty state rather than + // masking it with a mystery sample. + auto sel = selectSample(json, "deleted-id"); + CHECK(!sel.has_value()); +} + +static void testSelectRootNoteDefault() { + const std::string json = bookJson( + {makeSample("a", "Loop", "reasampler_bank/a.wav", std::nullopt)}, {}); + // A sample with no root-note intrinsic defaults to middle C (60). + auto sel = selectSample(json, "a"); + CHECK(sel.has_value()); + CHECK(sel && sel->rootNote == 60); +} + +static void testSelectLoopThreaded() { + Sample s = makeSample("a", "Pad", "reasampler_bank/a.wav", 60); + s.loop = LoopPoints{100, 500}; + const std::string json = bookJson({s}, {}); + auto sel = selectSample(json, "a"); + CHECK(sel.has_value()); + CHECK(sel && sel->loop.hasLoop); + CHECK(sel && sel->loop.start == 100 && sel->loop.end == 500); +} + +static void testSelectNoLoopIsAbsent() { + const std::string json = bookJson( + {makeSample("a", "OneShot", "reasampler_bank/a.wav", 60)}, {}); + auto sel = selectSample(json, "a"); + CHECK(sel.has_value()); + CHECK(sel && !sel->loop.hasLoop); // absent loop -> hasLoop false (not a zero loop) +} + +static void testSelectEmptyBlob() { + CHECK(!selectSample("", "a").has_value()); +} + +static void testSelectMalformedBlob() { + CHECK(!selectSample("{not valid json", "a").has_value()); +} + +static void testSelectZeroSamples() { + // A valid book with NO samples anywhere -> nothing to play. + const std::string json = bookJson({}, {}); + CHECK(!selectSample(json, "").has_value()); + CHECK(!selectSample(json, "anything").has_value()); +} + +// --- listSamples -------------------------------------------------------------- + +static void testListSamplesOrdinalOrder() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36), + makeSample("c", "Hat", "reasampler_bank/c.wav", 42)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + const std::vector list = listSamples(json); + // Pool samples (insertion order) come before the named bank's. + CHECK(list.size() == 3); + CHECK(list.size() == 3 && list[0].id == "a" && list[0].displayName == "Kick"); + CHECK(list.size() == 3 && list[1].id == "c"); + CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare"); +} + +static void testListSamplesCarriesCardMetadata() { + // The browser card needs rootNote/key badge + the bank id (for the filter). A pool sample + // reports the pool bank id; a named-bank sample reports "drums-id"; an un-rooted sample + // reports no rootNote (the badge shows "root —", never a guessed value). + Sample rooted = makeSample("a", "Kick", "reasampler_bank/a.wav", 36); + rooted.key = "Cm"; + Sample unrooted = makeSample("u", "Loop", "reasampler_bank/u.wav", std::nullopt); + const std::string json = bookJson({rooted, unrooted}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + const std::vector list = listSamples(json); + CHECK(list.size() == 3); + // Pool sample "a": rooted + keyed, pool bank id. + CHECK(list[0].id == "a" && list[0].rootNote.has_value() && *list[0].rootNote == 36); + CHECK(list[0].key.has_value() && *list[0].key == "Cm"); + CHECK(!list[0].bankId.empty()); // the pool has an id; the filter matches on it + // Pool sample "u": no root intrinsic -> no rootNote (badge shows "root —"). + CHECK(list[1].id == "u" && !list[1].rootNote.has_value()); + // Named-bank sample "b": its bank id distinguishes it from the pool for the filter. + CHECK(list[2].id == "b" && list[2].bankId == "drums-id"); + CHECK(list[2].bankId != list[0].bankId); // pool vs. named bank differ (filterable apart) +} + +static void testListSamplesEmptyAndMalformed() { + CHECK(listSamples("").empty()); + CHECK(listSamples("{garbage").empty()); + CHECK(listSamples(bookJson({}, {})).empty()); +} + +static void testListBanksOrdinalOrder() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + const std::vector banks = listBanks(json); + // Pool first (bank-zero), then the named bank "Drums". Both ids are present so the filter + // tab strip can key on them. + CHECK(banks.size() == 2); + CHECK(banks.size() == 2 && banks[1].id == "drums-id" && banks[1].displayName == "Drums"); + CHECK(banks.size() == 2 && !banks[0].id.empty()); // the pool bank has an id too +} + +static void testListBanksEmptyAndMalformed() { + CHECK(listBanks("").empty()); + CHECK(listBanks("{garbage").empty()); + // A valid book with no samples still has the pool bank -> one entry. + CHECK(listBanks(bookJson({}, {})).size() == 1); +} + +// --- downmixToMono ------------------------------------------------------------ + +static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; } + +static void testDownmixMonoPassthrough() { + const std::vector in{0.1f, -0.2f, 0.3f}; + const std::vector out = downmixToMono(in, 1); + CHECK(out.size() == 3); + CHECK(out.size() == 3 && approx(out[0], 0.1) && approx(out[1], -0.2) && + approx(out[2], 0.3)); +} + +static void testDownmixStereoAverages() { + // Two frames, stereo interleaved: frame0 = (1.0, 0.0) -> 0.5; frame1 = (0.4, 0.6) -> 0.5. + const std::vector in{1.0f, 0.0f, 0.4f, 0.6f}; + const std::vector out = downmixToMono(in, 2); + CHECK(out.size() == 2); + CHECK(out.size() == 2 && approx(out[0], 0.5) && approx(out[1], 0.5)); +} + +static void testDownmixThreeChannelAverages() { + // One 3-channel frame (0.3, 0.3, 0.6) -> 0.4. + const std::vector in{0.3f, 0.3f, 0.6f}; + const std::vector out = downmixToMono(in, 3); + CHECK(out.size() == 1); + CHECK(out.size() == 1 && approx(out[0], 0.4)); +} + +static void testDownmixDegenerate() { + CHECK(downmixToMono({}, 2).empty()); // empty input + CHECK(downmixToMono({0.1f, 0.2f}, 0).empty()); // zero stride + CHECK(downmixToMono({0.1f, 0.2f}, -1).empty()); // negative stride +} + +// --- buildTier0Keymap --------------------------------------------------------- + +static void testBuildKeymapSingleFullZone() { + SampleLoop loop; + loop.hasLoop = true; + loop.start = 10; + loop.end = 90; + const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 40, loop); + // One sample, one zone spanning the whole keyboard, rooted at 40. + CHECK(km.samples.size() == 1); + CHECK(km.zones.size() == 1); + CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 0 && km.zones[0].highNote == 127); + CHECK(km.zones.size() == 1 && km.zones[0].rootNote == 40); + CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 48000); + CHECK(km.samples.size() == 1 && km.samples[0].rootNote == 40); + CHECK(km.samples.size() == 1 && km.samples[0].frames.size() == 3); + CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop && + km.samples[0].loop.start == 10 && km.samples[0].loop.end == 90); + // Resolution: any note lands in the single zone. + CHECK(km.resolve(0, 100).matched); + CHECK(km.resolve(127, 100).matched); +} + + +// --- selection state (setState/getState) -------------------------------------- + +static void testSelectionStateRoundTrip() { + const std::string id = "sample-guid-123"; + const std::vector bytes = serializeSelection(id); + // Versioned: 4-byte tag + the id bytes. + CHECK(bytes.size() == 4 + id.size()); + CHECK(deserializeSelection(bytes) == id); +} + + +static void testSelectionStateEmptyId() { + const std::vector bytes = serializeSelection(""); + CHECK(bytes.size() == 4); // just the version tag + CHECK(deserializeSelection(bytes) == ""); +} + +static void testSelectionStateWrongVersion() { + std::vector bytes = serializeSelection("id"); + bytes[0] = 0xEE; // corrupt the version tag + CHECK(deserializeSelection(bytes) == ""); // unknown version -> no selection +} + +static void testSelectionStateTruncated() { + CHECK(deserializeSelection({}) == ""); // empty + CHECK(deserializeSelection({1, 0, 0}) == ""); // fewer than 4 bytes (no tag) +} + +// --- wav_trim -> extractFloatFrames -> downmixToMono integration --------------- +// +// Locks the interleave-stride contract at the seam between wav_trim and sample_map: +// wav_trim reports channelCount, extractFloatFrames yields interleaved samples with +// that stride, and downmixToMono divides by that same stride. If either module +// changed its understanding of the layout (e.g. extractFloatFrames started packing +// differently, or downmixToMono changed its stride divisor), this test catches it. + +static void putU16sm(std::vector& b, std::uint16_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); +} +static void putU32sm(std::vector& b, std::uint32_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); + b.push_back(static_cast((v >> 16) & 0xFF)); + b.push_back(static_cast((v >> 24) & 0xFF)); +} +static void putTagsm(std::vector& b, const char* t) { + for (int i = 0; i < 4; ++i) b.push_back(static_cast(t[i])); +} +static void putFloatsm(std::vector& b, float f) { + std::uint8_t tmp[4]; + std::memcpy(tmp, &f, 4); + for (int i = 0; i < 4; ++i) b.push_back(tmp[i]); +} + +// Build a 32-bit-float WAV byte buffer. Samples: frame f, channel c = value(f, c). +template +static std::vector buildWav(std::uint16_t channels, + std::uint32_t sampleRate, + std::size_t frames, + Fn value) { + const std::uint32_t dataBytes = + static_cast(frames * channels * 4u); + std::vector chunks; + putTagsm(chunks, "fmt "); + putU32sm(chunks, 16); + putU16sm(chunks, 3); // IEEE float + putU16sm(chunks, channels); + putU32sm(chunks, sampleRate); + putU32sm(chunks, sampleRate * channels * 4u); + putU16sm(chunks, static_cast(channels * 4)); + putU16sm(chunks, 32); + putTagsm(chunks, "data"); + putU32sm(chunks, dataBytes); + for (std::size_t f = 0; f < frames; ++f) + for (std::uint16_t c = 0; c < channels; ++c) + putFloatsm(chunks, value(f, c)); + std::vector wav; + putTagsm(wav, "RIFF"); + putU32sm(wav, static_cast(4 + chunks.size())); + putTagsm(wav, "WAVE"); + wav.insert(wav.end(), chunks.begin(), chunks.end()); + return wav; +} + +static void testWavTrimToDownmixPipelineStereo() { + // Stereo WAV: frame f, L = f * 0.1f, R = f * 0.1f + 0.5f. Expected mono average: + // (f * 0.1f + f * 0.1f + 0.5f) / 2 = f * 0.1f + 0.25f. + const std::size_t kFrames = 4; + auto wav = buildWav(2, 48000, kFrames, + [](std::size_t f, std::uint16_t c) { + return static_cast(f) * 0.1f + (c == 1 ? 0.5f : 0.0f); + }); + WavLayout layout = parseWavLayout(wav); + CHECK(layout.valid); + CHECK(layout.channelCount == 2); + CHECK(layout.frameCount() == kFrames); + const std::vector interleaved = + extractFloatFrames(wav, layout, 0, layout.frameCount()); + CHECK(interleaved.size() == kFrames * 2); + const std::vector mono = downmixToMono(interleaved, layout.channelCount); + CHECK(mono.size() == kFrames); + for (std::size_t f = 0; f < kFrames; ++f) { + const float expected = static_cast(f) * 0.1f + 0.25f; + CHECK(approx(mono[f], expected)); + } +} + +static void testWavTrimToDownmixPipelineMono() { + // Mono WAV: extractFloatFrames -> downmixToMono with channelCount==1 is a passthrough. + const std::size_t kFrames = 3; + auto wav = buildWav(1, 44100, kFrames, + [](std::size_t f, std::uint16_t) { + return static_cast(f) * 0.5f; + }); + WavLayout layout = parseWavLayout(wav); + CHECK(layout.valid); + CHECK(layout.channelCount == 1); + const std::vector interleaved = + extractFloatFrames(wav, layout, 0, layout.frameCount()); + CHECK(interleaved.size() == kFrames); + const std::vector mono = downmixToMono(interleaved, layout.channelCount); + CHECK(mono.size() == kFrames); + CHECK(approx(mono[0], 0.0) && approx(mono[1], 0.5) && approx(mono[2], 1.0)); +} + +// --- performance map: resolvePerformance -------------------------------------- + +static PerformanceZone zone(const std::string& id, int lo, int hi, + std::optional rootOverride = std::nullopt) { + PerformanceZone z; + z.sampleId = id; + z.lowNote = lo; + z.highNote = hi; + z.rootOverride = rootOverride; + return z; +} + +static void testResolveEmptyMap() { + // An empty performance map resolves to nothing (the shell falls back to Tier 0). + const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); + const ResolvedPerformance r = resolvePerformance(json, PerformanceMap{}); + CHECK(r.zones.empty()); + CHECK(r.droppedSampleIds.empty()); +} + +static void testResolveEmptyBlob() { + PerformanceMap m; + m.zones.push_back(zone("a", 0, 127)); + CHECK(resolvePerformance("", m).zones.empty()); // no bank + CHECK(resolvePerformance("{garbage", m).zones.empty()); // malformed +} + +static void testResolveMultiZoneAcrossBanks() { + const std::string json = bookJson( + {makeSample("a", "Kick", "b/a.wav", 36)}, + {makeSample("b", "Snare", "b/b.wav", 38)}); + PerformanceMap m; + m.zones.push_back(zone("a", 36, 47)); + m.zones.push_back(zone("b", 48, 59)); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 2); + CHECK(r.droppedSampleIds.empty()); + // Order preserved; paths + ranges threaded. + CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav"); + CHECK(r.zones.size() == 2 && r.zones[0].lowNote == 36 && r.zones[0].highNote == 47); + CHECK(r.zones.size() == 2 && r.zones[1].relativePath == "b/b.wav"); + CHECK(r.zones.size() == 2 && r.zones[1].lowNote == 48 && r.zones[1].highNote == 59); +} + +static void testResolveStaleIdDropsZone() { + // STALE-ID POLICY: a zone naming a deleted sample is dropped, its id reported; the + // surviving zone still resolves (the whole map is NOT abandoned). + const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); + PerformanceMap m; + m.zones.push_back(zone("a", 0, 59)); + m.zones.push_back(zone("ghost", 60, 127)); // no such sample + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1); + CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav"); + CHECK(r.droppedSampleIds.size() == 1); + CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost"); +} + +static void testResolveRootPrecedence() { + // Override beats bank intrinsic beats middle-C default. + const std::string json = bookJson( + {makeSample("rooted", "R", "b/r.wav", 40), // bank intrinsic 40 + makeSample("unrooted", "U", "b/u.wav", std::nullopt)}, // no intrinsic + {}); + PerformanceMap m; + m.zones.push_back(zone("rooted", 0, 42)); // no override -> 40 + m.zones.push_back(zone("rooted", 43, 84, /*override=*/72)); // override -> 72 + m.zones.push_back(zone("unrooted", 85, 127)); // no intrinsic -> 60 + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 3); + CHECK(r.zones.size() == 3 && r.zones[0].rootNote == 40); // bank intrinsic + CHECK(r.zones.size() == 3 && r.zones[1].rootNote == 72); // override wins + CHECK(r.zones.size() == 3 && r.zones[2].rootNote == 60); // middle-C default +} + +static void testResolveLoopThreaded() { + Sample s = makeSample("a", "Pad", "b/a.wav", 60); + s.loop = LoopPoints{200, 800}; + const std::string json = bookJson({s}, {}); + PerformanceMap m; + m.zones.push_back(zone("a", 0, 127)); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1); + CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); + CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800); + // No loop override + no startPoint -> effective start is 0 (S11 default). + CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 0); +} + +static void testResolveLoopOverrideWins() { + // S11: the instrument's per-zone loopOverride beats the bank's S2 loop intrinsic, and the + // startPoint feeds the effective startFrame — without mutating the bank. + Sample s = makeSample("a", "Pad", "b/a.wav", 60); + s.loop = LoopPoints{200, 800}; // bank intrinsic + const std::string json = bookJson({s}, {}); + PerformanceMap m; + PerformanceZone z = zone("a", 0, 127); + SampleLoop over; over.hasLoop = true; over.start = 1000; over.end = 4000; + z.loopOverride = over; // instrument override + z.startPoint = 512; // start offset + m.zones.push_back(z); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); + CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 1000 && r.zones[0].loop.end == 4000); + CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 512); +} + +static void testResolveLoopOverrideDisablesLoop() { + // A loopOverride with hasLoop=false explicitly REMOVES the bank's loop for this instance + // (override present-but-empty wins over the intrinsic — a deliberate "no loop here"). + Sample s = makeSample("a", "Pad", "b/a.wav", 60); + s.loop = LoopPoints{200, 800}; + const std::string json = bookJson({s}, {}); + PerformanceMap m; + PerformanceZone z = zone("a", 0, 127); + z.loopOverride = SampleLoop{}; // hasLoop=false, start=end=0 + m.zones.push_back(z); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1 && !r.zones[0].loop.hasLoop); +} + +// --- performance map: buildZonedKeymap ---------------------------------------- + +static void testBuildZonedKeymapMultiZone() { + std::vector zones; + ResolvedZone z0; z0.lowNote = 36; z0.highNote = 47; z0.rootNote = 36; zones.push_back(z0); + ResolvedZone z1; z1.lowNote = 48; z1.highNote = 59; z1.rootNote = 48; zones.push_back(z1); + std::vector decoded; + decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100}); + decoded.push_back(DecodedZonePcm{{0.3f, 0.4f, 0.5f}, 48000}); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 2); + CHECK(km.zones.size() == 2); + // Zone 0 -> sample 0, rooted 36, range 36..47; zone 1 -> sample 1, rooted 48. + CHECK(km.zones.size() == 2 && km.zones[0].sampleIndex == 0 && km.zones[0].rootNote == 36); + CHECK(km.zones.size() == 2 && km.zones[0].lowNote == 36 && km.zones[0].highNote == 47); + CHECK(km.zones.size() == 2 && km.zones[1].sampleIndex == 1 && km.zones[1].rootNote == 48); + CHECK(km.samples.size() == 2 && km.samples[1].sampleRate == 48000); + CHECK(km.samples.size() == 2 && km.samples[1].frames.size() == 3); + // Resolution: a note in each range lands in the right zone. + CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0); + CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1); + // A note outside every zone does not match (no-play, not zone 0). + CHECK(!km.resolve(24, 100).matched); +} + +static void testBuildZonedKeymapThreadsLoopAndStart() { + // S11: the effective loop + start on a ResolvedZone reach the core's SampleData so the + // voice honors them at note-on. + std::vector zones; + ResolvedZone z0; + z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; + z0.loop.hasLoop = true; z0.loop.start = 3; z0.loop.end = 7; + z0.startFrame = 2; + zones.push_back(z0); + std::vector decoded; + decoded.push_back(DecodedZonePcm{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, 44100}); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 1); + CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop && + km.samples[0].loop.start == 3 && km.samples[0].loop.end == 7); + CHECK(km.samples.size() == 1 && km.samples[0].startFrame == 2); +} + +static void testBuildZonedKeymapDropsEmptyPcm() { + // A zone whose decoded WAV is empty is dropped; the other zone survives, and the + // survivor's sampleIndex points at ITS sample (not the dropped one's slot). + std::vector zones; + ResolvedZone z0; z0.lowNote = 0; z0.highNote = 63; z0.rootNote = 60; zones.push_back(z0); + ResolvedZone z1; z1.lowNote = 64; z1.highNote = 127; z1.rootNote = 72; zones.push_back(z1); + std::vector decoded; + decoded.push_back(DecodedZonePcm{{}, 44100}); // empty -> dropped + decoded.push_back(DecodedZonePcm{{0.9f}, 44100}); // survives + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 1); + CHECK(km.zones.size() == 1); + CHECK(km.zones.size() == 1 && km.zones[0].sampleIndex == 0); // remapped to slot 0 + CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 64 && km.zones[0].rootNote == 72); +} + +static void testBuildZonedKeymapOverlapFirstWins() { + // OVERLAP POLICY: two zones share keys; the FIRST in order wins the contested note + // (mirrors the S3 core's first-match resolve). + std::vector zones; + ResolvedZone z0; z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; zones.push_back(z0); + ResolvedZone z1; z1.lowNote = 60; z1.highNote = 72; z1.rootNote = 48; zones.push_back(z1); + std::vector decoded; + decoded.push_back(DecodedZonePcm{{0.1f}, 44100}); + decoded.push_back(DecodedZonePcm{{0.2f}, 44100}); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.zones.size() == 2); + // Note 64 is in both zones; first-match resolves to zone 0. + CHECK(km.resolve(64, 100).matched && km.resolve(64, 100).zoneIndex == 0); +} + +static void testBuildZonedKeymapEmpty() { + // No zones -> empty keymap (silence). + const Keymap km = buildZonedKeymap({}, {}); + CHECK(km.samples.empty() && km.zones.empty()); + CHECK(!km.resolve(60, 100).matched); +} + +// --- performance-map state: serialize / deserialize --------------------------- + +static void testPerformanceStateRoundTrip() { + PerformanceMap m; + m.zones.push_back(zone("kick", 36, 47)); // no override + m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override + const std::vector bytes = serializePerformance(m); + const PerformanceMap back = deserializePerformance(bytes, 44100.0); + CHECK(back.zones.size() == 2); + CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick"); + CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47); + CHECK(back.zones.size() == 2 && !back.zones[0].rootOverride.has_value()); + CHECK(back.zones.size() == 2 && back.zones[1].sampleId == "snare"); + CHECK(back.zones.size() == 2 && back.zones[1].rootOverride.has_value() && + *back.zones[1].rootOverride == 50); +} + +static void testPerformanceStateEmpty() { + const std::vector bytes = serializePerformance(PerformanceMap{}); + // Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4). + CHECK(bytes.size() == 16); + CHECK(deserializePerformance(bytes, 44100.0).zones.empty()); +} + +static void testPerformanceStateLoopStartRoundTrip() { + // S11: the per-zone loopOverride + startPoint survive the payload-v2 round trip. + PerformanceMap m; + PerformanceZone z = zone("pad", 24, 96, /*override=*/64); + SampleLoop lp; lp.hasLoop = true; lp.start = 12345; lp.end = 67890; + z.loopOverride = lp; + z.startPoint = 4096; + m.zones.push_back(z); + // A second zone with NO overrides proves the optional tail is per-record. + m.zones.push_back(zone("kick", 0, 23)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); + CHECK(back.zones.size() == 2); + CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() && + *back.zones[0].rootOverride == 64); + CHECK(back.zones.size() == 2 && back.zones[0].loopOverride.has_value() && + back.zones[0].loopOverride->hasLoop && + back.zones[0].loopOverride->start == 12345 && + back.zones[0].loopOverride->end == 67890); + CHECK(back.zones.size() == 2 && back.zones[0].startPoint.has_value() && + *back.zones[0].startPoint == 4096); + // Zone 1: no overrides -> all optionals absent after round trip. + CHECK(back.zones.size() == 2 && !back.zones[1].loopOverride.has_value()); + CHECK(back.zones.size() == 2 && !back.zones[1].startPoint.has_value()); +} + +static void testPerformanceStateV1PayloadBackCompat() { + // A pre-S11 PAYLOAD v1 blob (no format marker: envelope v2 + bare count + short records) + // parses cleanly with the loop/start overrides defaulting absent. Hand-build the exact + // shipped shape to prove the reader still accepts the marker-less payload. + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + u32(2); // envelope version 2 + u32(1); // zone count 1 (NOT the marker -> payload v1) + const std::string id = "legacy"; + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(10); // lowNote + u32(40); // highNote + b.push_back(0); // hasRootOverride = 0 (record ends here in v1) + const PerformanceMap back = deserializePerformance(b, 44100.0); + CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy"); + CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40); + CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value()); + CHECK(back.zones.size() == 1 && !back.zones[0].startPoint.has_value()); +} + +static void testComponentStateLoopStartRoundTrip() { + // The overrides also round-trip through the v3 ComponentState envelope (zones nest inside + // it), so the processor's live getState/setState preserves them — the composition property. + ComponentState s; + s.selectionId = "pick"; + PerformanceZone z = zone("pick", 0, 127); + SampleLoop lp; lp.hasLoop = true; lp.start = 500; lp.end = 9000; + z.loopOverride = lp; + z.startPoint = 128; + s.map.zones.push_back(z); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() && + back.map.zones[0].loopOverride->start == 500 && + back.map.zones[0].loopOverride->end == 9000); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].startPoint.has_value() && + *back.map.zones[0].startPoint == 128); +} + +static void testPerformanceStateV1BackCompat() { + // A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone. + const std::vector v1 = serializeSelection("legacy-sample-id"); + const PerformanceMap back = deserializePerformance(v1, 44100.0); + CHECK(back.zones.size() == 1); + CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id"); + CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127); + CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value()); + // A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection"). + CHECK(deserializePerformance(serializeSelection(""), 44100.0).zones.empty()); +} + +static void testPerformanceStateGarbage() { + // Unknown version / truncated / empty -> empty map (never throws). + CHECK(deserializePerformance({}, 44100.0).zones.empty()); + CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}, 44100.0).zones.empty()); // unknown version + // Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty. + std::vector t; + t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2 + t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1 + // (no zone payload) + CHECK(deserializePerformance(t, 44100.0).zones.empty()); +} + +static void testPerformanceStateNegativeNotesRoundTrip() { + // Notes are clamped in the UI, but the wire format must survive the full int range so + // a hand-set/legacy value round-trips without corruption (two's-complement on the wire). + PerformanceMap m; + m.zones.push_back(zone("s", 0, 127, /*override=*/0)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); + CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() && + *back.zones[0].rootOverride == 0); +} + +// --- Combined component state (v3, S10) -------------------------------------- + +static void testComponentStateRoundTrip() { + // The v3 state carries the single-capture selection AND the opt-in zones, distinctly. + ComponentState s; + s.selectionId = "picked-capture"; + s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt)); + s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.selectionId == "picked-capture"); + CHECK(back.map.zones.size() == 2); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && + back.map.zones[0].highNote == 59 && !back.map.zones[0].rootOverride.has_value()); + CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" && + back.map.zones[1].rootOverride.has_value() && *back.map.zones[1].rootOverride == 48); +} + +static void testComponentStateSelectionOnlyNoZones() { + // A single-capture instance: a pick, no zones. Must restore the pick with an empty map + // (NOT synthesize a zone) — the default face is one capture, zones are opt-in. + ComponentState s; + s.selectionId = "just-a-pick"; + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.selectionId == "just-a-pick"); + CHECK(back.map.zones.empty()); +} + +static void testComponentStateEmptyIsEmpty() { + // No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample. + const ComponentState s; // selectionId "", empty map + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.selectionId.empty()); + CHECK(back.map.zones.empty()); +} + +static void testComponentStateV1BackCompat() { + // A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick + // survives as BOTH the selection and a one-zone map. + const std::vector v1 = serializeSelection("legacy-id"); + const ComponentState back = deserializeComponentState(v1, 44100.0); + CHECK(back.selectionId == "legacy-id"); + CHECK(back.map.zones.size() == 1); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" && + back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127); + // A v1 blob with an EMPTY id -> empty state (no selection, no zone). + const ComponentState empty = deserializeComponentState(serializeSelection(""), 44100.0); + CHECK(empty.selectionId.empty() && empty.map.zones.empty()); +} + +static void testComponentStateV2BackCompat() { + // A v2 S5 blob (zones-only) lifts to {"", zones}: that instance had zones but no separate + // single-capture selection. + PerformanceMap m; + m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt)); + const std::vector v2 = serializePerformance(m); + const ComponentState back = deserializeComponentState(v2, 44100.0); + CHECK(back.selectionId.empty()); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" && + back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24); +} + +static void testComponentStateGarbage() { + // Empty / unknown version -> empty (never throws across the host). + CHECK(deserializeComponentState({}, 44100.0).selectionId.empty()); + CHECK(deserializeComponentState({}, 44100.0).map.zones.empty()); + const std::vector unknown{0xAA, 0xBB, 0xCC, 0xDD}; + CHECK(deserializeComponentState(unknown, 44100.0).map.zones.empty()); + CHECK(deserializeComponentState(unknown, 44100.0).selectionId.empty()); + // A v3 header claiming a longer id than the blob holds -> empty (bounded read). + std::vector t; + t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0); // version 3 + t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0); // id length 200 (absent) + CHECK(deserializeComponentState(t, 44100.0).selectionId.empty()); + CHECK(deserializeComponentState(t, 44100.0).map.zones.empty()); +} + +// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ---------- + +static void testExtractChannelStereo() { + // Interleaved stereo [L0,R0,L1,R1,...]; extract channel 0 -> L's, channel 1 -> R's. + const std::vector in{0.1f, 0.9f, 0.2f, 0.8f, 0.3f, 0.7f}; + const std::vector l = extractChannel(in, 2, 0); + const std::vector r = extractChannel(in, 2, 1); + CHECK(l.size() == 3 && approx(l[0], 0.1) && approx(l[1], 0.2) && approx(l[2], 0.3)); + CHECK(r.size() == 3 && approx(r[0], 0.9) && approx(r[1], 0.8) && approx(r[2], 0.7)); +} + +static void testExtractChannelClampsToLast() { + // A mono source asked for channel 1 yields channel 0 (clamp to last) — the dual-mono block. + const std::vector mono{0.1f, 0.2f, 0.3f}; + const std::vector ch1 = extractChannel(mono, 1, 1); + CHECK(ch1.size() == 3 && approx(ch1[0], 0.1) && approx(ch1[2], 0.3)); // == channel 0 + CHECK(extractChannel({}, 2, 0).empty()); // empty in + CHECK(extractChannel({0.1f}, 0, 0).empty()); // zero stride +} + +static void testDecodeChannelsMonoModeDownmixes() { + // MONO mode: a stereo source averages to one channel (the existing policy), framesR empty. + const std::vector stereo{1.0f, 0.0f, 0.4f, 0.6f}; // frames (1,0) and (0.4,0.6) + const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Mono, 48000); + CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.5) && approx(d.monoFrames[1], 0.5)); + CHECK(d.framesR.empty()); // mono mode -> single channel + CHECK(d.sampleRate == 48000); +} + +static void testDecodeChannelsStereoModeStereoSource() { + // STEREO mode + stereo source: channels taken as-is (L/R), both present + distinct. + const std::vector stereo{0.1f, 0.9f, 0.2f, 0.8f}; + const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Stereo, 44100); + CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.1) && approx(d.monoFrames[1], 0.2)); + CHECK(d.framesR.size() == 2 && approx(d.framesR[0], 0.9) && approx(d.framesR[1], 0.8)); +} + +static void testDecodeChannelsStereoModeMonoSourceDualMono() { + // STEREO mode + mono source: dual-mono — framesR duplicates channel 0 (centered, not silent). + const std::vector mono{0.3f, 0.6f, 0.9f}; + const DecodedZonePcm d = decodeChannels(mono, 1, ChannelMode::Stereo, 44100); + CHECK(d.monoFrames.size() == 3); + CHECK(d.framesR.size() == 3); + for (std::size_t i = 0; i < 3; ++i) CHECK(approx(d.monoFrames[i], d.framesR[i])); // R == L +} + +// --- S7: buildTier0Keymap stereo threading ------------------------------------ + +static void testBuildKeymapStereoCarriesSecondChannel() { + const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}, {0.9f, 0.8f}); + CHECK(km.samples.size() == 1); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2); + CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 && + approx(km.samples[0].framesR[0], 0.9) && approx(km.samples[0].framesR[1], 0.8)); +} + +static void testBuildKeymapMonoWhenNoSecondChannel() { + // No framesR passed -> mono SampleData (byte-identical to the pre-S7 build). + const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1); + CHECK(km.samples.size() == 1 && km.samples[0].framesR.empty()); +} + +static void testBuildKeymapDropsMismatchedSecondChannel() { + // A framesR whose length mismatches frames is dropped -> mono (a bad pair never half-plays). + const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 60, SampleLoop{}, {0.9f}); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1); +} + +static void testBuildZonedKeymapCarriesSecondChannel() { + // The zoned build threads each zone's framesR when it length-matches channel 0. + std::vector zones; + ResolvedZone z; z.lowNote = 0; z.highNote = 127; z.rootNote = 60; zones.push_back(z); + std::vector decoded; + DecodedZonePcm d; d.monoFrames = {0.1f, 0.2f}; d.sampleRate = 44100; d.framesR = {0.9f, 0.8f}; + decoded.push_back(d); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2); + CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 && + approx(km.samples[0].framesR[1], 0.8)); +} + +// --- S7: component state v4 (channel mode) ------------------------------------ + +static void testComponentStateV4RoundTripStereo() { + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.selectionId == "pick"); + CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); +} + +static void testComponentStateV4RoundTripMono() { + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Mono; + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.selectionId == "pick"); + CHECK(back.channelMode == ChannelMode::Mono); +} + +static void testComponentStateV4DefaultIsMono() { + // A default-constructed state serializes with mono and restores mono (preserves behavior). + const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); + CHECK(back.channelMode == ChannelMode::Mono); + CHECK(back.selectionId.empty() && back.map.zones.empty()); +} + +static void testComponentStateV3LiftsToMono() { + // A pre-S7 v3 blob (selection + zones, no mode byte) lifts to channelMode = mono, with the + // selection and zones intact. Build a v3 blob by hand: tag 3, id length + id, zones payload. + std::vector v3; + v3.push_back(3); v3.push_back(0); v3.push_back(0); v3.push_back(0); // version 3 + const std::string id = "legacy"; + v3.push_back(static_cast(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0); + v3.insert(v3.end(), id.begin(), id.end()); + v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0 + const ComponentState back = deserializeComponentState(v3, 44100.0); + CHECK(back.selectionId == "legacy"); + CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default + CHECK(back.map.zones.empty()); +} + +static void testComponentStateV1V2LiftToMono() { + // The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read. + const ComponentState v1 = deserializeComponentState(serializeSelection("old"), 44100.0); + CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old"); + PerformanceMap m; m.zones.push_back(zone("s", 12, 24)); + const ComponentState v2 = deserializeComponentState(serializePerformance(m), 44100.0); + CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1); +} + +static void testComponentStateV4TruncatedModeByte() { + // A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds. + std::vector t{4, 0, 0, 0}; // version 4, nothing after + const ComponentState back = deserializeComponentState(t, 44100.0); + CHECK(back.channelMode == ChannelMode::Mono); + CHECK(back.selectionId.empty() && back.map.zones.empty()); +} + +static void testComponentStateV4StereoWithZoneOverridesRoundTrip() { + // The MERGE composition property (S7 v4 envelope x S11 v2 zones payload): a v4 blob carrying + // channelMode = STEREO AND zones with loopOverride + startPoint must round-trip ALL of it + // losslessly. The channel-mode byte lives on the envelope; the loop/start overrides live in + // the self-versioned zones payload — the two tracks are orthogonal, so both survive one + // serialize/deserialize. (V4RoundTripStereo covers mode with a bare zone; LoopStartRoundTrip + // covers overrides at the default mono mode; this asserts them TOGETHER.) + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + PerformanceZone z0 = zone("z0", 12, 48, /*override=*/36); + SampleLoop lp0; lp0.hasLoop = true; lp0.start = 500; lp0.end = 9000; + z0.loopOverride = lp0; + z0.startPoint = 128; + PerformanceZone z1 = zone("z1", 49, 127); // second zone: no overrides (mixed payload) + s.map.zones.push_back(z0); + s.map.zones.push_back(z1); + + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.channelMode == ChannelMode::Stereo); // envelope field survives + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 2); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && + back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 48); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].rootOverride.has_value() && + *back.map.zones[0].rootOverride == 36); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].loopOverride.has_value() && + back.map.zones[0].loopOverride->hasLoop && + back.map.zones[0].loopOverride->start == 500 && + back.map.zones[0].loopOverride->end == 9000); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].startPoint.has_value() && + *back.map.zones[0].startPoint == 128); + // The override-free second zone stays override-free (the payload framing per zone is intact). + CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" && + !back.map.zones[1].loopOverride.has_value() && + !back.map.zones[1].startPoint.has_value()); +} + +// --- v5 component state: the S8/S9 last-consumed-assignment marker ------------- + +static void testComponentStateV5MarkerRoundTrip() { + // The consumed-assignment generation (S8 reader marker) round-trips through the v5 envelope + // alongside selection + mode + zones. A non-zero, > 32-bit value proves the 8-byte LE field. + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX + s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives + CHECK(back.channelMode == ChannelMode::Stereo); + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); +} + +static void testComponentStateDefaultMarkerIsZero() { + // A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first + // assign (generation >= 1) must not be swallowed by a non-zero default. + const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); + CHECK(back.lastConsumedAssignGeneration == 0); +} + +static void testComponentStateV4LiftsMarkerToZero() { + // A GENUINE v4 blob (version tag 4: mode byte, then id + zones — NO 8-byte marker) must lift + // with lastConsumedAssignGeneration = 0 and its mode/selection/zones intact. Build it by hand + // (serializeComponentState now emits v5, so we cannot use it to make a v4 blob). This proves + // an already-saved pre-S8/S9 instance restores cleanly and its first assign still applies. + std::vector v4; + v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // version 4 + v4.push_back(1); // channel mode = stereo + const std::string id = "saved"; + v4.push_back(static_cast(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0); + v4.insert(v4.end(), id.begin(), id.end()); + v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0 + const ComponentState back = deserializeComponentState(v4, 44100.0); + CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0 + CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored + CHECK(back.selectionId == "saved"); + CHECK(back.map.zones.empty()); +} + +static void testComponentStateV5TruncatedMarker() { + // A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty, + // mono + marker 0 default holds (bounded read, never throws across the host). + std::vector t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes + const ComponentState back = deserializeComponentState(t, 44100.0); + CHECK(back.lastConsumedAssignGeneration == 0); + CHECK(back.selectionId.empty() && back.map.zones.empty()); +} + +// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ---------------- +// +// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and +// ps-w9-t2-modes (payload v3, adds the per-zone play params) makes THREE combinations first +// reachable. Each pre-existing suite covers one axis in isolation; these lock the axes together. + +static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() { + // (a) The full v5 face: channelMode + the S8/S9 consumed marker (envelope) AND zones carrying + // S15/S16 play params (payload) must ALL survive one serialize/deserialize. The two extensions + // sit on orthogonal tracks (envelope vs self-versioned payload); this proves they compose with + // no field cross-talk — neither the marker read nor the play-param read consumes the other's bytes. + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX -> exercises the full 8-byte field + PerformanceZone z = zone("z0", 0, 127, /*override=*/48); + z.play.playMode = PlayMode::Trigger; + z.play.adsr.holdSeconds = 0.093; + z.play.trigger.lengthFraction = 0.625; + z.play.trigger.fadeInFrames = 32; + z.play.trigger.fadeOutFrames = 96; + z.play.pitchEngine = PitchEngine::Varispeed; + z.play.pitchEnv.enabled = true; + z.play.pitchEnv.attackSeconds = 0.00018; + z.play.pitchEnv.decaySeconds = 0.0145; + z.play.pitchEnv.peakSemitones = 12.5; + s.map.zones.push_back(z); + + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode + CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 1); + if (back.map.zones.size() != 1) return; + const ZonePlaySeconds& p = back.map.zones[0].play; // payload: play params + CHECK(p.playMode == PlayMode::Trigger); + CHECK(p.adsr.holdSeconds == 0.093); + CHECK(p.trigger.lengthFraction == 0.625); + CHECK(p.trigger.fadeInFrames == 32 && p.trigger.fadeOutFrames == 96); + CHECK(p.pitchEngine == PitchEngine::Varispeed); + CHECK(p.pitchEnv.enabled && p.pitchEnv.attackSeconds == 0.00018 && + p.pitchEnv.decaySeconds == 0.0145 && p.pitchEnv.peakSemitones == 12.5); +} + +// Hand-build ONE v3 zone record (marker-versioned payload body) for a single-zone map. Emits the +// exact on-wire order the header's PAYLOAD v3 spec + putZonesPayload write: id, lo/hi, no root/loop/ +// start overrides, then the always-present S15/S16 play tail. Used to synthesize the two v4 blobs +// below WITHOUT serializeComponentState (which now emits v5) — so the reader's widened accept-chain +// is exercised against a genuine, older-envelope byte layout rather than a self-produced buffer. +static std::vector handBuildV3PayloadOneZone(const std::string& id) { + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + auto u64 = [&](std::uint64_t v) { + for (int i = 0; i < 8; ++i) b.push_back(static_cast((v >> (i * 8)) & 0xFF)); + }; + auto dbl = [&](double d) { std::uint64_t bits; std::memcpy(&bits, &d, 8); u64(bits); }; + u32(kZonesFormatMarker); + u32(3); // PAYLOAD VERSION 3 (S15/S16 play tail present) + u32(1); // zone count 1 + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(10); // lowNote + u32(70); // highNote + b.push_back(0); // hasRootOverride = 0 + b.push_back(0); // hasLoopOverride = 0 + b.push_back(0); // hasStartPoint = 0 + // Always-present v3 play tail: Trigger, hold, lengthFraction, fades, Varispeed, env off. + b.push_back(1); // playMode = Trigger + u64(static_cast(2048)); // adsr.holdFrames + dbl(0.5); // trigger.lengthFraction + u64(static_cast(16)); // trigger.fadeInFrames + u64(static_cast(48)); // trigger.fadeOutFrames + b.push_back(0); // pitchEngine = Varispeed + b.push_back(0); // pitchEnv.enabled = 0 + u64(0); // pitchEnv.attackFrames + u64(0); // pitchEnv.decayFrames + dbl(0.0); // pitchEnv.peakSemitones + return b; +} + +static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() { + // (b) + (c) unified: a GENUINE v4 ENVELOPE blob (version tag 4: mode byte, id, then the zones + // payload — NO 8-byte marker) whose zones payload is PAYLOAD v3 (the exact shape an S15-test-build + // save produced). Under the widened accept-chain it must (b) lift lastConsumedAssignGeneration to + // 0 AND (c) deserialize its payload-v3 play params intact. This is the precise blob a user who + // saved on the S15 test build (envelope v4 + payload v3) would hold; the v4 lift branch delegates + // zones to readZonesPayload, which self-selects the v3 record shape from the payload marker — so + // the two v4 layouts (S7-era payload-v2, S15-era payload-v3) are UNAMBIGUOUS, distinguished + // inside the payload, not on the envelope. + std::vector v4; + v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // ENVELOPE version 4 + v4.push_back(1); // channel mode = stereo + const std::string id = "s15saved"; + v4.push_back(static_cast(id.size())); + v4.push_back(0); v4.push_back(0); v4.push_back(0); // idLen (LE) + v4.insert(v4.end(), id.begin(), id.end()); + const std::vector payload = handBuildV3PayloadOneZone("zv3"); + v4.insert(v4.end(), payload.begin(), payload.end()); + + const ComponentState back = deserializeComponentState(v4, 44100.0); + CHECK(back.lastConsumedAssignGeneration == 0); // (b) no marker in v4 -> default 0 + CHECK(back.channelMode == ChannelMode::Stereo); // v4 envelope mode honored + CHECK(back.selectionId == "s15saved"); + CHECK(back.map.zones.size() == 1); // (c) payload-v3 zone parsed under widened check + if (back.map.zones.size() != 1) return; + CHECK(back.map.zones[0].sampleId == "zv3"); + CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70); + const ZonePlaySeconds& p = back.map.zones[0].play; + CHECK(p.playMode == PlayMode::Trigger); // (c) play params survive the v4 envelope + // Legacy v3 wall-clock frames convert to seconds at the passed project rate (44100.0 here). + CHECK(approx(p.adsr.holdSeconds, 2048.0 / 44100.0)); + CHECK(p.trigger.lengthFraction == 0.5); + CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is + CHECK(p.pitchEngine == PitchEngine::Varispeed); + CHECK(p.pitchEnv.enabled == false); +} + +// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift ------------- + +static void testPlayParamsRoundTrip() { + // A zone carrying explicit S15/S16 play params (Trigger mode, hold seconds, source-frame fades, + // Varispeed engine, pitch env on) must round-trip ALL fields losslessly through the v5 tail. + PerformanceMap m; + PerformanceZone z = zone("lead", 20, 100, /*override=*/55); + z.play.playMode = PlayMode::Trigger; + z.play.adsr.holdSeconds = 0.028; // wall-clock seconds + z.play.trigger.lengthFraction = 0.375; + z.play.trigger.fadeInFrames = 64; // source frames + z.play.trigger.fadeOutFrames = 128; + z.play.pitchEngine = PitchEngine::Varispeed; + z.play.pitchEnv.enabled = true; + z.play.pitchEnv.attackSeconds = 0.0002; // wall-clock seconds + z.play.pitchEnv.decaySeconds = 0.011; + z.play.pitchEnv.peakSemitones = -7.5; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const ZonePlaySeconds& p = back.zones[0].play; + CHECK(p.playMode == PlayMode::Trigger); + CHECK(p.adsr.holdSeconds == 0.028); // exact double round-trip + CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip + CHECK(p.trigger.fadeInFrames == 64); + CHECK(p.trigger.fadeOutFrames == 128); + CHECK(p.pitchEngine == PitchEngine::Varispeed); + CHECK(p.pitchEnv.enabled == true); + CHECK(p.pitchEnv.attackSeconds == 0.0002); + CHECK(p.pitchEnv.decaySeconds == 0.011); + CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip +} + +static void testPlayParamsComposeWithLoopStart() { + // S11 (loop/start) x S15/S16 (play params) tails co-exist per zone: both round-trip together. + PerformanceMap m; + PerformanceZone z = zone("pad", 0, 60); + SampleLoop lp; lp.hasLoop = true; lp.start = 111; lp.end = 222; + z.loopOverride = lp; + z.startPoint = 333; + z.play.playMode = PlayMode::Gate; + z.play.adsr.holdSeconds = 0.0225; + z.play.pitchEngine = PitchEngine::Preserve; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + CHECK(back.zones[0].loopOverride.has_value() && + back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222); + CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333); + CHECK(back.zones[0].play.adsr.holdSeconds == 0.0225); + CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); +} + +static void testPlayParamsV2BackCompatLiftsToDefaults() { + // A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail) + // lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the + // deliberate behavior change for already-saved instruments. Hand-build a v2 record exactly. + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + u32(kPerformanceStateVersion); // envelope version (2) + u32(kZonesFormatMarker); // marker -> a versioned payload + u32(2); // PAYLOAD VERSION 2 (S11, no play tail) + u32(1); // zone count 1 + const std::string id = "old"; + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(5); // lowNote + u32(80); // highNote + b.push_back(0); // hasRootOverride = 0 + b.push_back(0); // hasLoopOverride = 0 + b.push_back(0); // hasStartPoint = 0 (record ends here in v2) + const PerformanceMap back = deserializePerformance(b, 44100.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + CHECK(back.zones[0].sampleId == "old"); + // Lifted to product defaults: Gate play mode, PRESERVE engine (the S16-F1 default), env off. + CHECK(back.zones[0].play.playMode == PlayMode::Gate); + CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve + CHECK(back.zones[0].play.pitchEnv.enabled == false); + CHECK(back.zones[0].play.adsr.holdSeconds == 0.0); +} + +static void testPlayParamsThroughComponentEnvelope() { + // The play params round-trip through the v4 COMPONENT envelope too (the composition property: + // the zones payload is envelope-independent, so v4 {channelMode, selection, zones} carries them). + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + PerformanceZone z = zone("z", 0, 127); + z.play.playMode = PlayMode::Trigger; + z.play.trigger.lengthFraction = 0.9; + z.play.pitchEngine = PitchEngine::Varispeed; + s.map.zones.push_back(z); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.channelMode == ChannelMode::Stereo); + CHECK(back.map.zones.size() == 1); + if (back.map.zones.size() != 1) return; + CHECK(back.map.zones[0].play.playMode == PlayMode::Trigger); + CHECK(back.map.zones[0].play.trigger.lengthFraction == 0.9); + CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed); +} + +// --- S12 domain fix: wall-clock ADSR stored as SECONDS, resolved to frames at the live rate. --- +// +// These tests replace the R1/R2 flag/nominal-frame tests. The stored domain is seconds (rate-free); +// the keymap build resolves seconds -> frames against whatever WAV rate is live. The lift -> +// commit -> reload sequence must stay rate-correct at every rate (the R2 blocker). + +// All five AHDSR fields round-trip through the v5 payload as SECONDS (exact double round-trip). +static void testFullAdsrSecondsRoundTrip() { + PerformanceMap m; + PerformanceZone z = zone("pad", 0, 127); + z.play.playMode = PlayMode::Gate; + z.play.adsr.attackSeconds = 0.01; + z.play.adsr.holdSeconds = 0.02; + z.play.adsr.decaySeconds = 0.1; + z.play.adsr.sustainLevel = 0.7; + z.play.adsr.releaseSeconds = 0.2; + z.play.pitchEngine = PitchEngine::Preserve; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const AdsrSeconds& a = back.zones[0].play.adsr; + CHECK(a.attackSeconds == 0.01); + CHECK(a.holdSeconds == 0.02); + CHECK(a.decaySeconds == 0.1); + CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast + CHECK(a.releaseSeconds == 0.2); + CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); +} + +// A legacy PAYLOAD v3 blob (Daniel's beta projects — has holdFrames but no A/D/S/R) lifts the +// absent A/D/S/R to the tier-0 SECONDS defaults (0.003 / 0 / 1.0 / 0.060), NO rate involved: they +// were always the seconds constants. holdSeconds converts from the v3 44.1k-nominal frame count. +static void testV3BlobLiftsAdsrToSecondsDefaults() { + std::vector blob; + auto u32 = [&](std::uint32_t v) { + blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); + blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF); + }; + u32(kPerformanceStateVersion); // envelope version 2 header + const std::vector payload = handBuildV3PayloadOneZone("old"); + blob.insert(blob.end(), payload.begin(), payload.end()); + const PerformanceMap back = deserializePerformance(blob, 44100.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const AdsrSeconds& a = back.zones[0].play.adsr; + // hold converts from the v3 record's frames at the passed project rate (44100.0 here). + CHECK(approx(a.holdSeconds, 2048.0 / 44100.0)); // from the hand-built v3 record + CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds) + CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0 + CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0 + CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060 +} + +// A legacy PAYLOAD v3 blob decoded at 96k: the hold frame count (2048) converts using the +// PASSED project rate, not a baked 44100 constant. At 96000 the seconds value is 2048/96000. +static void testV3BlobLiftsAdsrAt96k() { + std::vector blob; + auto u32 = [&](std::uint32_t v) { + blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); + blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF); + }; + u32(kPerformanceStateVersion); // envelope version 2 header + const std::vector payload = handBuildV3PayloadOneZone("old96k"); + blob.insert(blob.end(), payload.begin(), payload.end()); + const PerformanceMap back = deserializePerformance(blob, 96000.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const AdsrSeconds& a = back.zones[0].play.adsr; + // 2048 frames at 96000 Hz -> 2048/96000 seconds (not 2048/44100). + CHECK(approx(a.holdSeconds, 2048.0 / 96000.0)); + CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); + CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); + CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); + CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); +} + +// The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone +// resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate)); +// an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates. +static void testKeymapBuildResolvesSecondsToFramesAtEachRate() { + const auto rnd = [](double s, int rate) { + return static_cast(s * static_cast(rate) + 0.5); + }; + for (int rate : {44100, 48000, 96000}) { + // (a) DEFAULT zone (round-tripped through serialize/deserialize) -> tier-0 seconds. + { + PerformanceMap m; + m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds) + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) continue; + ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60; + rz.play = back.zones[0].play; + const DecodedZonePcm pcm{{0.5f}, rate}; + const Keymap km = buildZonedKeymap({rz}, {pcm}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) continue; + const AdsrParams& a = km.samples[0].play.adsr; + CHECK(a.attackFrames == rnd(0.003, rate)); // tier-0 attack at this rate + CHECK(a.decayFrames == 0); + CHECK(a.sustainLevel == 1.0); // level, never rate-scaled + CHECK(a.releaseFrames == rnd(0.060, rate)); // tier-0 release at this rate + } + // (b) AUTHORED zone -> round(seconds * rate) at this rate. + { + PerformanceMap m; + PerformanceZone z = zone("auth", 0, 127); + z.play.adsr.attackSeconds = 0.01; + z.play.adsr.decaySeconds = 0.1; + z.play.adsr.sustainLevel = 0.7; + z.play.adsr.releaseSeconds = 0.2; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) continue; + ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60; + rz.play = back.zones[0].play; + const DecodedZonePcm pcm{{0.5f}, rate}; + const Keymap km = buildZonedKeymap({rz}, {pcm}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) continue; + const AdsrParams& a = km.samples[0].play.adsr; + CHECK(a.attackFrames == rnd(0.01, rate)); + CHECK(a.decayFrames == rnd(0.1, rate)); + CHECK(a.sustainLevel == 0.7); // level, never rate-scaled + CHECK(a.releaseFrames == rnd(0.2, rate)); + } + } +} + +// buildTier0Keymap resolves the default (seconds) play arg to frames at the WAV's rate — the +// single-capture fast path. At 48k the tier-0 attack is round(0.003*48000)=144, release +// round(0.060*48000)=2880 — identical wall-clock to any rate, no baked constant. +static void testBuildTier0KeymapResolvesSecondsAt48k() { + const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) return; + const AdsrParams& a = km.samples[0].play.adsr; + CHECK(a.attackFrames == 144); // round(0.003 * 48000) + CHECK(a.decayFrames == 0); + CHECK(a.sustainLevel == 1.0); // level, not a time + CHECK(a.releaseFrames == 2880); // round(0.060 * 48000) +} + +int main() { + testSelectByIdHit(); + testSelectEmptyIdIsSilence(); + testSelectUnknownIdIsSilence(); + testSelectRootNoteDefault(); + testSelectLoopThreaded(); + testSelectNoLoopIsAbsent(); + testSelectEmptyBlob(); + testSelectMalformedBlob(); + testSelectZeroSamples(); + testListSamplesOrdinalOrder(); + testListSamplesCarriesCardMetadata(); + testListSamplesEmptyAndMalformed(); + testListBanksOrdinalOrder(); + testListBanksEmptyAndMalformed(); + testDownmixMonoPassthrough(); + testDownmixStereoAverages(); + testDownmixThreeChannelAverages(); + testDownmixDegenerate(); + testBuildKeymapSingleFullZone(); + testSelectionStateRoundTrip(); + testSelectionStateEmptyId(); + testSelectionStateWrongVersion(); + testSelectionStateTruncated(); + testWavTrimToDownmixPipelineStereo(); + testWavTrimToDownmixPipelineMono(); + testResolveEmptyMap(); + testResolveEmptyBlob(); + testResolveMultiZoneAcrossBanks(); + testResolveStaleIdDropsZone(); + testResolveRootPrecedence(); + testResolveLoopThreaded(); + testResolveLoopOverrideWins(); + testResolveLoopOverrideDisablesLoop(); + testBuildZonedKeymapMultiZone(); + testBuildZonedKeymapThreadsLoopAndStart(); + testBuildZonedKeymapDropsEmptyPcm(); + testBuildZonedKeymapOverlapFirstWins(); + testBuildZonedKeymapEmpty(); + testPerformanceStateRoundTrip(); + testPerformanceStateEmpty(); + testPerformanceStateLoopStartRoundTrip(); + testPerformanceStateV1PayloadBackCompat(); + testPerformanceStateV1BackCompat(); + testPerformanceStateGarbage(); + testPerformanceStateNegativeNotesRoundTrip(); + testPlayParamsRoundTrip(); + testPlayParamsComposeWithLoopStart(); + testPlayParamsV2BackCompatLiftsToDefaults(); + testPlayParamsThroughComponentEnvelope(); + testFullAdsrSecondsRoundTrip(); + testV3BlobLiftsAdsrToSecondsDefaults(); + testV3BlobLiftsAdsrAt96k(); + testKeymapBuildResolvesSecondsToFramesAtEachRate(); + testBuildTier0KeymapResolvesSecondsAt48k(); + testComponentStateRoundTrip(); + testComponentStateLoopStartRoundTrip(); + testComponentStateSelectionOnlyNoZones(); + testComponentStateEmptyIsEmpty(); + testComponentStateV1BackCompat(); + testComponentStateV2BackCompat(); + testComponentStateGarbage(); + testExtractChannelStereo(); + testExtractChannelClampsToLast(); + testDecodeChannelsMonoModeDownmixes(); + testDecodeChannelsStereoModeStereoSource(); + testDecodeChannelsStereoModeMonoSourceDualMono(); + testBuildKeymapStereoCarriesSecondChannel(); + testBuildKeymapMonoWhenNoSecondChannel(); + testBuildKeymapDropsMismatchedSecondChannel(); + testBuildZonedKeymapCarriesSecondChannel(); + testComponentStateV4RoundTripStereo(); + testComponentStateV4RoundTripMono(); + testComponentStateV4DefaultIsMono(); + testComponentStateV3LiftsToMono(); + testComponentStateV1V2LiftToMono(); + testComponentStateV4TruncatedModeByte(); + testComponentStateV4StereoWithZoneOverridesRoundTrip(); + testComponentStateV5MarkerRoundTrip(); + testComponentStateDefaultMarkerIsZero(); + testComponentStateV4LiftsMarkerToZero(); + testComponentStateV5TruncatedMarker(); + testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); + testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay(); + + if (g_fail == 0) std::printf("sample_map: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp new file mode 100644 index 0000000..66153b2 --- /dev/null +++ b/tests/test_sampler_core.cpp @@ -0,0 +1,1297 @@ +// Standalone tests for reasampler::sampler_core — no VST3, no REAPER, no test +// framework. Same fast build/run loop as bank_model_tests / peaks_tests: feed known +// inputs, assert the engine's behavior. +// +// Covers (PLAN.md S3 / CONTEXT.md §Phase S pure core): +// 1. polyphonic allocation — N notes -> N voices; note-off releases the right voice. +// 2. voice stealing at the bound — deterministic policy (release-first, then oldest). +// 3. ADSR envelope shape vs a known signal, incl. release-before-sustain. +// 4. repitch ratio correctness across +/-1 octave from root incl. unity, asserted on +// the observed period of a synthesized sine. +// 5. loop-point sustain — held note past sample end loops [start,end) seamlessly; +// zero-length loop and absent-loop behavior. +// 6. keymap: chromatic-from-single-root; zoned ranges with boundary notes; velocity +// -> volume; out-of-zone note -> defined no-play. +// +// The plain-data boundary (no VST3/REAPER types in the core) is enforced STRUCTURALLY +// by the CMake target linking neither SDK — this file includes only sampler_core.h + +// the standard library, which is itself the compile-time proof. + +#include "../src/vst/sampler_core.h" + +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; } + +constexpr double kPi = 3.14159265358979323846; + +// A silent (all-1.0) sample so a rendered voice's output tracks the envelope * velocity +// directly (DC of amplitude 1). Root at note 60 by default. +static SampleData dcSample(std::size_t frames, int rootNote = 60) { + SampleData s; + s.frames.assign(frames, 1.0f); + s.rootNote = rootNote; + return s; +} + +// A mono sine of `cycles` periods over `frames` frames — used to observe repitch by +// measuring the played-back period. +static SampleData sineSample(std::size_t frames, double cycles, int rootNote = 60) { + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * cycles * + static_cast(i) / static_cast(frames))); + } + s.rootNote = rootNote; + return s; +} + +// An ADSR that stays fully open (level 1) forever while held, so voice output equals +// velocity gain — isolates allocation/repitch/loop tests from envelope shaping. +static AdsrParams flatAdsr() { + AdsrParams a; + a.attackFrames = 0; + a.decayFrames = 0; + a.sustainLevel = 1.0; + a.releaseFrames = 0; // note-off -> instant silence. + return a; +} + +// --------------------------------------------------------------------------- +// 6. Keymap resolution. +// --------------------------------------------------------------------------- + +static void testChromaticSingleRoot() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); + CHECK(km.zones.size() == 1); + // Every note in 0..127 resolves to the single zone. + for (int n = 0; n <= 127; ++n) { + ZoneResolution r = km.resolve(n, 100); + CHECK(r.matched); + CHECK(r.zoneIndex == 0); + } +} + +static void testZonedRangesBoundaries() { + Keymap km; + km.samples.push_back(dcSample(100, 48)); // low sample + km.samples.push_back(dcSample(100, 72)); // high sample + // Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the + // correct zone; a first-match order test would catch an off-by-one. + km.zones.push_back(KeyZone{36, 59, 48, 0}); + km.zones.push_back(KeyZone{60, 83, 72, 1}); + + CHECK(km.resolve(36, 100).matched); + CHECK(km.resolve(36, 100).zoneIndex == 0); + CHECK(km.resolve(59, 100).zoneIndex == 0); // last note of zone 0 + CHECK(km.resolve(60, 100).zoneIndex == 1); // first note of zone 1 + CHECK(km.resolve(83, 100).zoneIndex == 1); // last note of zone 1 + + // Out of every zone -> defined no-play (not a match, not zone 0). + CHECK(!km.resolve(35, 100).matched); + CHECK(!km.resolve(84, 100).matched); + CHECK(!km.resolve(127, 100).matched); +} + +static void testFirstMatchOnOverlap() { + // Overlapping zones: the earlier zone wins (documented deterministic rule). + Keymap km; + km.samples.push_back(dcSample(10, 60)); + km.samples.push_back(dcSample(10, 60)); + km.zones.push_back(KeyZone{0, 127, 60, 0}); // catch-all first + km.zones.push_back(KeyZone{60, 60, 60, 1}); // shadowed by the catch-all + CHECK(km.resolve(60, 100).zoneIndex == 0); +} + +// --------------------------------------------------------------------------- +// 4. Repitch ratio correctness. +// --------------------------------------------------------------------------- + +static void testPitchRatioMath() { + CHECK(approx(pitchRatio(60, 60), 1.0, 1e-9)); // unity at root + CHECK(approx(pitchRatio(72, 60), 2.0, 1e-9)); // +1 octave + CHECK(approx(pitchRatio(48, 60), 0.5, 1e-9)); // -1 octave + CHECK(approx(pitchRatio(61, 60), std::pow(2.0, 1.0 / 12.0), 1e-9)); // +1 semitone +} + +// Observe repitch on the rendered signal: a voice played an octave above root should +// advance through the sample twice as fast, so a sine's observed period halves. We +// measure the period by counting the interval between positive-going zero crossings. +static double observedPeriodFrames(const std::vector& out) { + std::vector upCrossings; + for (std::size_t i = 1; i < out.size(); ++i) { + if (out[i - 1] <= 0.0f && out[i] > 0.0f) upCrossings.push_back(i); + } + if (upCrossings.size() < 2) return 0.0; + // Average spacing between crossings. + double sum = 0.0; + for (std::size_t i = 1; i < upCrossings.size(); ++i) { + sum += static_cast(upCrossings[i] - upCrossings[i - 1]); + } + return sum / static_cast(upCrossings.size() - 1); +} + +static void testRepitchObservedPeriod() { + // A sine of 20 cycles over 8000 frames -> native period 400 frames at unity. + const std::size_t frames = 8000; + const double cycles = 20.0; + const double nativePeriod = static_cast(frames) / cycles; // 400 + + // Unity: played at root, observed period ~= native. + { + Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); + VoiceEngine eng(4, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, frames); + double p = observedPeriodFrames(out); + CHECK(approx(p, nativePeriod, 2.0)); + } + // +1 octave: advances 2x, observed period halves. + { + Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); + VoiceEngine eng(4, km); + eng.noteOn(72, 127); + std::vector out; + eng.render(out, frames / 2); // half as many frames covers the whole sample + double p = observedPeriodFrames(out); + CHECK(approx(p, nativePeriod / 2.0, 2.0)); + } + // -1 octave: advances 0.5x, observed period doubles. + { + Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); + VoiceEngine eng(4, km); + eng.noteOn(48, 127); + std::vector out; + eng.render(out, frames); + double p = observedPeriodFrames(out); + CHECK(approx(p, nativePeriod * 2.0, 4.0)); + } +} + +// --------------------------------------------------------------------------- +// 3. ADSR envelope shape vs a known signal. +// --------------------------------------------------------------------------- + +static void testAdsrShape() { + AdsrParams p; + p.attackFrames = 10; + p.decayFrames = 10; + p.sustainLevel = 0.5; + p.releaseFrames = 10; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + + // Attack: 0 -> ramps up. Frame 0 == 0, rising each frame. + double prev = -1.0; + for (int i = 0; i < 10; ++i) { + double v = env.tick(); + CHECK(v >= prev); // monotonic non-decreasing through attack + CHECK(v >= 0.0 && v <= 1.0); + prev = v; + } + // Decay: from 1.0 down toward sustain 0.5, monotonic non-increasing. + prev = 2.0; + for (int i = 0; i < 10; ++i) { + double v = env.tick(); + CHECK(v <= prev + 1e-9); // non-increasing through decay + CHECK(v >= 0.5 - 1e-9); // never below sustain during decay + prev = v; + } + // Sustain: holds 0.5 indefinitely. + for (int i = 0; i < 100; ++i) { + CHECK(approx(env.tick(), 0.5, 1e-9)); + } + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); + + // Release: 0.5 -> 0 over 10 frames, then Finished + latched at 0. + env.noteOff(); + prev = 1.0; + for (int i = 0; i < 10; ++i) { + double v = env.tick(); + CHECK(v <= prev + 1e-9); // non-increasing through release + prev = v; + } + CHECK(env.finished()); + for (int i = 0; i < 10; ++i) CHECK(approx(env.tick(), 0.0, 1e-12)); +} + +static void testAdsrReleaseBeforeSustain() { + // noteOff during the attack ramp releases from the PARTIAL level, not sustain. + AdsrParams p; + p.attackFrames = 100; + p.decayFrames = 10; + p.sustainLevel = 0.8; + p.releaseFrames = 20; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + + // Advance 50 frames into a 100-frame attack -> partial level ~0.5. + double last = 0.0; + for (int i = 0; i < 50; ++i) last = env.tick(); + CHECK(last > 0.3 && last < 0.7); // partway up the attack ramp + CHECK(env.stage() == AdsrEnvelope::Stage::Attack); + + env.noteOff(); + CHECK(env.stage() == AdsrEnvelope::Stage::Release); + // First release frame must be at or below the partial level we left off at — + // NOT jump up to sustain 0.8. This is the release-before-sustain guarantee. + double firstRelease = env.tick(); + CHECK(firstRelease <= last + 1e-9); + CHECK(firstRelease < p.sustainLevel); // proves it did not snap to sustain + // Decays to zero. + double prev = firstRelease; + for (int i = 0; i < 20; ++i) { + double v = env.tick(); + CHECK(v <= prev + 1e-9); + prev = v; + } + CHECK(env.finished()); +} + +static void testAdsrZeroAttackDecay() { + // Zero attack + zero decay -> jumps straight to sustain on the first ticks. + AdsrParams p; + p.attackFrames = 0; + p.decayFrames = 0; + p.sustainLevel = 0.7; + p.releaseFrames = 5; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + // Zero attack emits the attack peak (1.0) on frame 0 and immediately transitions + // through the (also zero-length) decay, so by frame 1 the envelope is holding + // sustain. The peak-at-boundary is the documented single-frame edge, not a bug. + CHECK(approx(env.tick(), 1.0, 1e-9)); // frame 0: attack peak + CHECK(approx(env.tick(), 0.7, 1e-9)); // frame 1: sustain + CHECK(approx(env.tick(), 0.7, 1e-9)); + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); +} + +// --------------------------------------------------------------------------- +// 1. Polyphonic allocation + note-off routing. +// --------------------------------------------------------------------------- + +static void testPolyphonicAllocation() { + Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60)); + VoiceEngine eng(8, km); + + // Four simultaneous notes -> four active voices, each on a distinct voice. + std::size_t v60 = eng.noteOn(60, 100); + std::size_t v64 = eng.noteOn(64, 100); + std::size_t v67 = eng.noteOn(67, 100); + std::size_t v72 = eng.noteOn(72, 100); + CHECK(v60 != VoiceEngine::kNoVoice); + CHECK(eng.activeVoiceCount() == 4); + CHECK(v60 != v64 && v64 != v67 && v67 != v72 && v60 != v72); + + // Note-off on 64 releases exactly one voice; with instant release it goes idle + // after the next render frame. + eng.noteOff(64); + std::vector out; + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 3); + + // The still-held notes keep sounding. + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 3); +} + +static void testNoteOffReleasesNewestSameNote() { + // Prove that noteOff releases the NEWEST (highest startOrder) instance of a + // re-triggered note, leaving the older voice in sustain. + // + // Two voices at distinct velocities so their output is distinguishable: + // "first" (older) -> velocity 64 -> gain ~0.504 (G_old) + // "second" (newer) -> velocity 127 -> gain 1.0 (G_new) + // + // With a DC-1 sample and sustain=1, while both are held: + // render sum == G_old + G_new. + // + // After noteOff (must release newest), the newer voice enters a short release. + // Render past releaseFrames: newer voice finishes; only the older voice remains. + // Sum then equals G_old, and activeVoiceCount drops to 1. If the WRONG voice + // were released, the older would finish and the remaining sum would equal G_new + // (1.0 vs ~0.504) — the velocities make the error distinguishable. + const int velOld = 64; + const int velNew = 127; + const double gainOld = velOld / 127.0; // ~0.504 + const double gainNew = velNew / 127.0; // 1.0 + + SampleData sd = dcSample(100000, 60); + sd.play.adsr = flatAdsr(); + sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release + Keymap km = Keymap::singleSampleChromatic(sd); + VoiceEngine eng(8, km); + + std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain + std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain + CHECK(first != second); + CHECK(eng.activeVoiceCount() == 2); + + // While both are held, combined output equals gainOld + gainNew. + { + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], gainOld + gainNew, 1e-4)); + } + + // Release once — must target the NEWEST voice (second). + eng.noteOff(60); + + // Render past the release (releaseFrames == 10): newer voice goes Finished. + std::vector out; + eng.render(out, 20); + + // Newer voice must be done; only the older voice remains. + CHECK(eng.activeVoiceCount() == 1); + // Tail frames must equal gainOld (~0.504), NOT gainNew (1.0). + // If the older voice were released instead, the tail would be ~1.0 here. + for (std::size_t i = 15; i < out.size(); ++i) { + CHECK(approx(out[i], gainOld, 1e-4)); + } + + // A second note-off releases the remaining older voice. + eng.noteOff(60); + eng.render(out, 20); + CHECK(eng.activeVoiceCount() == 0); +} + +static void testOutOfZoneNoteConsumesNoVoice() { + Keymap km; + km.samples.push_back(dcSample(100, 60)); + km.zones.push_back(KeyZone{60, 72, 60, 0}); + VoiceEngine eng(4, km); + + std::size_t v = eng.noteOn(30, 100); // below the only zone + CHECK(v == VoiceEngine::kNoVoice); + CHECK(eng.activeVoiceCount() == 0); // no voice consumed +} + +// --------------------------------------------------------------------------- +// 2. Voice stealing at the bound. +// --------------------------------------------------------------------------- + +static void testStealsReleasingVoiceFirst() { + // Long per-zone release so the voice stays active through the release tail. Voice::start reads + // sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData. + SampleData s = dcSample(100000, 60); + s.play.adsr = flatAdsr(); + s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active" + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(2, km); + + std::size_t vA = eng.noteOn(60, 100); // startOrder 1 + std::size_t vB = eng.noteOn(62, 100); // startOrder 2 + CHECK(eng.activeVoiceCount() == 2); + + // Release the NEWER voice (62) — it becomes the only releasing voice. + eng.noteOff(62); + std::vector out; + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 2); // both still ringing (long release) + + // A new note with the pool full must steal the RELEASING voice (vB), not the + // older held voice (vA) — release-first policy. + std::size_t vC = eng.noteOn(64, 100); + CHECK(vC == vB); + CHECK(eng.activeVoiceCount() == 2); +} + +static void testStealsOldestWhenNoneReleasing() { + // Long per-zone release — placed on SampleData.play.adsr per the S12 fix. + SampleData s = dcSample(100000, 60); + s.play.adsr = flatAdsr(); + s.play.adsr.releaseFrames = 100000; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(2, km); + + std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest) + std::size_t vB = eng.noteOn(62, 100); // startOrder 2 + CHECK(vA != vB); + + // No voice released; both held. A new note steals the OLDEST (vA). + std::size_t vC = eng.noteOn(64, 100); + CHECK(vC == vA); + CHECK(eng.activeVoiceCount() == 2); + + // The stolen voice now carries note 64; a note-off on 60 (the stolen-away note) + // finds nothing to release. + std::size_t before = eng.activeVoiceCount(); + eng.noteOff(60); + std::vector out; + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == before); // 60 no longer exists; no-op +} + +// --------------------------------------------------------------------------- +// 5. Loop-point-aware sustain. +// --------------------------------------------------------------------------- + +static void testLoopSustainSeamless() { + // A sample whose [0,20) frames are a distinctive ramp and [20,40) is a flat loop + // region of value 0.5. Held far past the sample end, the voice must keep emitting + // the loop region (0.5) rather than going silent. + SampleData s; + s.frames.resize(40); + for (int i = 0; i < 20; ++i) s.frames[i] = static_cast(i) / 20.0f; // attack + for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body + s.rootNote = 60; + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 40; + + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); // unity ratio, full velocity + + std::vector out; + eng.render(out, 200); // 5x the sample length + + // Voice is still active (looping), not exhausted. + CHECK(eng.activeVoiceCount() == 1); + // Frames well past the loop start must sit at the loop body value 0.5. + for (std::size_t i = 60; i < out.size(); ++i) { + CHECK(approx(out[i], 0.5, 1e-4)); + } +} + +static void testZeroLengthLoopGoesSilent() { + // A zero-length loop (start == end) is the "no sustain" marker: the note runs off + // the sample end and the voice goes idle, rather than spinning on an empty span. + SampleData s = dcSample(50, 60); // 50 frames of 1.0 + s.loop.hasLoop = true; + s.loop.start = 25; + s.loop.end = 25; // zero length + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + + std::vector out; + eng.render(out, 100); // past the 50-frame end + // After frame ~50 the voice should have gone idle (no loop to sustain it). + CHECK(eng.activeVoiceCount() == 0); + // Tail frames are silent. + for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); +} + +static void testSingleFrameLoop() { + // A loop of exactly one frame [start, start+1) — the narrowest valid loop. + // The path is correct-by-luck (loopLen = 1.0 divides evenly into any integer + // readPos advance at unity ratio), but Tier-2 tight loops make it load-bearing. + SampleData s; + s.frames.resize(10); + for (int i = 0; i < 10; ++i) s.frames[i] = static_cast(i) * 0.1f; + s.rootNote = 60; + s.loop.hasLoop = true; + s.loop.start = 5; + s.loop.end = 6; // single-frame loop: [5, 6) + + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); // unity ratio, full velocity + + std::vector out; + eng.render(out, 50); // well past the sample end + + // Voice must still be active — the single-frame loop keeps it alive. + CHECK(eng.activeVoiceCount() == 1); + // Every frame from the loop-start onward must be the value of frame 5 (0.5). + for (std::size_t i = 10; i < out.size(); ++i) { + CHECK(approx(out[i], 0.5, 1e-4)); + } +} + +static void testAbsentLoopGoesSilent() { + // No loop at all: held note runs off the end and goes idle (same as zero-length). + SampleData s = dcSample(50, 60); + // s.loop.hasLoop stays false. + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 100); + CHECK(eng.activeVoiceCount() == 0); + for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); +} + +// --------------------------------------------------------------------------- +// start point (S11): the voice's initial read position is SampleData::startFrame. +// --------------------------------------------------------------------------- + +static void testStartFrameOffsetsInitialRead() { + // A per-frame ramp (frame i holds i*0.01) so the first rendered value pinpoints the + // read position. startFrame = 30 -> the first output frame reads frame 30 (0.30). + SampleData s; + s.frames.resize(100); + for (int i = 0; i < 100; ++i) s.frames[i] = static_cast(i) * 0.01f; + s.rootNote = 60; + s.startFrame = 30; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); // unity ratio, full velocity, flat gain + std::vector out; + eng.render(out, 3); + CHECK(approx(out[0], 0.30, 1e-4)); // starts at frame 30, not 0 + CHECK(approx(out[1], 0.31, 1e-4)); // advances by unity ratio + CHECK(approx(out[2], 0.32, 1e-4)); +} + +static void testStartFrameZeroIsUnchanged() { + // startFrame default 0 is exactly the pre-S11 behavior: read begins at frame 0. + SampleData s; + s.frames.resize(20); + for (int i = 0; i < 20; ++i) s.frames[i] = static_cast(i) * 0.05f; + s.rootNote = 60; // startFrame stays 0 + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 0.0, 1e-6)); // frame 0 +} + +static void testStartFrameOutOfRangeClampsToZero() { + // A start point at/past the sample end degrades to frame 0 (play from the top), never an + // out-of-bounds read that would start the voice already exhausted. + SampleData s = dcSample(10, 60); // 10 frames of 1.0 + s.startFrame = 10; // == frameCount: out of range + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 5); + // Reads from frame 0: the DC sample plays its 1.0 body rather than an immediate idle. + CHECK(eng.activeVoiceCount() == 1); + CHECK(approx(out[0], 1.0, 1e-4)); +} + +static void testStartFrameWithLoop() { + // Start point and loop compose: begin reading mid-sample, then sustain the loop region. + SampleData s; + s.frames.resize(40); + for (int i = 0; i < 40; ++i) s.frames[i] = static_cast(i) * 0.01f; + for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body + s.rootNote = 60; + s.startFrame = 10; // begin at frame 10 + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 40; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 200); + CHECK(approx(out[0], 0.10, 1e-4)); // started at frame 10 + CHECK(eng.activeVoiceCount() == 1); // loop sustains it + for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.5, 1e-4)); +} + +static void testStartAfterLoopEndWrapsIntoLoop() { + // Regression (S11 reviewer finding): if startFrame > loop.end (but still < frameCount), + // the voice's initial read head is past the loop end. The wrap-while in renderFrame must + // pull it back into [loopStart, loopEnd) on the very first frame, so the note sounds from + // somewhere inside the loop rather than running off the sample end silently. + // + // Setup: 100-frame sample; loop is [20, 40); startFrame = 60 (past loop.end = 40). + // loop body is a constant 0.5 so every frame inside it reads 0.5. + // After wrap: readPos starts inside [20, 40), first output frame == 0.5. + // Voice must stay active (loop sustains it) and emit the loop value, NOT go silent. + SampleData s; + s.frames.resize(100, 0.0f); + for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body + s.rootNote = 60; + s.startFrame = 60; // > loop.end (40), < frameCount (100) + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 40; + + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); // unity ratio, full velocity + + std::vector out; + eng.render(out, 50); + + // Voice must still be active — the usable loop keeps it alive indefinitely. + CHECK(eng.activeVoiceCount() == 1); + // Every frame after the first wrap must read 0.5 (the loop body). We skip the very + // first frame because the fractional-position wrap lands somewhere in [20,40) and the + // exact offset depends on how many loop lengths fit into 60; what matters is that the + // voice is alive and emitting the loop value, not 0.0 (pre-loop region). + for (std::size_t i = 5; i < out.size(); ++i) { + CHECK(approx(out[i], 0.5, 1e-4)); + } +} + +// --------------------------------------------------------------------------- +// velocity -> volume. +// --------------------------------------------------------------------------- + +static void testVelocityToVolume() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 + // Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the + // rendered value is exactly velocity/127 on a DC-1 sample). + { + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 1.0, 1e-4)); + } + { + VoiceEngine eng(1, km); + eng.noteOn(60, 64); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 64.0 / 127.0, 1e-4)); + } + { + VoiceEngine eng(1, km); + eng.noteOn(60, 1); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 1.0 / 127.0, 1e-4)); + } +} + +// Two voices summed: polyphony mixes additively. +static void testPolyphonyMixesAdditively() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 + VoiceEngine eng(4, km); + eng.noteOn(60, 127); // gain 1.0 + eng.noteOn(60, 127); // gain 1.0 (second voice, same note) + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 2.0, 1e-4)); // both voices sum +} + +// --------------------------------------------------------------------------- +// 7. Stereo channel dimension (S7). +// --------------------------------------------------------------------------- + +// A distinct-per-channel stereo DC sample: L = `l`, R = `r` everywhere. A stereo render +// must keep them distinct; a mono render (channel 0 only) sees L. +static SampleData stereoDcSample(std::size_t frames, float l, float r, int rootNote = 60) { + SampleData s; + s.frames.assign(frames, l); + s.framesR.assign(frames, r); + s.rootNote = rootNote; + return s; +} + +static void testChannelCount() { + // Mono: framesR empty -> 1 channel. Stereo: matching-length framesR -> 2. + CHECK(dcSample(10, 60).channelCount() == 1); + CHECK(stereoDcSample(10, 1.0f, -1.0f).channelCount() == 2); + // A mismatched framesR length is treated as mono (a bad pair never half-plays). + SampleData bad = dcSample(10, 60); + bad.framesR.assign(5, 0.5f); // wrong length + CHECK(bad.channelCount() == 1); +} + +static void testStereoRenderKeepsChannelsDistinct() { + // A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each + // scaled by velocity (full here). If the engine copied L to both channels the R check fails. + Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + + std::vector left(8, 0.f), right(8, 0.f); + eng.render(left.data(), right.data(), 8); + for (std::size_t i = 0; i < 8; ++i) { + CHECK(approx(left[i], 1.0, 1e-4)); // channel 0 + CHECK(approx(right[i], -1.0, 1e-4)); // channel 1 — distinct, NOT a copy of L + } +} + +static void testMonoSamplePlaysDualMonoInStereo() { + // A MONO sample rendered through the stereo path plays dual-mono: both channels equal + // (centered), not silent on the right. The cross-mode "mono source in stereo mode" case. + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0 + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector left(8, 0.f), right(8, 0.f); + eng.render(left.data(), right.data(), 8); + for (std::size_t i = 0; i < 8; ++i) { + CHECK(approx(left[i], 1.0, 1e-4)); + CHECK(approx(right[i], 1.0, 1e-4)); // R == L (dual-mono), not 0 + } +} + +static void testMonoRenderUnchangedByStereoData() { + // Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical + // whether or not a second channel is present. A stereo sample rendered mono == its L channel. + Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60)); + VoiceEngine engS(1, kmS); + engS.noteOn(60, 127); + std::vector mono; + engS.render(mono, 8); // the mono overload + for (std::size_t i = 0; i < 8; ++i) CHECK(approx(mono[i], 0.75, 1e-4)); // == L, ignores R +} + +static void testStereoRenderAdvancesLikeMonoRepitch() { + // The stereo path must advance the read head by the SAME per-frame ratio as the mono path, + // so repitch is identical. Play a stereo sine (both channels the same signal) an octave up + // and confirm the observed period halves — the mono repitch assertion, on the stereo path. + const std::size_t frames = 8000; + const double cycles = 20.0; + const double nativePeriod = static_cast(frames) / cycles; // 400 + SampleData s; + s.frames.resize(frames); + s.framesR.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + const float v = static_cast(std::sin(2.0 * kPi * cycles * + static_cast(i) / static_cast(frames))); + s.frames[i] = v; + s.framesR[i] = v; + } + s.rootNote = 60; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(4, km); + eng.noteOn(72, 127); // +1 octave + std::vector left(frames / 2, 0.f), right(frames / 2, 0.f); + eng.render(left.data(), right.data(), frames / 2); + CHECK(approx(observedPeriodFrames(left), nativePeriod / 2.0, 2.0)); + CHECK(approx(observedPeriodFrames(right), nativePeriod / 2.0, 2.0)); // R repitches identically +} + +static void testStereoRenderSumsVoicesPerChannel() { + // Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo). + Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60)); + VoiceEngine eng(4, km); + eng.noteOn(60, 127); + eng.noteOn(60, 127); // second voice, same note + std::vector left(1, 0.f), right(1, 0.f); + eng.render(left.data(), right.data(), 1); + CHECK(approx(left[0], 1.0, 1e-4)); // 0.5 + 0.5 + CHECK(approx(right[0], -1.0, 1e-4)); // -0.5 + -0.5 +} + +static void testStereoRenderNullBufferIsNoOp() { + Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector buf(4, 0.f); + eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash + eng.render(buf.data(), nullptr, 4); // null right -> no-op + for (float v : buf) CHECK(approx(v, 0.0, 1e-9)); // untouched +} + +static void testStereoStartFrameLoopShareOneReadHead() { + // S7 x S11 compose: a STEREO sample with a startFrame AND a sustain loop must read BOTH + // channels from the SAME single read head — one offset, one loop wrap, applied to L and R + // identically (only the sampled value differs). A per-frame L/R ramp that is a fixed offset + // apart (R = L + 0.5) pins the read position on both channels: if the stereo path ever gave + // L and R independent heads, the constant L->R offset would break at the start jump or the + // loop seam. + SampleData s; + s.frames.resize(40); + s.framesR.resize(40); + for (int i = 0; i < 40; ++i) { + s.frames[i] = static_cast(i) * 0.01f; // L: 0.00 .. 0.39 + s.framesR[i] = static_cast(i) * 0.01f + 0.5f; // R: L + 0.5, everywhere + } + s.rootNote = 60; + s.startFrame = 10; // begin BOTH channels at frame 10 + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 30; // loop [20,30): frames 20..29 + CHECK(s.channelCount() == 2); + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); // unity ratio, full velocity, flat gain + + std::vector left(200, 0.f), right(200, 0.f); + eng.render(left.data(), right.data(), 200); + + // First frame: both channels start at frame 10 (L=0.10, R=0.60) — the shared start offset. + CHECK(approx(left[0], 0.10, 1e-4)); + CHECK(approx(right[0], 0.60, 1e-4)); + // The loop sustains the voice indefinitely. + CHECK(eng.activeVoiceCount() == 1); + // At every rendered frame R - L == 0.5 exactly: both channels read the SAME frame index + // (one read head) through the start jump and every loop wrap. A per-channel head drift would + // break this invariant at the seam. + for (std::size_t i = 0; i < left.size(); ++i) { + CHECK(approx(right[i] - left[i], 0.5, 1e-4)); + } + // Once fully inside the loop (start=10 -> reaches loop.start=20 within a handful of unity-ratio + // frames), every L value sits in the loop band [0.20, 0.30): the shared head is sustaining the + // loop region on both channels, never running off the sample end. + for (std::size_t i = 15; i < left.size(); ++i) { + CHECK(left[i] >= 0.20 - 1e-4 && left[i] < 0.30 + 1e-4); + } +} + +// =========================================================================== +// S15 — sampling modes (Gate AHDSR hold stage, Trigger %-length + fades, note-off immunity). +// =========================================================================== + +// --- AHDSR hold stage vs a known signal. --- +static void testAhdsrHoldStageShape() { + // Gate grows a HOLD stage between Attack and Decay: attack 0->1 (5f), HOLD at 1.0 (8f), + // decay 1->0.5 (5f), sustain 0.5. Assert the hold plateau is exactly 1.0 for holdFrames. + AdsrParams p; + p.attackFrames = 5; + p.holdFrames = 8; + p.decayFrames = 5; + p.sustainLevel = 0.5; + p.releaseFrames = 5; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + + for (int i = 0; i < 5; ++i) env.tick(); // consume Attack (ends at 1.0) + // The next holdFrames ticks must all be exactly 1.0 (the plateau), stage == Hold. + for (int i = 0; i < 8; ++i) { + CHECK(env.stage() == AdsrEnvelope::Stage::Hold); + CHECK(approx(env.tick(), 1.0, 1e-9)); + } + // Then Decay begins, falling from 1.0 toward sustain 0.5. + CHECK(env.stage() == AdsrEnvelope::Stage::Decay); + double v = env.tick(); + CHECK(v <= 1.0 + 1e-9 && v >= 0.5 - 1e-9); +} + +// --- hold == 0 is byte-identical to the pre-S15 ADSR (back-compat regression). --- +static void testAhdsrHoldZeroEqualsAdsr() { + // The load-bearing back-compat guarantee: hold=0 reproduces the classic ADSR frame-for-frame. + // Assert against a HAND-COMPUTED expected sequence (not another envelope — that would be + // tautological). attack 4, hold 0, decay 4, sustain 0.5. Expected per-tick output: + // Attack: 0/4, 1/4, 2/4, 3/4 (ticks 0..3, level rising 0 -> 0.75) + // Decay: 1.0, then 1.0+(0.5-1)*t for t=1/4..3/4 (ticks 4..7: 1.0, 0.875, 0.75, 0.625) + // Sustain: 0.5 forever (tick 8+) + AdsrParams p; + p.attackFrames = 4; + p.holdFrames = 0; // the degenerate — must NOT insert an extra unity frame + p.decayFrames = 4; + p.sustainLevel = 0.5; + p.releaseFrames = 4; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + const double expected[] = {0.0, 0.25, 0.5, 0.75, // attack + 1.0, 0.875, 0.75, 0.625, // decay (first sample 1.0 at t=0) + 0.5, 0.5, 0.5}; // sustain + for (double e : expected) CHECK(approx(env.tick(), e, 1e-9)); + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); // reached sustain at the SAME tick count +} + +// A trigger-mode DC sample (all 1.0) so a rendered voice's output tracks the trigger envelope +// * velocity directly. `play` sets Trigger mode + params; Varispeed so no shift colours the amp. +static SampleData triggerSample(std::size_t frames, double lengthFraction, + std::int64_t fadeIn, std::int64_t fadeOut, + std::int64_t startFrame = 0) { + SampleData s = dcSample(frames, 60); + s.startFrame = startFrame; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; // isolate amp shape from pitch + s.play.trigger.lengthFraction = lengthFraction; + s.play.trigger.fadeInFrames = fadeIn; + s.play.trigger.fadeOutFrames = fadeOut; + return s; +} + +// --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. --- +static void testTriggerLengthFractionFrames() { + // 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees. + Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); // unity ratio + std::vector out; + eng.render(out, 200); + // First 100 frames sound (amp>0 for a no-fade trigger = 1.0), then silence + voice freed. + for (std::size_t i = 0; i < 100; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 100; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); // ran off playEnd +} + +// --- Trigger start point: %-length measured from the start offset. --- +static void testTriggerLengthWithStart() { + // 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free. + Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 200); + for (std::size_t i = 0; i < 80; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 80; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); +} + +// --- Trigger fade-in / fade-out ramp shape (equal-power default). --- +static void testTriggerFadeShape() { + // 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity + // between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1. + Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 120); + CHECK(approx(out[0], 0.0, 1e-3)); // fade-in starts at 0 + // Fade-in monotonic non-decreasing. + for (std::size_t i = 1; i < 20; ++i) CHECK(out[i] >= out[i - 1] - 1e-4); + // Unity plateau in the middle. + for (std::size_t i = 25; i < 75; ++i) CHECK(approx(out[i], 1.0, 1e-3)); + // Fade-out monotonic non-increasing over [80,100). + for (std::size_t i = 81; i < 100; ++i) CHECK(out[i] <= out[i - 1] + 1e-4); + // Past playEnd = silence. + for (std::size_t i = 100; i < 120; ++i) CHECK(approx(out[i], 0.0, 1e-6)); +} + +// --- Trigger edge cases: %=0 (immediate free) and fades overlapping (clamped). --- +static void testTriggerEdgeCases() { + // %=0: zero play length -> voice frees at once, no sound. + { + Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 50); + for (float v : out) CHECK(approx(v, 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); + } + // Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]). + { + // 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped. + Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 50); + for (std::size_t i = 0; i < 40; ++i) CHECK(out[i] >= -1e-4 && out[i] <= 1.0 + 1e-4); + CHECK(eng.activeVoiceCount() == 0); + } + // %=100 plays the full post-start span. + { + Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 80); + for (std::size_t i = 0; i < 60; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 60; i < 80; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + } +} + +// --- Trigger ignores note-off (S15): the one-shot plays through regardless. --- +static void testTriggerIgnoresNoteOff() { + Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 10); + eng.noteOff(60); // must be a NO-OP in Trigger + CHECK(eng.activeVoiceCount() == 1); // still sounding after note-off + eng.render(out, 200); + // It still plays its full 100-frame length (frames 10..99 remain > 0 after the note-off). + for (std::size_t i = 10; i < 100; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 100; i < 210; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); // frees on its own playEnd, not on note-off +} + +// =========================================================================== +// S16 — pitch engine (Preserve duration invariance) + pitch envelope (off = identical). +// =========================================================================== + +// Render one note to completion (or `maxFrames`) and return the frame count at which the voice +// went idle (the audible LENGTH). A Gate note with a short release + a finite sample runs off. +static std::size_t soundingLength(VoiceEngine& eng, std::size_t maxFrames) { + std::vector out; + std::size_t len = 0; + for (std::size_t f = 0; f < maxFrames; ++f) { + eng.render(out, 1); + if (eng.activeVoiceCount() > 0) len = f + 1; + else break; + } + return len; +} + +// A Preserve-engine one-shot Trigger sample: under Preserve, the %-length wall-clock is stable +// under transpose (the S15xS16 contract). Trigger + Preserve isolates the length measurement from +// Gate's release tail. +static SampleData preserveTriggerSample(std::size_t frames, double lengthFraction) { + SampleData s = dcSample(frames, 60); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Preserve; + s.play.trigger.lengthFraction = lengthFraction; + return s; +} + +// --- Preserve duration invariance: same note length across +/-12 semitones. --- +static void testPreserveDurationInvariance() { + // A Preserve Trigger at 100% length of a 1000-frame sample plays ~1000 output frames + // regardless of transpose (duration held). Under Varispeed an octave up would halve it. + const std::size_t frames = 1000; + const std::size_t window = 512; // pre-size the shifters + + auto lengthAt = [&](int note) -> std::size_t { + Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0)); + VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast(window)); + eng.noteOn(note, 127); + return soundingLength(eng, 4000); + }; + + const std::size_t atRoot = lengthAt(60); + const std::size_t atUp = lengthAt(72); // +12 + const std::size_t atDown = lengthAt(48); // -12 + // All three within a small tolerance of the source length (Preserve holds duration). The + // tolerance covers the shifter's fill/latency edge, not a duration scaling (which would be 2x). + CHECK(atRoot >= frames - 20 && atRoot <= frames + 20); + CHECK(atUp >= frames - 20 && atUp <= frames + 20); + CHECK(atDown >= frames - 20 && atDown <= frames + 20); + // The decisive assertion: the up/down lengths track the root length (NOT halved/doubled). + CHECK(atUp > frames / 2 + 200); // an octave up did NOT halve the duration (Varispeed would) + CHECK(atDown < frames * 2 - 200); // an octave down did NOT double it +} + +// --- Varispeed still couples duration (the contrast to Preserve — regression on the old default). --- +static void testVarispeedStillCouplesDuration() { + // A Varispeed Trigger octave up runs off in ~half the frames (pitch & duration coupled). + auto lengthAt = [&](int note) -> std::size_t { + SampleData s = dcSample(1000, 60); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.trigger.lengthFraction = 1.0; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(note, 127); + return soundingLength(eng, 4000); + }; + const std::size_t atRoot = lengthAt(60); + const std::size_t atUp = lengthAt(72); + CHECK(approx(static_cast(atUp), static_cast(atRoot) / 2.0, 30.0)); +} + +// --- Pitch envelope OFF == bit-identical to the un-modulated engine (regression). --- +static void testPitchEnvOffBitIdentical() { + // Two Varispeed voices, one with a disabled pitch env, one with no pitch env at all. Their + // rendered output must be BIT-IDENTICAL (pitch-env-off applies zero modulation — the S16 + // "identical to pre-S16" guarantee). Uses a sine so any pitch drift would show as phase drift. + const std::size_t n = 4000; + auto renderOne = [&](bool withDisabledEnv) -> std::vector { + SampleData s = sineSample(n, 20.0, 60); + s.play.pitchEngine = PitchEngine::Varispeed; + if (withDisabledEnv) { + s.play.pitchEnv.enabled = false; // explicitly disabled (offset always 0) + s.play.pitchEnv.peakSemitones = 12.0; // a depth that WOULD matter if enabled + s.play.pitchEnv.attackFrames = 0; + s.play.pitchEnv.decayFrames = 500; + } + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path) + std::vector out; + eng.render(out, n); + return out; + }; + const std::vector a = renderOne(false); + const std::vector b = renderOne(true); + CHECK(a.size() == b.size()); + bool identical = a.size() == b.size(); + for (std::size_t i = 0; i < a.size() && identical; ++i) { + if (a[i] != b[i]) identical = false; + } + CHECK(identical); // disabled pitch env produces the EXACT same samples (no modulation) +} + +// --- Pitch envelope ON biases pitch (Varispeed): a positive-peak zero-attack env starts sharp. --- +static void testPitchEnvOnBendsVarispeed() { + // Zero attack + positive peak = "start high, drop to base": the note begins transposed UP and + // settles. Observe the read advancing FASTER at the start (period shorter early) than late. + const std::size_t n = 8000; + SampleData s = sineSample(n, 40.0, 60); + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.attackFrames = 0; // start at the peak + s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames + s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0 + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend + std::vector out; + eng.render(out, 4000); + // Early period (heavily transposed up) should be shorter than the late period (settled). + std::vector early(out.begin(), out.begin() + 800); + std::vector late(out.begin() + 3200, out.begin() + 4000); + const double pe = observedPeriodFrames(early); + const double pl = observedPeriodFrames(late); + CHECK(pe > 0.0 && pl > 0.0); + CHECK(pe < pl); // pitch dropped over time (period lengthened) -> the AD env bent the pitch +} + +// --- Compose: engine x mode x stereo x loop (a Preserve Gate loop in stereo sounds + sustains). --- +static void testPreserveGateStereoLoopComposes() { + // A STEREO sample, GATE mode, PRESERVE engine, with a sustain loop. It must sound on BOTH + // channels and sustain (the loop keeps the voice alive) — S7 x S15 x S16 all composing. + SampleData s; + const std::size_t frames = 400; + s.frames.resize(frames); + s.framesR.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + const float v = static_cast(std::sin(2.0 * kPi * 8.0 * + static_cast(i) / static_cast(frames))); + s.frames[i] = v; + s.framesR[i] = v * 0.5f; // R is a distinct (half-amplitude) channel + } + s.rootNote = 60; + s.loop.hasLoop = true; + s.loop.start = 100; + s.loop.end = 300; + s.play.playMode = PlayMode::Gate; + s.play.pitchEngine = PitchEngine::Preserve; + CHECK(s.channelCount() == 2); + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, 0, 512); + eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held) + std::vector left(2000, 0.f), right(2000, 0.f); + eng.render(left.data(), right.data(), 2000); + // The loop sustains the voice well past the sample length (400 frames) -> still active. + CHECK(eng.activeVoiceCount() == 1); + // Both channels carry signal (some frame has non-trivial magnitude on each). + double maxL = 0.0, maxR = 0.0; + for (std::size_t i = 600; i < 2000; ++i) { + if (std::fabs(left[i]) > maxL) maxL = std::fabs(left[i]); + if (std::fabs(right[i]) > maxR) maxR = std::fabs(right[i]); + } + CHECK(maxL > 0.05); + CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved +} + +// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. --- +static void testPreserveVoiceCap() { + SampleData s = dcSample(2000, 60); + s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + // 8 voices total, Preserve cap of 2. + VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256); + CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice + CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap) + CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap + CHECK(eng.activeVoiceCount() == 2); +} + +// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). --- +// +// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at +// keymap build); the engine holds no instrument-wide ADSR. These two tests assert that path. + +// The zone's attackFrames drives the envelope ramp. Strategy: put an explicit 10-frame attack on +// the SampleData.play.adsr. If Voice::start reads the zone ADSR, the DC-1 output will be 0 at frame +// 0 and 1.0 after the 10-frame ramp; a voice that ignored the zone ADSR (instant) would already be +// 1.0 at frame 0. This is the load-bearing proof. +static void testPerZoneAdsrReachesVoiceEnvelope() { + SampleData s = dcSample(500, 60); + // Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release. + s.play.adsr.attackFrames = 10; + s.play.adsr.holdFrames = 0; + s.play.adsr.decayFrames = 0; + s.play.adsr.sustainLevel = 1.0; + s.play.adsr.releaseFrames = 0; + s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0 + std::vector out; + eng.render(out, 20); + // Frame 0: attack start, envelope near 0. A voice ignoring the zone ADSR would read 1.0 here. + CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp + // Frame 9: still ramping (last attack frame, linear ramp reaches 0.9). + CHECK(out[9] < 1.0 - 1e-9); + // Frame 10+: attack complete, sustain at 1.0. + CHECK(approx(out[10], 1.0, 1e-9)); + CHECK(approx(out[19], 1.0, 1e-9)); +} + +// Default-valued zone (AdsrParams all zeros) is behavior-identical to the pre-fix flat path. +// A zero-init AdsrParams (attackFrames=0, decayFrames=0, sustainLevel=1.0, releaseFrames=0) must +// yield an instant-attack/instant-sustain voice — frame 0 immediately at 1.0. This preserves the +// back-compat invariant: an old zone with no A/D/S/R storage sounds the same as before. +static void testZeroAdsrIsInstantSustain() { + SampleData s = dcSample(20, 60); + // Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp. + s.play.adsr = AdsrParams{}; + s.play.pitchEngine = PitchEngine::Varispeed; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 5); + // All frames must be 1.0: zero attack + sustain 1.0 = instantly at full level. + for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9)); +} + +int main() { + testChromaticSingleRoot(); + testZonedRangesBoundaries(); + testFirstMatchOnOverlap(); + testPitchRatioMath(); + testRepitchObservedPeriod(); + testAdsrShape(); + testAdsrReleaseBeforeSustain(); + testAdsrZeroAttackDecay(); + testPolyphonicAllocation(); + testNoteOffReleasesNewestSameNote(); + testOutOfZoneNoteConsumesNoVoice(); + testStealsReleasingVoiceFirst(); + testStealsOldestWhenNoneReleasing(); + testLoopSustainSeamless(); + testZeroLengthLoopGoesSilent(); + testSingleFrameLoop(); + testAbsentLoopGoesSilent(); + testStartFrameOffsetsInitialRead(); + testStartFrameZeroIsUnchanged(); + testStartFrameOutOfRangeClampsToZero(); + testStartFrameWithLoop(); + testStartAfterLoopEndWrapsIntoLoop(); + testVelocityToVolume(); + testPolyphonyMixesAdditively(); + testChannelCount(); + testStereoRenderKeepsChannelsDistinct(); + testMonoSamplePlaysDualMonoInStereo(); + testMonoRenderUnchangedByStereoData(); + testStereoRenderAdvancesLikeMonoRepitch(); + testStereoRenderSumsVoicesPerChannel(); + testStereoRenderNullBufferIsNoOp(); + testStereoStartFrameLoopShareOneReadHead(); + + // S15 — sampling modes. + testAhdsrHoldStageShape(); + testAhdsrHoldZeroEqualsAdsr(); + testTriggerLengthFractionFrames(); + testTriggerLengthWithStart(); + testTriggerFadeShape(); + testTriggerEdgeCases(); + testTriggerIgnoresNoteOff(); + + // S16 — pitch engine + pitch envelope. + testPreserveDurationInvariance(); + testVarispeedStillCouplesDuration(); + testPitchEnvOffBitIdentical(); + testPitchEnvOnBendsVarispeed(); + testPreserveGateStereoLoopComposes(); + testPreserveVoiceCap(); + + // S12 review fix — per-zone A/D/S/R reaches the voice envelope. + testPerZoneAdsrReachesVoiceEnvelope(); + testZeroAdsrIsInstantSustain(); + + if (g_fail == 0) { + std::printf("all sampler_core tests passed\n"); + return 0; + } + std::printf("%d sampler_core check(s) failed\n", g_fail); + return 1; +} diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp new file mode 100644 index 0000000..2969cd9 --- /dev/null +++ b/tests/test_waveform_view.cpp @@ -0,0 +1,223 @@ +// Standalone tests for reasampler::vst::waveform_view — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests. Assert the S11 waveform surface's +// frame<->pixel mapping, marker grab regions, drag-delta frame resolver (with clamps), and +// the zero-crossing snap — the geometry + snap that back the draggable start/loop markers. +// +// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width); +// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection); +// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width +// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower, +// no-crossing keeps target, target clamp, degenerate buffers). + +#include "../src/vst/waveform_view.h" + +#include +#include + +using namespace reasampler::vst; +using reasampler::AudioSample; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs). +static Rect wideArea() { return Rect{20, 10, 1020, 90}; } // width 1000 + +// --- frameToX / xToFrame ------------------------------------------------------ + +static void testFrameToXEndpoints() { + const Rect a = wideArea(); + CHECK(frameToX(a, 1000, 0) == a.left); // frame 0 -> left edge + CHECK(frameToX(a, 1000, 1000) == a.right); // frameCount -> right edge + CHECK(frameToX(a, 1000, 500) == a.left + 500); // midpoint (1:1 here) +} + +static void testFrameToXClampsOutOfRange() { + const Rect a = wideArea(); + CHECK(frameToX(a, 1000, -50) == a.left); // below 0 pins left + CHECK(frameToX(a, 1000, 5000) == a.right); // above count pins right +} + +static void testFrameToXDegenerate() { + const Rect a = wideArea(); + CHECK(frameToX(a, 0, 100) == a.left); // no frames -> left + const Rect z = Rect{5, 5, 5, 45}; // zero width + CHECK(frameToX(z, 1000, 500) == z.left); +} + +static void testXToFrameInverse() { + const Rect a = wideArea(); + CHECK(xToFrame(a, 1000, a.left) == 0); + CHECK(xToFrame(a, 1000, a.right) == 1000); + CHECK(xToFrame(a, 1000, a.left + 250) == 250); // 1:1 map here +} + +static void testXToFrameClampsOutside() { + const Rect a = wideArea(); + CHECK(xToFrame(a, 1000, a.left - 100) == 0); // left of area -> 0 + CHECK(xToFrame(a, 1000, a.right + 100) == 1000); // right of area -> frameCount + CHECK(xToFrame(a, 0, a.left + 10) == 0); // no frames -> 0 +} + +static void testFrameToXRoundTrip() { + // Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then + // xToFrame should land within a couple frames (rounding both directions). + const Rect a = Rect{0, 0, 800, 60}; + for (std::int64_t f = 0; f <= 2000; f += 137) { + const int x = frameToX(a, 2000, f); + const std::int64_t back = xToFrame(a, 2000, x); + CHECK(back >= f - 3 && back <= f + 3); + } +} + +// --- markerAtPoint ------------------------------------------------------------ + +static void testMarkerAtPointGrabsWithinBand() { + const Rect a = wideArea(); + // Markers at frames 100, 500, 900 -> x = left+100, left+500, left+900. + const std::int64_t frames[3] = {100, 500, 900}; + const int midY = a.top + a.height() / 2; + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 100, midY) == 0); + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 900, midY) == 2); + // Within the grab band on either side of the line. + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 + kMarkerGrabWidth, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 - kMarkerGrabWidth, midY) == 1); +} + +static void testMarkerAtPointMissesBetween() { + const Rect a = wideArea(); + const std::int64_t frames[3] = {100, 500, 900}; + const int midY = a.top + a.height() / 2; + // Well away from any marker line. + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 300, midY) == -1); + // Off the area vertically. + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, a.top - 5) == -1); +} + +static void testMarkerAtPointFirstMatchOnOverlap() { + const Rect a = wideArea(); + // Two markers at the same frame -> first in order wins. + const std::int64_t frames[2] = {400, 400}; + const int midY = a.top + a.height() / 2; + CHECK(markerAtPoint(a, 1000, frames, 2, a.left + 400, midY) == 0); +} + +static void testMarkerAtPointRejectsNullEmpty() { + const Rect a = wideArea(); + const int midY = a.top + a.height() / 2; + CHECK(markerAtPoint(a, 1000, nullptr, 3, a.left + 100, midY) == -1); + const std::int64_t frames[1] = {100}; + CHECK(markerAtPoint(a, 1000, frames, 0, a.left + 100, midY) == -1); +} + +// --- resolveDragFrame --------------------------------------------------------- + +static void testResolveDragFrameShift() { + const Rect a = wideArea(); // 1:1 (1000px / 1000 frames) + CHECK(resolveDragFrame(a, 1000, 300, 0) == 300); // zero delta -> unchanged + CHECK(resolveDragFrame(a, 1000, 300, 100) == 400); // +100px -> +100 frames + CHECK(resolveDragFrame(a, 1000, 300, -50) == 250); // -50px -> -50 frames +} + +static void testResolveDragFrameClamps() { + const Rect a = wideArea(); + CHECK(resolveDragFrame(a, 1000, 50, -500) == 0); // clamp low + CHECK(resolveDragFrame(a, 1000, 950, 500) == 1000); // clamp high (== frameCount) +} + +static void testResolveDragFrameRounds() { + // 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is + // at the frame centre. Use a scale where a fractional result appears. + const Rect a = Rect{0, 0, 300, 60}; // 1000 frames / 300px = 3.33 frames/px + // +3px -> 3*1000/300 = 10.0 -> 10 frames. + CHECK(resolveDragFrame(a, 1000, 100, 3) == 110); + // +1px -> 1000/300 = 3.33 -> rounds to 3. + CHECK(resolveDragFrame(a, 1000, 100, 1) == 103); +} + +static void testResolveDragFrameDegenerate() { + const Rect z = Rect{0, 0, 0, 60}; // zero width + CHECK(resolveDragFrame(z, 1000, 300, 100) == 300); // pinned to start + const Rect a = wideArea(); + CHECK(resolveDragFrame(a, 0, 300, 100) == 0); // no frames -> clamp(start)=0 + // startFrame out of range is clamped first. + CHECK(resolveDragFrame(a, 1000, 5000, 0) == 1000); +} + +// --- nearestZeroCrossing ------------------------------------------------------ + +static void testZeroCrossingNearest() { + // Crossings (sign change from i-1 to i): i=4 (1->-1), i=5 (-1->1), i=10 (1->-1). + std::vector pcm = {1, 1, 1, 1, -1, 1, 1, 1, 1, 1, -1, -1}; + // Target 4 is itself a crossing -> 4. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 4); + // Nearest to 6: crossing 5 (dist 1) beats 4 (dist 2) and 10 (dist 4) -> 5. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 6) == 5); + // Nearest to 9: crossing 10 (dist 1) beats 5 (dist 4) -> 10. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 9) == 10); +} + +static void testZeroCrossingSampleOnZero() { + // A sample exactly 0 is its own crossing (frame index of the zero sample). + std::vector pcm = {1, 1, 0, 1, 1}; + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2); + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 3) == 2); +} + +static void testZeroCrossingEquidistantTieToLower() { + // Crossings at i=2 (1->-1) and i=6 (-1->1). Target 4 is equidistant (dist 2) -> lower (2). + std::vector pcm = {1, 1, -1, -1, -1, -1, 1, 1}; + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 2); +} + +static void testZeroCrossingNoneKeepsTarget() { + // All one sign -> no crossing -> the (clamped) target comes back unchanged. + std::vector pcm = {0.5f, 0.6f, 0.7f, 0.8f}; + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2); +} + +static void testZeroCrossingClampsTarget() { + std::vector pcm = {1, -1, 1, -1}; // crossings at 1,2,3 + // Target beyond the end clamps to frames-1 (3) then finds crossing at 3. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 999) == 3); + // Negative target clamps to 0; nearest crossing is 1. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), -999) == 1); +} + +static void testZeroCrossingDegenerate() { + CHECK(nearestZeroCrossing(nullptr, 0, 5) == 0); + std::vector one = {1}; + CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target +} + +int main() { + testFrameToXEndpoints(); + testFrameToXClampsOutOfRange(); + testFrameToXDegenerate(); + testXToFrameInverse(); + testXToFrameClampsOutside(); + testFrameToXRoundTrip(); + + testMarkerAtPointGrabsWithinBand(); + testMarkerAtPointMissesBetween(); + testMarkerAtPointFirstMatchOnOverlap(); + testMarkerAtPointRejectsNullEmpty(); + + testResolveDragFrameShift(); + testResolveDragFrameClamps(); + testResolveDragFrameRounds(); + testResolveDragFrameDegenerate(); + + testZeroCrossingNearest(); + testZeroCrossingSampleOnZero(); + testZeroCrossingEquidistantTieToLower(); + testZeroCrossingNoneKeepsTarget(); + testZeroCrossingClampsTarget(); + testZeroCrossingDegenerate(); + + if (g_fail == 0) std::printf("waveform_view: all tests passed\n"); + else std::printf("waveform_view: %d FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/vendor/vst3sdk b/vendor/vst3sdk new file mode 160000 index 0000000..dfff2e3 --- /dev/null +++ b/vendor/vst3sdk @@ -0,0 +1 @@ +Subproject commit dfff2e399c1a638bd7f5e334440a61e7262eed3f