Merge phase-s: Phase S complete — ReaSampler 9000 VST3 sampler (S1-S18), editor scale + control surfaces (S12), drop-and-load (S17), editor drop-accept (S13 relay degraded per spec)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -51,6 +51,7 @@ Key targets (see CMakeLists.txt for the full list):
|
||||
| `tooltip_tests` | executable | Pure unit tests for `tooltip` — no REAPER, no DAW. |
|
||||
| `card_drag_tests` | executable | Pure unit tests for `card_drag` — no REAPER, no DAW. |
|
||||
| `card_meta_tests` | executable | Pure unit tests for `card_meta` — no REAPER, no DAW. |
|
||||
| `pitch_shift_tests` | executable | Pure unit tests for `pitch_shift` (S16 Preserve engine) — no REAPER, no DAW. |
|
||||
| `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). |
|
||||
|
||||
### Beta channel build (Phase V, V4)
|
||||
|
||||
+355
-1
@@ -34,9 +34,13 @@ set(REASAMPLER_CHANNEL "stable" CACHE STRING "Build channel: stable (default) or
|
||||
if(REASAMPLER_CHANNEL STREQUAL "beta")
|
||||
set(REASAMPLER_CHANNEL_IS_BETA 1)
|
||||
set(REASAMPLER_OUTPUT_NAME "reaper_reasampler_beta")
|
||||
# The VST3 instrument's on-disk name forks the same way (S18) — must match
|
||||
# app_version::vstOutputName() so the artifact name and the in-binary self-id agree.
|
||||
set(REASAMPLER_VST_OUTPUT_NAME "reasampler_9000_beta")
|
||||
elseif(REASAMPLER_CHANNEL STREQUAL "stable")
|
||||
set(REASAMPLER_CHANNEL_IS_BETA 0)
|
||||
set(REASAMPLER_OUTPUT_NAME "reaper_reasampler")
|
||||
set(REASAMPLER_VST_OUTPUT_NAME "reasampler_9000")
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"REASAMPLER_CHANNEL must be 'stable' or 'beta' (got '${REASAMPLER_CHANNEL}')")
|
||||
@@ -301,6 +305,19 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge
|
||||
add_library(provenance STATIC src/provenance.cpp)
|
||||
target_include_directories(provenance PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2j') Pure assignment_request library — NO REAPER, NO SWELL, NO VST3. The S8 ingest
|
||||
# assignment-request wire format: the (bankId, sampleId, generation) value the
|
||||
# EXTENSION writes to "reasampler" ext-state after an ingest-with-assign, decoded by
|
||||
# the VST3 instrument in a later dispatch. Only the wire (build/parse round-trip)
|
||||
# lives here — writing it is the persist shell's job, reading it the instrument's.
|
||||
# Split out (mirror of provenance / owned_manifest) so the format both artifacts
|
||||
# depend on is unit-tested outside the DAW; the reader lands in a separate artifact,
|
||||
# so the round-trip test is the contract guard. No dependency — plain strings + int64.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(assignment_request STATIC src/assignment_request.cpp)
|
||||
target_include_directories(assignment_request PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2k) Pure action_buttons library — NO REAPER, NO SWELL. The Milestone 11
|
||||
# action-trigger button strip: strip rect + N buttons at a minimum width ->
|
||||
@@ -329,6 +346,20 @@ target_include_directories(action_buttons PUBLIC src)
|
||||
add_library(drag_out STATIC src/drag_out.cpp)
|
||||
target_include_directories(drag_out PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2l') Pure instrument_drop library — NO REAPER, NO SWELL, NO VST3 SDK. The S17
|
||||
# drop-and-load blob-construction core: turn the dragged capture id into the
|
||||
# base64 "vst_chunk" the extension injects via TrackFX_SetNamedConfigParm so a
|
||||
# freshly-added ReaSampler 9000 plays that capture. Reuses the instrument's OWN
|
||||
# serializer (sample_map::serializeComponentState) — NOT a parallel byte writer —
|
||||
# so the cross-artifact blob contract cannot drift; links sample_map (which pulls
|
||||
# bank_book/wav_trim/sampler_core transitively) and NEITHER SDK. The round-trip
|
||||
# test decodes back through the instrument's own reader. Mirror of assignment_request.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(instrument_drop STATIC src/instrument_drop.cpp)
|
||||
target_include_directories(instrument_drop PUBLIC src src/vst)
|
||||
target_link_libraries(instrument_drop PUBLIC sample_map)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2m) Pure theme library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) palette
|
||||
# core of the shared drawing kit: a ROLE-based color model (bg/base..warn), the
|
||||
@@ -446,6 +477,31 @@ add_library(card_drag STATIC src/card_drag.cpp)
|
||||
target_include_directories(card_drag PUBLIC src)
|
||||
target_link_libraries(card_drag PUBLIC drag_out bank_grid)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2v) Pure sampler_core library — NO VST3, NO REAPER, NO SWELL. The HEART of the
|
||||
# Phase S MIDI-playback instrument (S3 / D3): polyphonic voice allocation with
|
||||
# bounded stealing, an ADSR amplitude envelope, a key/velocity keymap with
|
||||
# (note, velocity) -> zone resolution, and repitch/interpolation from a root note
|
||||
# with loop-point-aware sustain. The mirror of bank_model / peaks / bank_book,
|
||||
# tested hard outside any host. Lives under src/vst/ (it is instrument code) but
|
||||
# links NEITHER SDK — the plain-data boundary is enforced structurally: the test
|
||||
# target below links only sampler_core (+ its peaks dep for the AudioSample alias,
|
||||
# the one house precedent wav_trim also relies on). The VST3 shell (src/vst/
|
||||
# reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified.
|
||||
# ---------------------------------------------------------------------------
|
||||
# pitch_shift (S16) — the pure duration-preserving PitchShifter (Preserve-engine DSP core).
|
||||
# NO VST3/REAPER/SWELL/vendor: a hand-rolled OLA shifter chosen over WDL_SimplePitchShifter
|
||||
# because that header drags <windows.h> (via wdltypes.h) into any TU that includes it, which
|
||||
# cannot enter the pure sampler_core. Links only peaks (the AudioSample alias). sampler_core
|
||||
# depends on it (Voice owns two PitchShifters).
|
||||
add_library(pitch_shift STATIC src/vst/pitch_shift.cpp)
|
||||
target_include_directories(pitch_shift PUBLIC src src/vst)
|
||||
target_link_libraries(pitch_shift PUBLIC peaks)
|
||||
|
||||
add_library(sampler_core STATIC src/vst/sampler_core.cpp)
|
||||
target_include_directories(sampler_core PUBLIC src src/vst)
|
||||
target_link_libraries(sampler_core PUBLIC peaks pitch_shift)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -548,6 +604,13 @@ add_executable(drag_out_tests tests/test_drag_out.cpp)
|
||||
target_link_libraries(drag_out_tests PRIVATE drag_out)
|
||||
add_test(NAME drag_out_tests COMMAND drag_out_tests)
|
||||
|
||||
# instrument_drop (S17): the drop-and-load vst_chunk blob builder. The round-trip test
|
||||
# decodes the base64 back through the instrument's OWN reader (deserializeComponentState) to
|
||||
# prove the extension injects exactly what setState accepts — the cross-artifact contract guard.
|
||||
add_executable(instrument_drop_tests tests/test_instrument_drop.cpp)
|
||||
target_link_libraries(instrument_drop_tests PRIVATE instrument_drop)
|
||||
add_test(NAME instrument_drop_tests COMMAND instrument_drop_tests)
|
||||
|
||||
add_executable(theme_tests tests/test_theme.cpp)
|
||||
target_link_libraries(theme_tests PRIVATE theme)
|
||||
add_test(NAME theme_tests COMMAND theme_tests)
|
||||
@@ -584,6 +647,176 @@ add_executable(card_drag_tests tests/test_card_drag.cpp)
|
||||
target_link_libraries(card_drag_tests PRIVATE card_drag)
|
||||
add_test(NAME card_drag_tests COMMAND card_drag_tests)
|
||||
|
||||
add_executable(assignment_request_tests tests/test_assignment_request.cpp)
|
||||
target_link_libraries(assignment_request_tests PRIVATE assignment_request)
|
||||
add_test(NAME assignment_request_tests COMMAND assignment_request_tests)
|
||||
|
||||
# sampler_core: the S3 heart. Links ONLY sampler_core (+ its peaks dep) — NEITHER the
|
||||
# VST3 SDK nor the REAPER SDK — which is the structural proof of the plain-data
|
||||
# boundary (a VST3/REAPER type in the core would fail to compile/link here).
|
||||
# pitch_shift (S16): the pure Preserve-engine OLA shifter. Links ONLY pitch_shift (+ peaks) —
|
||||
# NEITHER SDK — the same plain-data-boundary proof, and specifically the compile-time proof it
|
||||
# does NOT drag in the WDL <windows.h> chain the built-in WDL shifter would.
|
||||
add_executable(pitch_shift_tests tests/test_pitch_shift.cpp)
|
||||
target_link_libraries(pitch_shift_tests PRIVATE pitch_shift)
|
||||
add_test(NAME pitch_shift_tests COMMAND pitch_shift_tests)
|
||||
|
||||
add_executable(sampler_core_tests tests/test_sampler_core.cpp)
|
||||
target_link_libraries(sampler_core_tests PRIVATE sampler_core)
|
||||
add_test(NAME sampler_core_tests COMMAND sampler_core_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE.
|
||||
# editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math
|
||||
# (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge
|
||||
# read marshalling — GetProjExtState result decode + a small JSON string-field
|
||||
# reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW;
|
||||
# the VST3 shell (src/vst/*) that draws/routes/invokes is DAW-verified.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(editor_geometry STATIC src/vst/editor_geometry.cpp)
|
||||
target_include_directories(editor_geometry PUBLIC src/vst)
|
||||
|
||||
add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp)
|
||||
target_include_directories(bridge_marshal PUBLIC src/vst)
|
||||
|
||||
# embed_strip (Phase S6) — PURE layout + hit-test for the embedded TCP/MCP strip: the
|
||||
# 128-key span -> zone-segment rects, point -> zone selection, and the level-band fill.
|
||||
# The mirror of editor_geometry (whose Rect + contains() it reuses); unit-tested outside
|
||||
# the DAW, while the embed shell (src/vst/reasampler_embed.cpp) marshals REAPER's embed
|
||||
# messages (paint bitmap + mouse coords) into it. Links editor_geometry for the shared Rect.
|
||||
add_library(embed_strip STATIC src/vst/embed_strip.cpp)
|
||||
target_include_directories(embed_strip PUBLIC src/vst)
|
||||
target_link_libraries(embed_strip PUBLIC editor_geometry)
|
||||
|
||||
# sample_map (Phase S4) — PURE mapping logic for the Tier-0 instrument: the live bank
|
||||
# blob -> selected sample (via the SHARED bank_book JSON parse, NOT a second parser),
|
||||
# interleaved->mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap
|
||||
# build, and the selected-sample instance-state (de)serialization. Links the three pure
|
||||
# modules it composes — bank_book (shared JSON), wav_trim (shared WAV parse), and
|
||||
# sampler_core (the Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell
|
||||
# (reasampler_processor.cpp) does the bridge read + file I/O off the audio thread, then
|
||||
# calls these; the process callback stays allocation-free.
|
||||
add_library(sample_map STATIC src/vst/sample_map.cpp)
|
||||
target_include_directories(sample_map PUBLIC src/vst src)
|
||||
target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core)
|
||||
|
||||
# capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the
|
||||
# capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly
|
||||
# grid/tab arithmetic lives here, unit-tested outside the DAW; the editor shell draws each
|
||||
# card's peak thumbnail + name + badge and routes clicks into it. Links editor_geometry for
|
||||
# the shared Rect + contains(). NEITHER SDK.
|
||||
add_library(capture_browser STATIC src/vst/capture_browser.cpp)
|
||||
target_include_directories(capture_browser PUBLIC src/vst)
|
||||
target_link_libraries(capture_browser PUBLIC editor_geometry)
|
||||
|
||||
# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects +
|
||||
# edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's
|
||||
# keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of
|
||||
# embed_strip; links editor_geometry for the shared Rect. NEITHER SDK.
|
||||
add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp)
|
||||
target_include_directories(keyboard_strip PUBLIC src/vst)
|
||||
target_link_libraries(keyboard_strip PUBLIC editor_geometry)
|
||||
|
||||
# waveform_view (Phase S11) — PURE frame<->pixel mapping, marker grab regions, drag-delta
|
||||
# frame resolver, and the zero-crossing snap for the capture-first editor's waveform surface
|
||||
# (draggable start + loop markers over the picked capture's decoded PCM). The mirror of
|
||||
# keyboard_strip; links editor_geometry for the shared Rect and peaks for the AudioSample
|
||||
# alias the snap scans. NEITHER SDK.
|
||||
add_library(waveform_view STATIC src/vst/waveform_view.cpp)
|
||||
target_include_directories(waveform_view PUBLIC src/vst src)
|
||||
target_link_libraries(waveform_view PUBLIC editor_geometry peaks)
|
||||
|
||||
# bank_sync (Phase S9/S8 reader) — PURE decision logic for the instrument's off-audio-thread
|
||||
# poll: parse/compare the S9 bank-generation stamp, and the S8 assignment-request CONSUME
|
||||
# decision (new-and-resolvable-and-target -> apply; unresolvable -> drop-and-mark; non-target
|
||||
# -> stay eligible). The shell owns the timer cadence + side effects (reloadFromBank,
|
||||
# setSelectedSampleId, component-state marker); this owns only the yes/no maths, unit-tested
|
||||
# outside the DAW. Links assignment_request for the decoded AssignmentRequest it consumes.
|
||||
# NEITHER SDK.
|
||||
add_library(bank_sync STATIC src/vst/bank_sync.cpp)
|
||||
target_include_directories(bank_sync PUBLIC src/vst src)
|
||||
target_link_libraries(bank_sync PUBLIC assignment_request)
|
||||
|
||||
# browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search
|
||||
# geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect +
|
||||
# thumb-drag<->offset mapping, and the name-substring filter that composes with the bank
|
||||
# filter. The mirror of capture_browser; links capture_browser (for BrowserLayout + the card
|
||||
# metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK.
|
||||
add_library(browser_scroll STATIC src/vst/browser_scroll.cpp)
|
||||
target_include_directories(browser_scroll PUBLIC src/vst)
|
||||
target_link_libraries(browser_scroll PUBLIC capture_browser)
|
||||
|
||||
# note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of
|
||||
# a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to
|
||||
# [0,127]). No dependency beyond the standard library. NEITHER SDK.
|
||||
add_library(note_entry STATIC src/vst/note_entry.cpp)
|
||||
target_include_directories(note_entry PUBLIC src/vst)
|
||||
|
||||
# param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface
|
||||
# layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the
|
||||
# Gate|Trigger + Varispeed|Preserve toggles and the AHDSR / Trigger / pitch-env sliders). The
|
||||
# mirror of keyboard_strip; links editor_geometry for the shared Rect. Deliberately engine-free
|
||||
# (no sampler_core types) — the shell owns the control-id -> param binding + the value DOMAIN
|
||||
# mapping. NEITHER SDK.
|
||||
add_library(param_slider STATIC src/vst/param_slider.cpp)
|
||||
target_include_directories(param_slider PUBLIC src/vst)
|
||||
target_link_libraries(param_slider PUBLIC editor_geometry)
|
||||
|
||||
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
|
||||
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
|
||||
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
|
||||
|
||||
add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp)
|
||||
target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal)
|
||||
add_test(NAME bridge_marshal_tests COMMAND bridge_marshal_tests)
|
||||
|
||||
add_executable(embed_strip_tests tests/test_embed_strip.cpp)
|
||||
target_link_libraries(embed_strip_tests PRIVATE embed_strip)
|
||||
add_test(NAME embed_strip_tests COMMAND embed_strip_tests)
|
||||
|
||||
# sample_map: the S4 mapping heart. Links ONLY sample_map (+ its pure deps) — NEITHER
|
||||
# the VST3 SDK nor the REAPER SDK — the same structural plain-data-boundary proof the
|
||||
# sampler_core test enforces.
|
||||
add_executable(sample_map_tests tests/test_sample_map.cpp)
|
||||
target_link_libraries(sample_map_tests PRIVATE sample_map)
|
||||
add_test(NAME sample_map_tests COMMAND sample_map_tests)
|
||||
|
||||
add_executable(capture_browser_tests tests/test_capture_browser.cpp)
|
||||
target_link_libraries(capture_browser_tests PRIVATE capture_browser)
|
||||
add_test(NAME capture_browser_tests COMMAND capture_browser_tests)
|
||||
|
||||
add_executable(keyboard_strip_tests tests/test_keyboard_strip.cpp)
|
||||
target_link_libraries(keyboard_strip_tests PRIVATE keyboard_strip)
|
||||
add_test(NAME keyboard_strip_tests COMMAND keyboard_strip_tests)
|
||||
|
||||
# waveform_view (S11): the pure marker geometry + zero-crossing snap. Links ONLY waveform_view
|
||||
# (+ its pure editor_geometry/peaks deps) — NEITHER SDK — the same plain-data-boundary proof.
|
||||
add_executable(waveform_view_tests tests/test_waveform_view.cpp)
|
||||
target_link_libraries(waveform_view_tests PRIVATE waveform_view)
|
||||
add_test(NAME waveform_view_tests COMMAND waveform_view_tests)
|
||||
|
||||
# bank_sync (S9/S8 reader): the pure generation-parse + assignment-consume decision. Links
|
||||
# ONLY bank_sync (+ its assignment_request dep) — NEITHER SDK — the plain-data-boundary proof.
|
||||
add_executable(bank_sync_tests tests/test_bank_sync.cpp)
|
||||
target_link_libraries(bank_sync_tests PRIVATE bank_sync)
|
||||
add_test(NAME bank_sync_tests COMMAND bank_sync_tests)
|
||||
|
||||
# browser_scroll (S12): the pure scroll-window/thumb + search geometry over capture_browser.
|
||||
add_executable(browser_scroll_tests tests/test_browser_scroll.cpp)
|
||||
target_link_libraries(browser_scroll_tests PRIVATE browser_scroll)
|
||||
add_test(NAME browser_scroll_tests COMMAND browser_scroll_tests)
|
||||
|
||||
# note_entry (S12): the pure text->clamped-MIDI-note parse for direct numeric entry.
|
||||
add_executable(note_entry_tests tests/test_note_entry.cpp)
|
||||
target_link_libraries(note_entry_tests PRIVATE note_entry)
|
||||
add_test(NAME note_entry_tests COMMAND note_entry_tests)
|
||||
|
||||
# param_slider (S12 + S15/S16 control surfaces): the pure control-panel layout + slider/toggle
|
||||
# value<->pixel mapping the editor parameter surface draws + routes against.
|
||||
add_executable(param_slider_tests tests/test_param_slider.cpp)
|
||||
target_link_libraries(param_slider_tests PRIVATE param_slider)
|
||||
add_test(NAME param_slider_tests COMMAND param_slider_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -624,9 +857,11 @@ add_library(reaper_reasampler MODULE
|
||||
src/lane_keys.cpp
|
||||
src/item_read.cpp
|
||||
src/actions.cpp
|
||||
src/ingest.cpp
|
||||
src/bank_book.cpp
|
||||
src/owned_manifest.cpp
|
||||
src/drag_out_win.cpp
|
||||
src/instrument_drop_win.cpp
|
||||
src/action_bar.cpp
|
||||
src/footer_bar.cpp
|
||||
src/overflow_menu.cpp
|
||||
@@ -635,7 +870,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/card_meta.cpp
|
||||
src/card_drag.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
|
||||
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
|
||||
@@ -671,3 +906,122 @@ else()
|
||||
# php ${WDL_INC}/swell/mac_resgen.php src/resource.rc
|
||||
# target_sources(reaper_reasampler PRIVATE src/resource.rc_mac_dlg.h) # generated
|
||||
endif()
|
||||
|
||||
# ===========================================================================
|
||||
# 5) The ReaSampler VST3 instrument — the SECOND build artifact (Phase S1).
|
||||
#
|
||||
# Windows-only, VST3-only, REAPER-only (D5). A separate native VST3 plugin the user
|
||||
# instantiates on an instrument track. Additive: the reaper_reasampler target above
|
||||
# builds unchanged. This is the S1 opening spike — a silent-but-loading
|
||||
# SingleComponentEffect skeleton, an IPlugView<->LICE editor, and the REAPER VST-host
|
||||
# bridge read — not yet a sampler.
|
||||
#
|
||||
# ONE-TIME SDK SUBMODULE SETUP (see README / .gitmodules): the vst3sdk superproject is
|
||||
# vendored pinned to tag v3.7.9_build_61; only three of its sub-submodules are needed
|
||||
# (VSTGUI/examples/tests are NOT). After `git submodule update --init vendor/vst3sdk`:
|
||||
# cd vendor/vst3sdk && git submodule update --init pluginterfaces base public.sdk
|
||||
# ===========================================================================
|
||||
set(VST3_SDK ${CMAKE_CURRENT_SOURCE_DIR}/vendor/vst3sdk)
|
||||
# The VST3 module needs the nested vst3sdk slice (pluginterfaces / base / public.sdk)
|
||||
# checked out — the one-time step documented above. When it is absent (a fresh clone
|
||||
# that ran only the top-level `git submodule update --init`), skip the module rather than
|
||||
# fail configure on missing sources: the pure geometry/mapping libraries + their CTest
|
||||
# targets still build and test without the SDK. Probe one representative source file.
|
||||
if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
|
||||
# --- 5a) The bounded slice of the Steinberg VST3 SDK this spike needs. --------
|
||||
# Enumerated (not add_subdirectory of the whole SDK) to keep the build hermetic and
|
||||
# lean, matching the project's two-submodule discipline: no VSTGUI, no examples, no
|
||||
# SDK-global CMake helpers/install machinery. Pinned to tag v3.7.9_build_61, so the
|
||||
# list is fixed. If the SDK tag is bumped, re-verify this set.
|
||||
add_library(vst3_sdk STATIC
|
||||
# pluginterfaces/base — FUnknown, IIDs, string table, ustring.
|
||||
${VST3_SDK}/pluginterfaces/base/funknown.cpp
|
||||
${VST3_SDK}/pluginterfaces/base/coreiids.cpp
|
||||
${VST3_SDK}/pluginterfaces/base/conststringtable.cpp
|
||||
${VST3_SDK}/pluginterfaces/base/ustring.cpp
|
||||
# base/source — FObject, strings, buffers, streamer, debug, IIDs, update handler.
|
||||
${VST3_SDK}/base/source/fobject.cpp
|
||||
${VST3_SDK}/base/source/fstring.cpp
|
||||
${VST3_SDK}/base/source/fbuffer.cpp
|
||||
${VST3_SDK}/base/source/fstreamer.cpp
|
||||
${VST3_SDK}/base/source/fdebug.cpp
|
||||
${VST3_SDK}/base/source/baseiids.cpp
|
||||
${VST3_SDK}/base/source/updatehandler.cpp
|
||||
${VST3_SDK}/base/thread/source/flock.cpp
|
||||
# public.sdk/source/vst — the SingleComponentEffect base + its deps. NOTE:
|
||||
# vstsinglecomponenteffect.cpp #includes vsteditcontroller.cpp (unity-style), so
|
||||
# vsteditcontroller.cpp must NOT be listed separately (double definition).
|
||||
${VST3_SDK}/public.sdk/source/vst/vstsinglecomponenteffect.cpp
|
||||
${VST3_SDK}/public.sdk/source/vst/vstcomponentbase.cpp
|
||||
${VST3_SDK}/public.sdk/source/vst/vstbus.cpp
|
||||
${VST3_SDK}/public.sdk/source/vst/vstparameters.cpp
|
||||
${VST3_SDK}/public.sdk/source/vst/vstinitiids.cpp
|
||||
# public.sdk/source/common — CPluginView (IPlugView base) + IIDs.
|
||||
${VST3_SDK}/public.sdk/source/common/pluginview.cpp
|
||||
${VST3_SDK}/public.sdk/source/common/commoniids.cpp
|
||||
# public.sdk/source/main — the class-factory (GetPluginFactory) support. NOTE:
|
||||
# dllmain.cpp + moduleinit.cpp (which carry the InitDll/ExitDll dll exports) are
|
||||
# compiled into the MODULE target directly, NOT here: their SMTG_EXPORT_SYMBOL
|
||||
# functions have no internal referrer, so the linker strips them from a static
|
||||
# lib. Compiling them into the module keeps the exports.
|
||||
${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp
|
||||
)
|
||||
target_include_directories(vst3_sdk PUBLIC ${VST3_SDK})
|
||||
# The SDK requires exactly one of RELEASE / DEVELOPMENT (fdebug.cpp keys off it).
|
||||
target_compile_definitions(vst3_sdk PUBLIC $<IF:$<CONFIG:Debug>,DEVELOPMENT=1,RELEASE=1>)
|
||||
|
||||
# --- 5b) The VST3 module (loadable .vst3 DLL). -------------------------------
|
||||
add_library(reasampler_vst MODULE
|
||||
src/vst/vst_entry.cpp
|
||||
src/vst/reasampler_processor.cpp
|
||||
src/vst/reasampler_editor.cpp
|
||||
src/vst/reasampler_embed.cpp
|
||||
src/vst/reaper_bridge.cpp
|
||||
# SDK module entry — compiled into the module (not the static lib) so the
|
||||
# InitDll/ExitDll dll exports survive the link (see vst3_sdk note above).
|
||||
${VST3_SDK}/public.sdk/source/main/dllmain.cpp
|
||||
${VST3_SDK}/public.sdk/source/main/moduleinit.cpp
|
||||
${LICE_SRC}
|
||||
)
|
||||
# editor_geometry + bridge_marshal: the pure spike helpers. sample_map (S4): the pure
|
||||
# bank->keymap mapping + state (de)ser the processor drives off the audio thread;
|
||||
# linking it pulls its pure deps (bank_book, wav_trim, sampler_core, bank_model,
|
||||
# peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile /
|
||||
# projectDirOfRpp) the bridge + processor use. Its PUBLIC include dirs (src, src/vst)
|
||||
# give the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...).
|
||||
# embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals
|
||||
# into; it links editor_geometry transitively (shared Rect).
|
||||
# app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so
|
||||
# the instrument reads the SAME namespace the extension writes; its PUBLIC include dir
|
||||
# (build/generated) carries version_generated.h for the channel bit.
|
||||
# capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip
|
||||
# geometry the capture-first editor draws + hit-tests against; both link editor_geometry
|
||||
# transitively (shared Rect).
|
||||
# waveform_view (S11): the pure frame<->pixel marker geometry + zero-crossing snap the
|
||||
# editor's waveform surface draws + hit-tests against; links editor_geometry + peaks
|
||||
# transitively (shared Rect + AudioSample).
|
||||
# bank_sync (S9/S8 reader): the pure generation-compare + assignment-consume decision the
|
||||
# processor's off-thread poll runs; links assignment_request transitively (the decoded
|
||||
# request it consumes) — the same key the extension writes, shared via the pure module.
|
||||
# browser_scroll + note_entry + param_slider (S12 + S15/S16 control surfaces): the pure
|
||||
# scroll/search geometry over the capture browser, the numeric-note-entry parse, and the
|
||||
# control-panel layout + slider/toggle value<->pixel mapping the editor's parameter surface
|
||||
# draws + routes against. browser_scroll pulls capture_browser transitively; param_slider +
|
||||
# note_entry link editor_geometry / the stdlib only. All engine-free, DAW-verified in the shell.
|
||||
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
||||
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
|
||||
waveform_view bank_sync browser_scroll note_entry param_slider)
|
||||
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
|
||||
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
|
||||
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
# A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk
|
||||
# product name, channel-forked (S18): reasampler_9000.vst3 (stable, byte-identical to
|
||||
# pre-S18) / reasampler_9000_beta.vst3 (beta) — driven by REASAMPLER_VST_OUTPUT_NAME set
|
||||
# from the ONE channel decision above, mirroring the extension's REASAMPLER_OUTPUT_NAME
|
||||
# and matching app_version::vstOutputName(). The two channels install side-by-side; the
|
||||
# per-channel VST3 class UID (reasampler_vst.h) keeps a saved instance rebinding to its
|
||||
# own channel (save-rename-reopen is a DAW-verify).
|
||||
set_target_properties(reasampler_vst PROPERTIES PREFIX "" SUFFIX ".vst3"
|
||||
OUTPUT_NAME "${REASAMPLER_VST_OUTPUT_NAME}")
|
||||
endif()
|
||||
|
||||
+792
-1
@@ -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*/ <negative>)`. **Verified present** in
|
||||
`reaper_plugin_functions.h`:
|
||||
`int TrackFX_AddByName(MediaTrack* track, const char* fxname, bool recFX, int
|
||||
instantiate)` — a **negative** `instantiate` always creates a new effect (per the
|
||||
header comment); the `"VST3:"` prefix selects the format. Captures the returned FX
|
||||
index (or `-1` on failure).
|
||||
- **Loads the dragged capture into that instance via the load-capture seam** (below).
|
||||
- Wraps the whole thing in one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the
|
||||
gesture is one Ctrl-Z — the same discipline the bank verbs use.
|
||||
|
||||
**The ReaSampler 9000 load-capture seam (the hard coupling — MUST be added; does not yet
|
||||
exist).** The Phase S spec today gives the instrument a **live-state *read* seam** (it
|
||||
reads bank index + mapping from `"reasampler"` ext-state via the bridge — §The two seams)
|
||||
but **no entry point for an external actor to say "this fresh instance should play *this
|
||||
specific* capture."** Reading the bank is not the same as being *pointed at one sample*.
|
||||
This wave is the reason to add that seam, and the seam lands **inside the instrument**
|
||||
(the `phase-s` artifact), not the extension.
|
||||
|
||||
**Mechanism (SETTLED — (B) VST3 component-state injection).** Right after
|
||||
`TrackFX_AddByName` returns the new FX index, the extension writes the instance's component
|
||||
state directly — the same blob the instrument's `getChunk`/`setChunk` round-trips — with
|
||||
the target capture pre-selected. Deterministic, no shared-state race, no cross-process
|
||||
handshake; it uses the instrument's own persistence format. The state-set path is
|
||||
`TrackFX_SetNamedConfigParm` — **verified present** in `reaper_plugin_functions.h`: `bool
|
||||
TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char*
|
||||
value)`, and the header documents the write-parms `vst_chunk` / `vst_chunk_program` as the
|
||||
base64-encoded VST-specific chunk. So the injection call is
|
||||
`TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)`.
|
||||
|
||||
**Load-bearing caveat — `vst_chunk` is the plugin's own serialized chunk.** `vst_chunk` is
|
||||
ReaSampler 9000's **own** base64-encoded serialized state (its `getChunk`/`setChunk`
|
||||
FXP/FXB-style blob), **not** a raw VST3 `IComponent::setState` stream that REAPER
|
||||
re-marshals into the plugin. The extension therefore has to construct **exactly the
|
||||
instrument's own state-blob bytes** with the capture pre-selected — REAPER does not
|
||||
translate a neutral state representation on its behalf. This makes the **component-state
|
||||
blob format a shared cross-artifact contract** — one that is **still being defined in Phase
|
||||
S** — and a **coordination dependency between the extension and the instrument:** both must
|
||||
agree on the exact byte layout that ReaSampler 9000's `setChunk` accepts before either half
|
||||
is final. The load-capture seam and the component-state persistence work (§Where the toggle
|
||||
lives / component-state version bumps) share this one blob format.
|
||||
|
||||
**Rejected alternative — (A) fresh-instance ext-state handshake.** The extension writes a
|
||||
small "pending load" hint into `"reasampler"` ext-state keyed to the target track/FX (a
|
||||
capture id + a target GUID); a freshly-instantiated ReaSampler 9000 reads it on init via
|
||||
the bridge it already uses, claims + clears the hint, and self-selects that capture. It
|
||||
would keep the artifacts loosely coupled through the one ext-state seam they already share
|
||||
and avoid the extension hard-coding the instrument's state format — but it **loses on the
|
||||
claim/clear race:** "which instance claims which hint" needs a stable key and a
|
||||
cross-process handshake to get right, and (B) sidesteps that entirely by writing the state
|
||||
directly and deterministically.
|
||||
|
||||
**Coexistence with the OS drag-out (disambiguation contract).** The two OS-vs-internal
|
||||
modes are disambiguated **by pointer location, not a mode toggle** — the user never picks
|
||||
"OS drag" vs. "instrument drop"; the extension infers it from where the pointer is when
|
||||
released. The M11 boundary (left the client rect) is *refined*, not replaced: leaving the
|
||||
rect now asks "over REAPER's UI → InstrumentDrop, else → OsDrag." Both M11 OS drag-out and
|
||||
the internal bank-to-bank drag must remain **byte-for-byte unchanged** in their own
|
||||
regions — this wave only inserts a new middle case. Multi-capture payloads are a
|
||||
disambiguation input too (see open question — instrument drop is naturally single-capture;
|
||||
a multi-capture drag over the FX button is either rejected or loads the first).
|
||||
|
||||
**Precision / invariant implications (drop-and-load).**
|
||||
- **Explicit user-driven placement — consistent with capture↔placement separation.** This
|
||||
is a *deliberate placement gesture*: the user chooses to put a playing instrument on a
|
||||
track, exactly as inserting an item into the arrange is a deliberate act. It does **not**
|
||||
auto-capture (the file already exists in the bank) and does **not** insert a media item
|
||||
into the timeline. It instantiates a *reader* of the bank on a track and points it at one
|
||||
already-captured sample. Capture, placement, and playback stay three distinct acts; this
|
||||
is placement-of-the-player, not a capture and not a timeline insert.
|
||||
- **No private sample copy.** The instantiated instrument consumes the one authoritative
|
||||
bank (it resolves the WAV via the shared M4 project-relative machinery like any
|
||||
ReaSampler 9000 instance); the seam hands it a *reference* (a capture identity), never a
|
||||
copied file. Any path that copies bytes into the instance is a bug.
|
||||
- **The internal drag stays pure-decidable and testable.** The new `InstrumentDrop`
|
||||
gesture is decided in the `drag_out` pure module (REAPER-free) over a shell-supplied
|
||||
predicate; the M11 `drag_out` unit tests must not regress.
|
||||
|
||||
**Open questions (Daniel / Phase S team to decide).**
|
||||
- **Multi-capture drag over an FX button** — reject (only single-capture drags arm
|
||||
`InstrumentDrop`), or load the first / a keymap of all? Tier-0 leans reject-or-first;
|
||||
a multi-capture keymap load is a Tier-1 stretch.
|
||||
- **FX-button hotspot vs. whole TCP.** Does the drop zone have to be the FX button
|
||||
specifically, or is dropping anywhere on the target track's TCP enough (simpler hit
|
||||
resolution, arguably clearer target)? Depends on what the SDK exposes (see must-verify).
|
||||
|
||||
**Must-verify before build (drop-and-load).**
|
||||
- `TrackFX_AddByName` — **verified present** (`reaper_plugin_functions.h`): signature and
|
||||
the `"VST3:"`-prefix + negative-`instantiate` semantics confirmed from the header.
|
||||
- **Pointer→track / FX-button hit resolution during a drag** — **not yet confirmed.**
|
||||
Candidates: `GetTrackFromPoint` / `GetThingFromPoint` (verify names + signatures against
|
||||
`reaper_plugin_functions.h`); whether the FX button specifically is addressable vs. the
|
||||
TCP as a whole is an open verification that also decides the "hotspot vs. whole TCP"
|
||||
question.
|
||||
- **Instance state injection (seam mechanism (B) — SETTLED, load-bearing prerequisite)** —
|
||||
**verified present** in `reaper_plugin_functions.h`: `bool
|
||||
TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char*
|
||||
value)`, with the header documenting `vst_chunk` / `vst_chunk_program` as the
|
||||
base64-encoded VST-specific chunk write-parms. The injection call is
|
||||
`TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)`. The remaining
|
||||
prerequisite is **not** the API but the **shared component-state blob format**: `vst_chunk`
|
||||
carries the instrument's *own* serialized chunk (its `setChunk` input), so the extension
|
||||
must construct exactly ReaSampler 9000's state bytes — the cross-artifact contract still
|
||||
being defined in Phase S. Blocks the drop half until the blob format is agreed.
|
||||
|
||||
## VST3 channel identity — the UID pair + the pairing surface (S18; extends Phase V V4)
|
||||
|
||||
**Decided (Daniel, 2026-07-26):** the beta/stable channel split Phase V V4 gave the
|
||||
*extension* extends to the **ReaSampler 9000 VST3 instrument** — a beta-built VST pairs with
|
||||
the beta extension only, a stable VST with stable only, both installable side-by-side in one
|
||||
REAPER. This is the instrument-side companion to V4 and mirrors its philosophy exactly:
|
||||
**one channel per binary; all channel identity derives from the ONE
|
||||
`REASAMPLER_CHANNEL_IS_BETA` bit via the pure `app_version` module — no scattered `#ifdef`s
|
||||
in the VST shell.**
|
||||
|
||||
**What is already isolated (structural, not added by S18).** The wire/data pairing is
|
||||
already done and needs no per-key work: `ext_keys.h`'s `kProjExtNamespace()` delegates to
|
||||
`app_version::extStateNamespace()`, so a beta-compiled VST's bridge reads `"reasampler_beta"`.
|
||||
Every wire key — `banks`, `assign_request`, S9's bank-generation key (in-flight), S17's
|
||||
component-state contract, and **any future key** — is a plain constant *under* that
|
||||
namespace, so channel data-isolation is **structural: no per-key opt-in, and a future key
|
||||
that forgets to isolate is impossible by construction** (it keys off the namespace accessor,
|
||||
not a raw literal). What S18 adds is only the missing *plugin identity* layer.
|
||||
|
||||
- **The UID-pair invariant (the permanent commitment).** The VST3 class UID is the plugin's
|
||||
identity — a saved REAPER project records it and rebinds a saved instance by it. Today
|
||||
`reasampler_vst.h` holds **one** forever-locked UID (`kReaSamplerProcessorUID`,
|
||||
`REASAMPLER_PROC_UID_1..4`, S-NAME-1). A beta VST with the **same** UID cannot coexist with
|
||||
stable in one install (same UID = identity collision / arbitrary rebind). So beta needs its
|
||||
**own** forever-stable UID: a second constant, minted once, locked exactly as the first.
|
||||
**Invariant: BOTH UIDs are frozen forever once shipped; the channel bit selects which is
|
||||
compiled into this binary** (one `DEF_CLASS2`, one class per binary — not both classes in
|
||||
one binary; that mirrors V4's fully-isolated-binary philosophy and keeps a beta build from
|
||||
ever presenting the stable identity). Saved-project isolation follows directly: a project
|
||||
saved with beta instances rebinds only to the beta VST; a stable-saved instance opened
|
||||
where only the beta extension has banks resolves the stable UID and shows a clean empty
|
||||
"pick a capture" state (S10 policy), not an error.
|
||||
- **Binary + display identity, channel-derived.** Mirror the extension's `OUTPUT_NAME` fork
|
||||
(`reaper_reasampler` / `reaper_reasampler_beta`): the VST3 module's on-disk name forks
|
||||
`reasampler_9000` / `reasampler_9000_beta`, its factory display name "ReaSampler 9000" /
|
||||
"ReaSampler 9000 beta", and its editor title band + S6 embed-strip label are channel-aware
|
||||
— **all sourced from `app_version` channel accessors (a VST-name accessor beside
|
||||
`binaryName()`/`dockTitle()`), never a literal in `reasampler_vst.h`/`vst_entry.cpp`.** The
|
||||
factory version string carries the `-beta` render where V4's `appVersion()` already does;
|
||||
vendor/url/email stay shared unless V4 qualified the equivalent (V4 kept the lane-name
|
||||
prefix shared — shared-where-V4-shares is the default).
|
||||
- **The complete pairing surface (the guarantee to state, not new code).** A channel's VST
|
||||
talks to that channel's extension **only**, because (1) plugin identity — UID + filename +
|
||||
display — is channel-forked (above), and (2) **all** wire keys live under the
|
||||
channel-derived `kProjExtNamespace()`. The two together make pairing complete and
|
||||
structural: identity keeps the *plugins* distinct; the namespace keeps the *data* distinct.
|
||||
No per-key or per-seam isolation work is ever needed — S8's assignment key, S9's generation
|
||||
key, and S17's blob-injection key all inherit it. **Verify all identity/factory wiring
|
||||
against the vendored Steinberg SDK** (`DEF_CLASS2` / `INLINE_UID` / `FUID` from
|
||||
`pluginfactory.h` + `funknown.h`); the pure `app_version` name accessors are CTest-tested.
|
||||
- **Fork S18-F1 (flagged — Daniel's call): mint the beta UID now vs. at first beta release.**
|
||||
Lean **mint now** — mirrors the stable UID (minted at the S1 spike, locked long before
|
||||
ship), removes a "remember to mint before shipping beta" landmine, zero cost for an
|
||||
unused-until-beta constant. The alternative (a locked-once placeholder replaced before the
|
||||
first beta VST ships) defers the commitment but adds a release-gate step. Flagged only
|
||||
because the UID is a forever commitment.
|
||||
|
||||
## Non-goals / guardrails
|
||||
|
||||
@@ -1319,6 +2066,13 @@ and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embe
|
||||
- **The instrument keeps no private copy of the samples.** It consumes the one
|
||||
authoritative bank; per-instance sample stores are a non-goal (they refork the source
|
||||
the one-source-multiple-views instinct keeps single).
|
||||
- **The instrument never ingests (S8).** Capture, import, and drop-ingest are *extension*
|
||||
acts; the instrument only reads and plays. A drop onto the editor window (if the spike
|
||||
proves it viable) is *relayed to the extension* as an ingest request — the instrument
|
||||
never writes the bank itself.
|
||||
- **Channel mode is a performance choice, not a bank fact (S7).** The mono/stereo toggle is
|
||||
per-instance component state, never written to `Sample` or the bank (D-B). The bank's
|
||||
per-sample channel-count intrinsic is a *file fact*; the play mode is the instrument's.
|
||||
- **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do
|
||||
not add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
|
||||
- **The pure core stays REAPER-free *and* VST3-free.** The voice engine / envelope /
|
||||
@@ -1329,7 +2083,44 @@ and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embe
|
||||
else in Phase S lives in the *second* artifact and does not alter the extension's
|
||||
M/D/B/R/V pillars.
|
||||
- **Do not spec Tier 2/3.** Tier 2 is held (noted, not specified); Tier 3 is
|
||||
optional-forever. Do not let their feature lists drive Tier 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+18
-5
@@ -645,7 +645,9 @@ void doBankDelete() {
|
||||
ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n");
|
||||
return;
|
||||
}
|
||||
persistBankOp("ReaSampler: delete bank");
|
||||
// S9: bump only when the deleted bank held samples — dropping them changes what a live
|
||||
// instance referencing one could play. Deleting an EMPTY bank is purely organizational.
|
||||
persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0);
|
||||
}
|
||||
|
||||
// Evacuate a named bank: move every member back to the pool (index-only, collapse by
|
||||
@@ -666,7 +668,8 @@ void doBankEvacuate() {
|
||||
"destination, not a source).\n");
|
||||
return;
|
||||
}
|
||||
persistBankOp("ReaSampler: evacuate bank");
|
||||
// S9: evacuate moves members between banks (bank membership changes) -> bump.
|
||||
persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true);
|
||||
}
|
||||
|
||||
// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool),
|
||||
@@ -752,7 +755,9 @@ void doBankTransferSelected(bool copy) {
|
||||
if (mutated) {
|
||||
const std::string label =
|
||||
std::string("ReaSampler: ") + verb + " sample(s)";
|
||||
persistBankOp(label.c_str());
|
||||
// S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an
|
||||
// instance may reference) -> bump so assigned instances refresh hands-free.
|
||||
persistBankOp(label.c_str(), /*bumpGeneration=*/true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,7 +799,9 @@ void doBankRemoveSelected() {
|
||||
}
|
||||
|
||||
// No-op guardrail (R-B): open an undo point only if the index actually mutated.
|
||||
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
|
||||
// S9: a remove drops a sample from a bank (an instance referencing it must refresh — it
|
||||
// will resolve to silence, per the stale-id policy) -> bump.
|
||||
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true);
|
||||
}
|
||||
|
||||
// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE
|
||||
@@ -884,8 +891,14 @@ void doBankPruneFolder() {
|
||||
// change stands and persists on the user's next save; it just earns no undo point until
|
||||
// there is a project to persist into (undo of an unsaved bank op has nothing to roll
|
||||
// back to anyway). The Begin/End must still be balanced, hence the close-either-way.
|
||||
void persistBankOp(const char* label) {
|
||||
void persistBankOp(const char* label, bool bumpGeneration) {
|
||||
Undo_BeginBlock2(nullptr);
|
||||
// S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the
|
||||
// fresh generation rides the same ext-state write the persist makes (persistBook() ->
|
||||
// saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs
|
||||
// (the caller decides); a pure-organizational verb passes false and leaves the counter be,
|
||||
// so a rename/activate does not needlessly refresh live instances.
|
||||
if (bumpGeneration) g_session->bumpBankGeneration();
|
||||
const bool persisted = persistBook();
|
||||
if (persisted)
|
||||
Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG);
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// assignment_request.cpp — see assignment_request.h. Pure: standard library only.
|
||||
|
||||
#include "assignment_request.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kMagic = "rsassign1";
|
||||
|
||||
// Append one length-prefixed field: <decimal-len> ':' <bytes>. Mirror of
|
||||
// provenance's putField so the two seams share one wire idiom.
|
||||
void putField(std::string& out, const std::string& field) {
|
||||
out += std::to_string(field.size());
|
||||
out += ':';
|
||||
out += field;
|
||||
}
|
||||
|
||||
// Cursor over the encoded string. All reads are bounds-checked; a short read fails
|
||||
// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the
|
||||
// three field kinds this record needs.
|
||||
class Cursor {
|
||||
public:
|
||||
explicit Cursor(const std::string& s) : s_(s) {}
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
bool atEnd() const { return pos_ >= s_.size(); }
|
||||
|
||||
// Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or
|
||||
// non-numeric length, a length that overflows SIZE_MAX, or a length that runs past
|
||||
// the end. The digit count is capped at 20 (the decimal width of SIZE_MAX on a
|
||||
// 64-bit host) so a crafted 200-digit length cannot accumulate past SIZE_MAX via
|
||||
// repeated multiply. "never UB" promise from the header is upheld here.
|
||||
bool field(std::string& out) {
|
||||
if (!ok_) return false;
|
||||
const std::size_t colon = s_.find(':', pos_);
|
||||
if (colon == std::string::npos) return fail();
|
||||
if (colon == pos_) return fail(); // empty length token
|
||||
// Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus.
|
||||
if (colon - pos_ > 20u) return fail();
|
||||
std::size_t len = 0;
|
||||
for (std::size_t i = pos_; i < colon; ++i) {
|
||||
const char c = s_[i];
|
||||
if (c < '0' || c > '9') return fail();
|
||||
const std::size_t digit = static_cast<std::size_t>(c - '0');
|
||||
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
|
||||
if (len > (std::numeric_limits<std::size_t>::max() - digit) / 10u)
|
||||
return fail();
|
||||
len = len * 10u + digit;
|
||||
}
|
||||
const std::size_t start = colon + 1;
|
||||
// Guard: start may equal s_.size() (empty remainder), in which case only len==0
|
||||
// is valid; start > s_.size() cannot happen (colon < s_.size() by find()).
|
||||
// Use subtraction-first form to avoid start+len wrapping on a huge len.
|
||||
if (start > s_.size() || len > s_.size() - start) return fail();
|
||||
out.assign(s_, start, len);
|
||||
pos_ = start + len;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reads a length-prefixed field and parses it as a signed 64-bit decimal (an
|
||||
// optional leading '-'). Fails on empty, non-digit, trailing bytes, or a value
|
||||
// that would overflow INT64_MAX / underflow INT64_MIN. The digit count is capped
|
||||
// at 19 (the decimal width of INT64_MAX, plus 1 for the optional sign = 20
|
||||
// characters maximum) so a crafted 21-digit field cannot accumulate UB. "never UB"
|
||||
// promise from the header is upheld: all arithmetic is done on positive digits
|
||||
// and capped before applying the sign.
|
||||
bool fieldInt64(std::int64_t& out) {
|
||||
std::string f;
|
||||
if (!field(f)) return false;
|
||||
if (f.empty()) return fail();
|
||||
std::size_t i = 0;
|
||||
bool neg = false;
|
||||
if (f[0] == '-') {
|
||||
neg = true;
|
||||
i = 1;
|
||||
if (f.size() == 1) return fail(); // bare "-"
|
||||
}
|
||||
// Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit
|
||||
// positive value would overflow INT64_MAX; a 20-digit negative might be valid
|
||||
// (INT64_MIN = -9223372036854775808) but we conservatively reject it too: the
|
||||
// generation field is a unix timestamp, never near INT64 limits in practice.
|
||||
if (f.size() - i > 19u) return fail();
|
||||
std::int64_t v = 0;
|
||||
for (; i < f.size(); ++i) {
|
||||
const char c = f[i];
|
||||
if (c < '0' || c > '9') return fail();
|
||||
const std::int64_t digit = static_cast<std::int64_t>(c - '0');
|
||||
// Overflow guard: v * 10 + digit must not exceed INT64_MAX.
|
||||
if (v > (std::numeric_limits<std::int64_t>::max() - digit) / 10)
|
||||
return fail();
|
||||
v = v * 10 + digit;
|
||||
}
|
||||
out = neg ? -v : v;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Consumes an exact literal at the cursor (the magic tag). Fails if absent.
|
||||
bool literal(const char* lit) {
|
||||
if (!ok_) return false;
|
||||
std::size_t i = 0;
|
||||
for (; lit[i] != '\0'; ++i) {
|
||||
if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail();
|
||||
}
|
||||
pos_ += i;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
bool fail() {
|
||||
ok_ = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string& s_;
|
||||
std::size_t pos_ = 0;
|
||||
bool ok_ = true;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string encodeAssignmentRequest(const AssignmentRequest& req) {
|
||||
std::string out = kMagic;
|
||||
putField(out, req.bankId);
|
||||
putField(out, req.sampleId);
|
||||
putField(out, std::to_string(req.generation));
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<AssignmentRequest> decodeAssignmentRequest(const std::string& wire) {
|
||||
Cursor cur(wire);
|
||||
if (!cur.literal(kMagic)) return std::nullopt;
|
||||
|
||||
AssignmentRequest req;
|
||||
if (!cur.field(req.bankId)) return std::nullopt;
|
||||
if (!cur.field(req.sampleId)) return std::nullopt;
|
||||
if (!cur.fieldInt64(req.generation)) return std::nullopt;
|
||||
|
||||
// Reject trailing garbage: a well-formed value ends exactly at the last field.
|
||||
if (!cur.ok() || !cur.atEnd()) return std::nullopt;
|
||||
return req;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
// assignment_request — the pure core of the S8 ingest assignment-request seam.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3,
|
||||
// NO vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
|
||||
// "small pure type + length-prefixed round-trip" pattern as provenance / owned_manifest.
|
||||
//
|
||||
// -- What it is --------------------------------------------------------------
|
||||
//
|
||||
// When the EXTENSION ingests a sample (S8: arrange capture / Media-Explorer import /
|
||||
// drop-onto-panel) it writes an ASSIGNMENT REQUEST to its own "reasampler" ext-state
|
||||
// namespace: "the active sampler instance should now play THIS sample." The value
|
||||
// names the ingested sample by (bankId, sampleId) plus a monotonic `generation` the
|
||||
// reader compares to decide the request is NEW (a fresh ingest, even of the same id).
|
||||
//
|
||||
// This module owns ONLY the value's WIRE FORMAT — build/parse round-trip. Writing it
|
||||
// to ext-state is the persist shell's job; READING it is the instrument's job in a
|
||||
// LATER dispatch (S8 instrument-side follow-up, after S10 merges). This is why the
|
||||
// format is documented here in the header, not just in code: the reader lands elsewhere
|
||||
// and must decode exactly what this writer produced.
|
||||
//
|
||||
// -- The data-ownership boundary (load-bearing) ------------------------------
|
||||
//
|
||||
// The EXTENSION writes this; the instrument only READS it. That does not violate the
|
||||
// instrument's read-only-over-the-bank rule: the assignment request is the extension
|
||||
// writing its OWN namespace (a request FROM the extension TO the instrument), never the
|
||||
// instrument writing back into the bank. The instrument, on reading a new generation,
|
||||
// updates its OWN component-state selection (the same selection S4 persists) and reloads.
|
||||
//
|
||||
// -- Why `generation` -------------------------------------------------------
|
||||
//
|
||||
// Instances reference sample IDs, so re-assigning the SAME id (e.g. a recapture, or a
|
||||
// re-drop of the same file) would be indistinguishable from a stale value without a
|
||||
// changing field. `generation` is a monotonic disambiguator (the ingest writer supplies
|
||||
// a wall-clock unix-epoch stamp today — see the writer shell) so the reader can tell
|
||||
// "assigned again just now" from "already saw this." It is DELIBERATELY the same shape
|
||||
// the S9 bank-generation counter will use, but it is NOT that counter — S9 is a separate
|
||||
// point; this field is self-contained to the request and does not depend on S9 landing.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// One assignment request: the ingested sample's identity + a monotonic disambiguator.
|
||||
// bankId — the bank the sample was ingested into (the active/target bank).
|
||||
// sampleId — the ingested Sample's stable id (BankIndex key).
|
||||
// generation — a monotonic value the reader compares to detect a NEW request. The
|
||||
// writer supplies a unix-epoch-seconds stamp; the reader treats it as an
|
||||
// opaque "did this change?" token, not a wall-clock it interprets.
|
||||
struct AssignmentRequest {
|
||||
std::string bankId;
|
||||
std::string sampleId;
|
||||
std::int64_t generation = 0;
|
||||
|
||||
bool operator==(const AssignmentRequest& o) const {
|
||||
return bankId == o.bankId && sampleId == o.sampleId &&
|
||||
generation == o.generation;
|
||||
}
|
||||
bool operator!=(const AssignmentRequest& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
// Encode an assignment request to the wire string. Length-prefixed fields behind a
|
||||
// magic+version tag ("rsassign1"), so arbitrary bytes in an id (a GUID, a display-
|
||||
// derived id) round-trip whole with no escaping ambiguity — the same idiom provenance
|
||||
// uses. Deterministic: the same request always yields the same string.
|
||||
//
|
||||
// FORMAT (documented for the LATER instrument-side reader):
|
||||
// "rsassign1" <len>':'<bankId> <len>':'<sampleId> <len>':'<generation-decimal>
|
||||
// where each <len> is the decimal byte length of the field that follows the ':'.
|
||||
std::string encodeAssignmentRequest(const AssignmentRequest& req);
|
||||
|
||||
// Parse a wire string produced by encodeAssignmentRequest. std::nullopt on any
|
||||
// malformed / truncated / trailing-garbage input (never UB, never a partial value) —
|
||||
// the reader shell treats absence/malformed as "no pending request." Round-trips:
|
||||
// decodeAssignmentRequest(encodeAssignmentRequest(x)) == x.
|
||||
//
|
||||
// READER REQUIREMENT (instrument-side, S8 follow-up dispatch): after successfully
|
||||
// decoding a request, the reader MUST verify that (bankId, sampleId) resolves to an
|
||||
// existing sample before acting on it. An undo on the extension side rolls back the
|
||||
// `banks` ext-state key (removing the sample) but cannot atomically clear the
|
||||
// `assign_request` key if the write happened outside the undo block. Even with the
|
||||
// undo-grouping fix (Major 2), the reader must guard against this: treat an
|
||||
// unresolvable (bankId, sampleId) pair as a stale/no-op request and discard it
|
||||
// silently, never crashing or selecting a nonexistent entry.
|
||||
std::optional<AssignmentRequest> decodeAssignmentRequest(const std::string& wire);
|
||||
|
||||
} // namespace reasampler
|
||||
+56
-1
@@ -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 {
|
||||
|
||||
@@ -63,6 +63,22 @@ struct Levels {
|
||||
bool operator==(const Levels& o) const;
|
||||
};
|
||||
|
||||
// Sample-accurate sustain-loop bounds, as frame indices into the captured file
|
||||
// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like
|
||||
// sampleRate or length — consumed by the future MIDI-playback instrument to hold
|
||||
// notes past the recorded length. Modeled as one optional struct (not two loose
|
||||
// optionals) so "both points or neither" is a structural invariant, not a rule to
|
||||
// re-check at every boundary. Frame indices, not seconds, because the loop is a
|
||||
// per-sample-frame contract; the instrument reads the file's sample rate to relate
|
||||
// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end.
|
||||
// start == end is a valid zero-length loop marker.
|
||||
struct LoopPoints {
|
||||
std::int64_t start = 0;
|
||||
std::int64_t end = 0;
|
||||
|
||||
bool operator==(const LoopPoints& o) const;
|
||||
};
|
||||
|
||||
// The metadata record for one captured sample. The audio itself lives in a
|
||||
// project-relative file; `relativePath` is ALWAYS relative (enforced at the
|
||||
// BankIndex::add boundary — see AddResult).
|
||||
@@ -95,6 +111,18 @@ struct Sample {
|
||||
|
||||
std::optional<std::string> key; // musical key, when known
|
||||
|
||||
// Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument,
|
||||
// additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S
|
||||
// samples deserialize without them and re-serialize without inventing values.
|
||||
// - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument
|
||||
// can repitch it across the keyboard. DISTINCT from the musical `key` above:
|
||||
// `key` is a human label ("F#m"); `rootNote` is the exact pitch for repitch.
|
||||
// Populated at/after capture only where derivable — left empty (never guessed)
|
||||
// when the source is not a single played note.
|
||||
// - loop: sustain-loop bounds, populated only where explicitly set.
|
||||
std::optional<int> rootNote;
|
||||
std::optional<LoopPoints> loop;
|
||||
|
||||
Levels levels;
|
||||
bool clipped = false;
|
||||
|
||||
|
||||
+133
-30
@@ -61,6 +61,9 @@
|
||||
#include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1)
|
||||
#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4)
|
||||
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
|
||||
#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest
|
||||
#include "instrument_drop.h" // pure buildInstrumentDropChunk — the vst_chunk blob (S17)
|
||||
#include "instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17)
|
||||
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
|
||||
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
|
||||
#include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5)
|
||||
@@ -82,6 +85,7 @@
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
||||
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest
|
||||
#else
|
||||
#include <pthread.h>
|
||||
#endif
|
||||
@@ -319,6 +323,15 @@ struct PanelState {
|
||||
CardGesture cardGesture = CardGesture::None;
|
||||
int dragTargetSlot = -1;
|
||||
|
||||
// --- S17 drop-and-load (InstrumentDrop) -----------------------------------
|
||||
// While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an
|
||||
// InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX
|
||||
// hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the
|
||||
// dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last
|
||||
// resolved FX-hotspot track (null when the pointer is not over an FX button) — read on
|
||||
// release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched.
|
||||
MediaTrack* instrumentDropTrack = nullptr;
|
||||
|
||||
// --- Tail-mode toggle -----------------------------------------------------
|
||||
// The authoritative tail setting now lives in ReaSamplerSession (session->tail()),
|
||||
// NOT in panel state, so it travels inside the .rpp (persist serializes it on save,
|
||||
@@ -2057,7 +2070,10 @@ void doDeleteBank(const std::string& bankId) {
|
||||
// r == 6 (Yes) falls through to a plain delete (drops members).
|
||||
}
|
||||
if (!book()->deleteBank(bankId)) return;
|
||||
persistBankOp("ReaSampler: delete bank");
|
||||
// S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then-
|
||||
// delete path moved/dropped members) — both change what a live instance could play. An
|
||||
// empty-bank delete is purely organizational, no bump.
|
||||
persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0);
|
||||
// shownBankId is reconciled by the next fingerprint pass. If no named banks remain,
|
||||
// nudge focus to the pool so the selection has a valid home.
|
||||
if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool;
|
||||
@@ -2069,7 +2085,7 @@ void doEvacuateBank(const std::string& bankId) {
|
||||
const Bank* bk = book()->bank(bankId);
|
||||
if (!bk || bk->isPool()) return;
|
||||
if (!book()->evacuate(bankId)) return;
|
||||
persistBankOp("ReaSampler: evacuate bank");
|
||||
persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); // S9: membership changed
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
@@ -2113,7 +2129,7 @@ void transferSamples(const std::vector<std::string>& sampleIds,
|
||||
if (!mutated) return; // nothing changed — no persist, no undo point
|
||||
|
||||
const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)";
|
||||
persistBankOp(label);
|
||||
persistBankOp(label, /*bumpGeneration=*/true); // S9: bank membership changed
|
||||
// The selection indexed into the source; after a move those indices are stale, so
|
||||
// clear it (the fingerprint pass will also clear, but do it now for immediacy).
|
||||
g_panel.selection = Selection{};
|
||||
@@ -2138,7 +2154,7 @@ void removeSamples(const std::vector<std::string>& sampleIds,
|
||||
++removed;
|
||||
if (removed == 0) return; // nothing changed — no persist, no undo point
|
||||
|
||||
persistBankOp("ReaSampler: remove sample(s)");
|
||||
persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); // S9: sample dropped
|
||||
// The selection indexed into the source; after a remove those indices are stale, so
|
||||
// clear it (the fingerprint pass will also clear, but do it now for immediacy).
|
||||
g_panel.selection = Selection{};
|
||||
@@ -2912,16 +2928,52 @@ void onMouseMove(int x, int y) {
|
||||
}
|
||||
}
|
||||
if (g_panel.dragging) {
|
||||
// M11 gesture boundary (invariant #4): while a drag with samples is under way, the
|
||||
// moment the pointer LEAVES the panel client area the gesture becomes OS-bound —
|
||||
// hand the payload to the native OS drag. Inside the client area it stays the
|
||||
// existing internal bank-to-bank drag, byte-identical. The boundary decision is the
|
||||
// pure drag_out::decideGesture (drag state + pointer + client rect).
|
||||
// M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the
|
||||
// pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant
|
||||
// #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture
|
||||
// splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an
|
||||
// InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a
|
||||
// pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays
|
||||
// Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint.
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
|
||||
const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
|
||||
if (decideGesture(x, y, client, st) == DragGesture::OsDrag) {
|
||||
const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom);
|
||||
|
||||
DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
|
||||
st.singleCapture = (g_panel.dragSampleIds.size() == 1);
|
||||
|
||||
// Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can
|
||||
// only arise there) and only for a single-capture payload — the SDK hit-test is skipped
|
||||
// on the common internal-drag path so it costs nothing there. The screen conversion is
|
||||
// Windows-only (D5); resolveFxDropTarget owns the REAPER hit query.
|
||||
FxDropTarget fx;
|
||||
if (!inside && st.singleCapture) {
|
||||
POINT sp{x, y};
|
||||
ClientToScreen(g_panel.hwnd, &sp);
|
||||
fx = resolveFxDropTarget(sp.x, sp.y);
|
||||
st.overReaperUi = fx.overReaperUi;
|
||||
}
|
||||
|
||||
const DragGesture gesture = decideGesture(x, y, client, st);
|
||||
|
||||
if (gesture == DragGesture::InstrumentDrop) {
|
||||
// Track the FX hotspot for the release; the highlight is REAPER's own FX-button
|
||||
// hover feedback under the pointer (the drop is driven on button-up). We keep the
|
||||
// internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does
|
||||
// NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the
|
||||
// panel does not also paint a bank-drop cue while the drag is out over a track.
|
||||
g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr;
|
||||
g_panel.dropKind = DropKind::None;
|
||||
g_panel.dropBankId.clear();
|
||||
invalidatePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target.
|
||||
g_panel.instrumentDropTrack = nullptr;
|
||||
|
||||
if (gesture == DragGesture::OsDrag) {
|
||||
// Resolve the payload to existing on-disk paths BEFORE tearing down internal
|
||||
// drag state (the resolver reads dragSourceBankId / dragSampleIds).
|
||||
const std::vector<std::string> paths = resolveDragPathsForOs();
|
||||
@@ -2983,6 +3035,20 @@ void doReplaceDrop(const std::string& newId, const std::string& oldId,
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
// Clears all drag-state fields to their resting values. Called from every exit path
|
||||
// (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields
|
||||
// stays consistent across all four sites.
|
||||
void resetDragState() {
|
||||
g_panel.dragArmed = false;
|
||||
g_panel.dragging = false;
|
||||
g_panel.dropKind = DropKind::None;
|
||||
g_panel.dropBankId.clear();
|
||||
g_panel.cardGesture = CardGesture::None;
|
||||
g_panel.dragTargetSlot = -1;
|
||||
g_panel.dragPrimaryId.clear();
|
||||
g_panel.instrumentDropTrack = nullptr;
|
||||
}
|
||||
|
||||
// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides:
|
||||
// * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each.
|
||||
// * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy).
|
||||
@@ -2990,6 +3056,19 @@ 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) {
|
||||
// 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;
|
||||
@@ -3017,6 +3096,7 @@ void onLBtnUp(int x, int y) {
|
||||
/*copy=*/g == CardGesture::Copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
// CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None).
|
||||
SetCursor(LoadCursor(nullptr, IDC_ARROW)); // restore the arrow on drop
|
||||
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
|
||||
@@ -3029,13 +3109,7 @@ void onLBtnUp(int x, int y) {
|
||||
if (focus >= 0)
|
||||
g_panel.selection = applyClick(g_panel.selection, focus, false, false, count);
|
||||
}
|
||||
g_panel.dragArmed = false;
|
||||
g_panel.dragging = false;
|
||||
g_panel.dropKind = DropKind::None;
|
||||
g_panel.dropBankId.clear();
|
||||
g_panel.cardGesture = CardGesture::None;
|
||||
g_panel.dragTargetSlot = -1;
|
||||
g_panel.dragPrimaryId.clear();
|
||||
resetDragState();
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
@@ -3075,8 +3149,35 @@ void handleRightClick(int x, int y) {
|
||||
|
||||
// --- Dialog proc + docking ----------------------------------------------------
|
||||
|
||||
// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands
|
||||
// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all and assigns
|
||||
// the first. Always DragFinish's the HDROP (frees the shell-allocated drop buffer) on every
|
||||
// path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; then each path is
|
||||
// queried by index. Both Win32 and SWELL expose DragQueryFile/DragFinish with this contract.
|
||||
void handleDropFiles(HDROP hDrop) {
|
||||
std::vector<std::string> paths;
|
||||
const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0);
|
||||
paths.reserve(count);
|
||||
for (UINT i = 0; i < count; ++i) {
|
||||
// Query the required length first (excludes the NUL), then read into a sized buffer.
|
||||
const UINT len = DragQueryFile(hDrop, i, nullptr, 0);
|
||||
if (len == 0) continue;
|
||||
std::vector<char> buf(static_cast<std::size_t>(len) + 1, '\0');
|
||||
DragQueryFile(hDrop, i, buf.data(), static_cast<UINT>(buf.size()));
|
||||
std::string p(buf.data());
|
||||
if (!p.empty()) paths.push_back(std::move(p));
|
||||
}
|
||||
DragFinish(hDrop);
|
||||
if (!paths.empty()) ingestDroppedFiles(paths);
|
||||
}
|
||||
|
||||
WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
switch (msg) {
|
||||
case WM_DROPFILES:
|
||||
// S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import
|
||||
// into the active bank + assign the first. wParam is the HDROP.
|
||||
handleDropFiles(reinterpret_cast<HDROP>(wParam));
|
||||
return 0;
|
||||
case WM_PAINT: {
|
||||
PAINTSTRUCT ps;
|
||||
HDC hdc = BeginPaint(hwnd, &ps);
|
||||
@@ -3105,13 +3206,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
// state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is
|
||||
// mutated on a cancel; the cursor is restored to the arrow.
|
||||
if (g_panel.dragArmed || g_panel.dragging) {
|
||||
g_panel.dragArmed = false;
|
||||
g_panel.dragging = false;
|
||||
g_panel.dropKind = DropKind::None;
|
||||
g_panel.dropBankId.clear();
|
||||
g_panel.cardGesture = CardGesture::None;
|
||||
g_panel.dragTargetSlot = -1;
|
||||
g_panel.dragPrimaryId.clear();
|
||||
resetDragState();
|
||||
SetCursor(LoadCursor(nullptr, IDC_ARROW));
|
||||
invalidatePanel();
|
||||
}
|
||||
@@ -3133,10 +3228,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
if (GetCapture() == hwnd) ReleaseCapture();
|
||||
stopAudition();
|
||||
g_panel.selection = Selection{};
|
||||
g_panel.dragArmed = g_panel.dragging = false;
|
||||
g_panel.cardGesture = CardGesture::None;
|
||||
g_panel.dragTargetSlot = -1;
|
||||
g_panel.dragPrimaryId.clear();
|
||||
resetDragState();
|
||||
g_panel.hovered = Hover{};
|
||||
g_panel.tooltipShown = false;
|
||||
g_panel.hwnd = nullptr;
|
||||
@@ -3172,6 +3264,17 @@ void openPanel() {
|
||||
DockWindowActivate(g_panel.hwnd);
|
||||
g_panel.open = true;
|
||||
|
||||
// S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles).
|
||||
// DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it,
|
||||
// so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3
|
||||
// instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration
|
||||
// surface is out of scope for this dispatch. WM_DROPFILES handling itself uses
|
||||
// DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means
|
||||
// would still ingest — only the accept opt-in is gated.
|
||||
#ifdef _WIN32
|
||||
DragAcceptFiles(g_panel.hwnd, TRUE);
|
||||
#endif
|
||||
|
||||
registerAccel();
|
||||
|
||||
reconcileShownBank();
|
||||
@@ -3182,7 +3285,7 @@ void closePanel() {
|
||||
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
|
||||
stopAudition();
|
||||
g_panel.selection = Selection{};
|
||||
g_panel.dragArmed = g_panel.dragging = false;
|
||||
resetDragState();
|
||||
unregisterAccel();
|
||||
if (g_panel.hwnd) {
|
||||
DockWindowRemove(g_panel.hwnd);
|
||||
|
||||
@@ -530,6 +530,11 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
}
|
||||
}
|
||||
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
// Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a
|
||||
// master mix / track / time-selection is not a single played note, so no root
|
||||
// note is derivable here — we do NOT guess one. Loop points are set later by an
|
||||
// explicit user action, not at capture. Leaving them empty is the honest default;
|
||||
// the instrument (Phase S) treats an absent root note as "not a pitched sample".
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = s;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring> // std::memcmp
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
@@ -213,6 +214,15 @@ std::string resolveBankFile(const std::string& projectDir,
|
||||
return dir + "/" + rel;
|
||||
}
|
||||
|
||||
std::string projectDirOfRpp(const std::string& rppPath) {
|
||||
// An unsaved project reports an empty .rpp path; keep it empty so downstream
|
||||
// resolution refuses (no default-location fallback). Mirrors persist.cpp's prior
|
||||
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
|
||||
if (rppPath.empty()) return {};
|
||||
std::string dir = std::filesystem::path(rppPath).parent_path().string();
|
||||
return normalizeSlashes(dir);
|
||||
}
|
||||
|
||||
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
||||
const std::string& newProjectDir) {
|
||||
BankRelocation r;
|
||||
|
||||
@@ -121,6 +121,14 @@ std::string bankRelativeForName(const std::string& fileName);
|
||||
std::string resolveBankFile(const std::string& projectDir,
|
||||
const std::string& relativePath);
|
||||
|
||||
// The project directory that holds a .rpp: its parent directory, forward-slashed,
|
||||
// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty
|
||||
// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the
|
||||
// no-default-location invariant). This is the M4 convention persist uses to place
|
||||
// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves
|
||||
// audio paths the SAME way persist does rather than re-implementing the derivation.
|
||||
std::string projectDirOfRpp(const std::string& rppPath);
|
||||
|
||||
// A relocation plan for the physical bank folder on Save-As to a new project
|
||||
// location. The index's relative paths do NOT change (they are relative to the
|
||||
// project dir, which is what moved with the .rpp), so relocation is purely a
|
||||
|
||||
+7
-1
@@ -19,7 +19,13 @@ bool insideClient(int px, int py, const PanelClientRect& c) {
|
||||
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
|
||||
const DragState& state) {
|
||||
if (!state.dragging || !state.hasArmedSamples) return DragGesture::None;
|
||||
return insideClient(px, py, client) ? DragGesture::Internal : DragGesture::OsDrag;
|
||||
if (insideClient(px, py, client)) return DragGesture::Internal;
|
||||
// Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is
|
||||
// still over REAPER's own UI is an instrument drop (heading for a track's FX button);
|
||||
// anything else (a multi-capture payload, or the pointer off REAPER entirely) is the
|
||||
// unchanged M11 OS drag-out.
|
||||
if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop;
|
||||
return DragGesture::OsDrag;
|
||||
}
|
||||
|
||||
PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
|
||||
|
||||
+29
-7
@@ -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
|
||||
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);
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
// ext_keys — the SINGLE SOURCE OF TRUTH for the "reasampler" project ext-state
|
||||
// namespace + key names, shared by the extension (writer, via persist.h) and the
|
||||
// VST3 instrument (reader, via the bridge). Both sides include this header so the
|
||||
// wire contract cannot drift between the two artifacts (the S4 reviewer flagged the
|
||||
// spike's duplicated constants as a drift risk).
|
||||
//
|
||||
// PURE HEADER: NO REAPER types, NO VST3 types, NO SWELL, NO vendor/ includes. The key
|
||||
// spellings are string constants; the NAMESPACE is channel-derived (Phase V, V4) so it
|
||||
// delegates to the pure app_version module (also REAPER-free / VST3-free). Both the
|
||||
// REAPER-facing persist shell and the SDK-facing VST bridge include this without pulling
|
||||
// either SDK.
|
||||
//
|
||||
// FOREVER-STABLE once shipped: these strings key every already-saved project's
|
||||
// stored state. Changing any of them orphans that state. See persist.h for the
|
||||
// per-key retirement / migration semantics — this header only owns the spellings.
|
||||
|
||||
#include "app_version.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The ext-state namespace all ReaSampler project state is stored under. CHANNEL-DERIVED
|
||||
// (Phase V, V4): delegates to the ONE app_version symbol so the extension (writer) and the
|
||||
// VST3 instrument (reader) resolve the SAME namespace per channel — "reasampler" on stable,
|
||||
// "reasampler_beta" on the isolated beta build. An accessor (not a constexpr literal)
|
||||
// because the value is fixed by the channel bit at build time. This is the wire-contract
|
||||
// reconciliation between S4 (shared ext_keys) and V4 (channel-isolated namespace): without
|
||||
// it a beta instrument would read the stable namespace and see empty state.
|
||||
inline const char* kProjExtNamespace() { return extStateNamespace().c_str(); }
|
||||
|
||||
// The multi-bank key: the whole serialized BankBook (pool + named banks). This is
|
||||
// the key the VST3 instrument reads to see the live bank (read-only, S4). persist.h
|
||||
// documents its authority + the legacy-key migration around it.
|
||||
inline constexpr const char* kProjExtBanksKey = "banks";
|
||||
|
||||
// The retired legacy single-bank key (read once on load to migrate into the pool).
|
||||
inline constexpr const char* kProjExtIndexKey = "bank_index";
|
||||
|
||||
// The Design-View model key.
|
||||
inline constexpr const char* kProjExtViewKey = "view_state";
|
||||
|
||||
// The docked panel's tail-setting key.
|
||||
inline constexpr const char* kProjExtTailKey = "tail_setting";
|
||||
|
||||
// The per-project minted-GUID identity key.
|
||||
inline constexpr const char* kProjExtGuidKey = "project_guid";
|
||||
|
||||
// The S9 BANK-GENERATION key. The EXTENSION stamps a monotonic decimal counter here that it
|
||||
// bumps on every bank-content mutation that changes what a live instance would PLAY (capture
|
||||
// add, re-capture-in-place, sample remove, move/copy affecting banks, ingest import). The VST3
|
||||
// instrument READS it off the audio thread on a UI-timer cadence and, when the value differs
|
||||
// from what it last saw, calls reloadFromBank() so a recapture/ingest refreshes playing
|
||||
// instances hands-free (the S9 change-detection trigger). WIRE-SHARED (instrument reads it);
|
||||
// the instrument never WRITES it (the extension owns it, same read-only-over-bank rule as the
|
||||
// assignment request). Additive to the persist blob — an absent stamp reads as generation 0
|
||||
// (a pre-S9 project), and the first bump (>= 1) then reads as a change. FOREVER-STABLE once
|
||||
// shipped: changing this spelling resets every already-shipped instance's change-detection
|
||||
// baseline (a one-time spurious reload), so it is fixed like every sibling key.
|
||||
inline constexpr const char* kProjExtBankGenKey = "bank_generation";
|
||||
|
||||
// The S8 ingest ASSIGNMENT-REQUEST key. The EXTENSION writes an assignment request here
|
||||
// after an ingest-with-assign (arrange capture / Media-Explorer import / drop-onto-panel):
|
||||
// "the active sampler instance should now play THIS sample." The value is the pure
|
||||
// assignment_request wire format ("rsassign1" + bankId + sampleId + generation) — see
|
||||
// assignment_request.h for the exact grammar. WIRE-SHARED because the VST3 instrument
|
||||
// READS it (in a later dispatch, S8 instrument-side follow-up) to update its own selection
|
||||
// and reload; the instrument never WRITES it (the extension writing its own namespace does
|
||||
// not violate the instrument's read-only-over-the-bank rule). FOREVER-STABLE once shipped:
|
||||
// changing this spelling strands any pending request an already-shipped instrument watches.
|
||||
inline constexpr const char* kProjExtAssignKey = "assign_request";
|
||||
|
||||
} // namespace reasampler
|
||||
+596
@@ -0,0 +1,596 @@
|
||||
// ingest.cpp — the S8 "ingest through the bank" shell (extension side). See ingest.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are extern
|
||||
// (CLAUDE.md §contract). REAPER-facing, DAW-verified; the pure serialization it drives
|
||||
// (assignment_request) is CTest-tested.
|
||||
|
||||
#include "ingest.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path)
|
||||
#include "app_version.h" // channelCommandId / channelActionName
|
||||
#include "assignment_request.h" // pure (bankId, sampleId, generation) encode
|
||||
#include "bank_book.h" // BankBook, Bank, activeBankId / activeIndex
|
||||
#include "bank_model.h" // Sample, AddResult, findByHash
|
||||
#include "bank_panel.h" // bankPanelRefresh
|
||||
#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent
|
||||
#include "persist.h" // ReaSamplerSession
|
||||
|
||||
#include "wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path
|
||||
|
||||
#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs)
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_MediaExplorerGetLastPlayedFileInfo
|
||||
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
|
||||
#define REAPERAPI_WANT_PCM_Source_Destroy
|
||||
#define REAPERAPI_WANT_GetMediaSourceNumChannels
|
||||
#define REAPERAPI_WANT_GetMediaSourceSampleRate
|
||||
#define REAPERAPI_WANT_GetMediaSourceLength
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// The live session the ingest paths mutate. Set once by ingestRegisterActions and read by
|
||||
// every ingest body. Not owned here (main.cpp owns g_session).
|
||||
ReaSamplerSession* g_session = nullptr;
|
||||
|
||||
// FOREVER-STABLE ingest action-id SUFFIX (Phase V, V4). The channel prefix is prepended at
|
||||
// register via channelCommandId; NEVER change a shipped suffix. Only the Media-Explorer
|
||||
// import registers here — the arrange capture+assign action lives in the capture family in
|
||||
// main.cpp (it reuses the capture render machinery there), and the drop path is a panel
|
||||
// callback (ingestDroppedFiles), not a bindable action.
|
||||
constexpr const char* kIdImportMediaExplorer = "INGEST_IMPORT_MEDIA_EXPLORER";
|
||||
|
||||
int g_cmdImportMediaExplorer = 0;
|
||||
gaccel_register_t g_accelImportMediaExplorer{};
|
||||
|
||||
// Durable store of the composed, channel-qualified command-id + label strings. Two scalar
|
||||
// std::string globals (one action); their c_str() pointers are handed to REAPER at register
|
||||
// and re-presented at unregister, so these strings must not be mutated after registration.
|
||||
// Populated once by ingestRegisterActions; stable for the extension lifetime.
|
||||
std::string g_idImportStr;
|
||||
std::string g_labelImportStr;
|
||||
|
||||
// --- Project directory --------------------------------------------------------
|
||||
|
||||
// The current project's directory (parent of its .rpp), forward-slashed, no trailing
|
||||
// slash — the M4 convention (projectDirOfRpp). Empty for an unsaved/no-active project,
|
||||
// which makes the import refuse to place a file (no default-location fallback — the
|
||||
// relative-paths invariant). Read-only.
|
||||
std::string currentProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror
|
||||
// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy.
|
||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return {};
|
||||
const std::streamsize n = f.tellg();
|
||||
if (n <= 0) return {};
|
||||
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(n));
|
||||
f.seekg(0);
|
||||
f.read(reinterpret_cast<char*>(bytes.data()), n);
|
||||
if (!f) return {};
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Writes a byte buffer to a file. Returns true on success. The caller is responsible for
|
||||
// ensuring the directory exists before calling.
|
||||
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
if (!f) return false;
|
||||
f.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
return f.good();
|
||||
}
|
||||
|
||||
// Builds a minimal 32-bit-float RIFF/WAVE byte buffer from interleaved double samples.
|
||||
// The output is a canonical WAV the bank and wav_trim can read:
|
||||
// RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, 16-byte body),
|
||||
// data chunk (interleaved little-endian float32, one float per sample per channel).
|
||||
// `nch` channels, `rate` Hz sample rate, `frameCount` frames (total samples = frameCount*nch).
|
||||
// Each ReaSample (double) is narrowed to float by assignment — the instrument expects
|
||||
// 32-bit float; the reduction is intentional and matches how the bank contract is defined
|
||||
// (capture.cpp kRenderFormatWavFloat32; wav_trim.h FORMAT ASSUMPTION).
|
||||
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
std::size_t frameCount,
|
||||
const std::vector<ReaSample>& interleaved) {
|
||||
const std::size_t sampleCount = frameCount * static_cast<std::size_t>(nch);
|
||||
const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32
|
||||
|
||||
// The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload.
|
||||
// Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount.
|
||||
const std::uint32_t riffSize =
|
||||
static_cast<std::uint32_t>(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data
|
||||
|
||||
std::vector<std::uint8_t> out;
|
||||
out.reserve(44u + dataBytesCount);
|
||||
|
||||
auto putU16 = [&](std::uint16_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
};
|
||||
auto putU32 = [&](std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||
};
|
||||
auto putTag = [&](const char* t) {
|
||||
for (int i = 0; i < 4; ++i)
|
||||
out.push_back(static_cast<std::uint8_t>(t[i]));
|
||||
};
|
||||
auto putF32 = [&](float f) {
|
||||
std::uint8_t tmp[4];
|
||||
std::memcpy(tmp, &f, 4);
|
||||
for (int i = 0; i < 4; ++i) out.push_back(tmp[i]);
|
||||
};
|
||||
|
||||
// RIFF header
|
||||
putTag("RIFF");
|
||||
putU32(riffSize);
|
||||
putTag("WAVE");
|
||||
|
||||
// fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003)
|
||||
putTag("fmt ");
|
||||
putU32(16u); // chunk body size
|
||||
putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT
|
||||
putU16(static_cast<std::uint16_t>(nch));
|
||||
putU32(rate);
|
||||
putU32(rate * static_cast<std::uint32_t>(nch) * 4u); // avgBytesPerSec
|
||||
putU16(static_cast<std::uint16_t>(nch * 4)); // blockAlign
|
||||
putU16(32u); // bitsPerSample
|
||||
|
||||
// data chunk
|
||||
putTag("data");
|
||||
putU32(static_cast<std::uint32_t>(dataBytesCount));
|
||||
for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
|
||||
putF32(static_cast<float>(interleaved[i]));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Decodes ALL samples from `src` into interleaved double-precision frames.
|
||||
// Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0).
|
||||
// Uses GetSamples in blocks; advances time_s monotonically. The caller has already
|
||||
// queried channelCount and sampleRate from the same source; those values are passed in
|
||||
// to avoid re-querying after GetSamples mutates decoder state.
|
||||
std::vector<ReaSample> decodePcmSource(PCM_source* src, int nch, double sampleRate,
|
||||
double lengthSeconds) {
|
||||
if (!src || nch <= 0 || sampleRate < 1.0 || lengthSeconds <= 0.0) return {};
|
||||
|
||||
const std::size_t totalFrames =
|
||||
static_cast<std::size_t>(lengthSeconds * sampleRate + 0.5);
|
||||
if (totalFrames == 0) return {};
|
||||
|
||||
std::vector<ReaSample> out;
|
||||
out.reserve(totalFrames * static_cast<std::size_t>(nch));
|
||||
|
||||
// Pull samples in blocks of ~4096 frames; loop until source is exhausted.
|
||||
constexpr int kBlockFrames = 4096;
|
||||
std::vector<ReaSample> block(static_cast<std::size_t>(kBlockFrames * nch));
|
||||
|
||||
PCM_source_transfer_t t{};
|
||||
t.samplerate = sampleRate;
|
||||
t.nch = nch;
|
||||
t.time_s = 0.0;
|
||||
t.midi_events = nullptr;
|
||||
|
||||
while (true) {
|
||||
t.samples = block.data();
|
||||
t.length = kBlockFrames;
|
||||
t.samples_out = 0;
|
||||
src->GetSamples(&t);
|
||||
if (t.samples_out <= 0) break;
|
||||
const std::size_t got = static_cast<std::size_t>(t.samples_out) *
|
||||
static_cast<std::size_t>(nch);
|
||||
out.insert(out.end(), block.data(), block.data() + got);
|
||||
t.time_s += static_cast<double>(t.samples_out) / sampleRate;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The result of an import-into-bank: the sample id to assign (the existing id on a
|
||||
// hash-dedup collapse, the new id otherwise) and whether anything was added to the index
|
||||
// (so the caller opens an undo point only for a real mutation).
|
||||
struct ImportResult {
|
||||
std::string sampleId; // "" on failure (nothing to assign)
|
||||
bool added = false; // true iff a NEW index entry was created (not a collapse)
|
||||
std::string message; // human-readable outcome for the console
|
||||
};
|
||||
|
||||
// Imports one OS-native source file into the ACTIVE bank: convert-if-needed to 32-bit-
|
||||
// float WAV, write to the project-relative bank folder, index-add, hash-dedup applied.
|
||||
//
|
||||
// BANK CONTRACT: the instrument (wav_trim) expects every bank file to be a canonical
|
||||
// 32-bit-float WAV (WAVE_FORMAT_IEEE_FLOAT, 32 bits). A verbatim copy of a non-WAV (or
|
||||
// an integer-PCM or double-float WAV) would be unplayable. This function therefore:
|
||||
// 1. Checks whether the source IS already a valid 32f WAV (parseWavLayout fast path).
|
||||
// 2. If yes: copies it verbatim — one I/O, content unchanged.
|
||||
// 3. If no: decodes via PCM_source::GetSamples and writes a fresh 32f WAV, preserving
|
||||
// the source's channel count and sample rate.
|
||||
//
|
||||
// DEDUP ORDERING: the content hash is taken from the CONVERTED (bank-format) bytes AFTER
|
||||
// building the file buffer but BEFORE writing to disk. This means:
|
||||
// * Re-importing the same source file yields the same converted bytes → same hash →
|
||||
// dedup fires → no redundant disk write (matching the DEDUP-BEFORE-DISK design).
|
||||
// * An imported WAV whose audio-content hash matches a captured WAV also deduplicates
|
||||
// correctly (hashWavContent is chunk-aware for both).
|
||||
// * The pre-conversion hash shortcut (hash the raw source bytes) is not used: a non-WAV
|
||||
// source's bytes would produce a different hash from the converted WAV bytes, so two
|
||||
// imports of the same mp3 would NOT dedup — which is wrong. Hashing post-conversion
|
||||
// is correct.
|
||||
//
|
||||
// NON-DESTRUCTIVE: the source file is never modified or moved — only read.
|
||||
// Records the written file in the owned-file manifest (Phase B B-cap) so Phase R prune can
|
||||
// attribute it. Does NOT persist or open an undo point — the caller batches that (a
|
||||
// multi-file drop is one undo point, one persist).
|
||||
ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
|
||||
ImportResult out;
|
||||
|
||||
if (absoluteSourcePath.empty()) {
|
||||
out.message = "empty file path";
|
||||
return out;
|
||||
}
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
if (!fs::exists(absoluteSourcePath, ec) || ec) {
|
||||
out.message = "file not found: " + absoluteSourcePath;
|
||||
return out;
|
||||
}
|
||||
|
||||
const std::string projectDir = currentProjectDir();
|
||||
if (projectDir.empty()) {
|
||||
out.message = "no saved project, so the bank has no location -- save the "
|
||||
"project first";
|
||||
return out;
|
||||
}
|
||||
|
||||
// Read source bytes; needed to check whether it is already a 32f WAV.
|
||||
const std::vector<std::uint8_t> srcBytes = readFileBytes(absoluteSourcePath);
|
||||
if (srcBytes.empty()) {
|
||||
out.message = "file is empty or unreadable: " + absoluteSourcePath;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Probe the source's audio geometry via PCM_source. Needed for conversion AND for
|
||||
// populating the Sample's metadata. A file REAPER cannot open leaves geometry at
|
||||
// zero — the sample still imports if the WAV-fast-path succeeds; the geometry
|
||||
// is simply unknown, the honest default.
|
||||
int channelCount = 0;
|
||||
int sampleRate = 0;
|
||||
double lengthSeconds = 0.0;
|
||||
PCM_source* srcHandle = PCM_Source_CreateFromFile(absoluteSourcePath.c_str());
|
||||
if (srcHandle) {
|
||||
channelCount = GetMediaSourceNumChannels(srcHandle);
|
||||
sampleRate = GetMediaSourceSampleRate(srcHandle);
|
||||
bool isQN = false;
|
||||
lengthSeconds = GetMediaSourceLength(srcHandle, &isQN);
|
||||
if (isQN) lengthSeconds = 0.0; // QN-length source has no seconds length to store
|
||||
}
|
||||
|
||||
// Determine whether a verbatim copy suffices (fast path) or a conversion is needed.
|
||||
// parseWavLayout validates that the source is a canonical 32-bit-float RIFF/WAVE; any
|
||||
// other format (mp3, aiff, integer PCM, 16-bit WAV, etc.) takes the decode+rewrite path.
|
||||
const WavLayout layout = parseWavLayout(srcBytes);
|
||||
const bool isFloat32Wav = layout.valid;
|
||||
|
||||
// Build the bank-format bytes in memory (the "converted" bytes), which we hash for dedup
|
||||
// BEFORE writing to disk so a re-import of the same source skips the disk write.
|
||||
std::vector<std::uint8_t> bankBytes;
|
||||
if (isFloat32Wav) {
|
||||
// Fast path: already canonical — bank bytes ARE the source bytes.
|
||||
bankBytes = srcBytes;
|
||||
if (srcHandle) PCM_Source_Destroy(srcHandle);
|
||||
} else {
|
||||
// Conversion path: decode all samples then write a fresh 32f WAV.
|
||||
// PCM_source is opened on the source path (not a copy); we already have srcHandle.
|
||||
std::vector<ReaSample> decoded;
|
||||
if (srcHandle && channelCount > 0 && sampleRate > 0 && lengthSeconds > 0.0) {
|
||||
decoded = decodePcmSource(srcHandle, channelCount,
|
||||
static_cast<double>(sampleRate), lengthSeconds);
|
||||
}
|
||||
if (srcHandle) PCM_Source_Destroy(srcHandle);
|
||||
|
||||
if (decoded.empty()) {
|
||||
// No decodable audio. The source is on disk (valid path, REAPER could open it)
|
||||
// but yielded no samples — e.g. a MIDI file, a zero-length audio file, or a
|
||||
// format REAPER does not support. Fail loudly: we must not write a silent WAV
|
||||
// and pretend the import succeeded.
|
||||
out.message = "could not decode audio samples from: " +
|
||||
fs::path(absoluteSourcePath).filename().string() +
|
||||
" (unsupported format or no audio data)";
|
||||
return out;
|
||||
}
|
||||
|
||||
const std::size_t frameCount =
|
||||
decoded.size() / static_cast<std::size_t>(channelCount > 0 ? channelCount : 1);
|
||||
bankBytes = buildFloat32Wav(channelCount,
|
||||
static_cast<std::uint32_t>(sampleRate),
|
||||
frameCount, decoded);
|
||||
}
|
||||
// srcHandle is destroyed above in both branches.
|
||||
|
||||
// Hash the converted (bank-format) bytes for dedup. WAV-aware hash (hashWavContent)
|
||||
// so a re-import of the same source deduplicates against a previously-captured or
|
||||
// previously-imported sample with identical audio content, even if non-audio RIFF
|
||||
// chunks differ. Empty hash (unhashable) is treated as "not dedupable" (safe direction:
|
||||
// copies + adds rather than silently collapsing onto an unrelated entry).
|
||||
const std::string contentHash = hashWavContent(bankBytes);
|
||||
|
||||
BankBook& book = g_session->book();
|
||||
|
||||
// Dedup-before-disk: if the active bank already holds this audio content, assign the
|
||||
// existing sample's id and skip the disk write (no redundant on-disk duplicate).
|
||||
// Empty hashes never match (findByHash treats "" as non-participating).
|
||||
if (!contentHash.empty()) {
|
||||
if (const Sample* existing = book.activeIndex().findByHash(contentHash)) {
|
||||
out.sampleId = existing->id;
|
||||
out.added = false; // already present — no index mutation, no undo point
|
||||
out.message = "already in the active bank (assigned existing sample)";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive the destination path. The stem comes from the source file name; a timestamp
|
||||
// uniqueTag avoids collision with a prior import of a same-named file.
|
||||
const std::string sourceStem = fs::path(absoluteSourcePath).stem().string();
|
||||
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
|
||||
const std::string uniqueTag = std::to_string(nowSec);
|
||||
const BankPaths paths = deriveBankPaths(projectDir, sourceStem, uniqueTag);
|
||||
|
||||
// Ensure the bank folder exists, then write the (converted) bank bytes.
|
||||
fs::create_directories(paths.absoluteDir, ec); // idempotent; ec ignored (write reports)
|
||||
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
|
||||
if (!writeFileBytes(destPath, bankBytes)) {
|
||||
out.message = "could not write converted file to the bank folder";
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build the Sample. Import is NOT a capture — sourceMode/range/tail do not apply; we
|
||||
// record what we know (path, hash, geometry, name) and leave capture-only fields at
|
||||
// their defaults. rootNote/loop stay empty: an imported file is not a single played
|
||||
// note, so we do not guess a root note.
|
||||
Sample s;
|
||||
s.id = "imp-" + uniqueTag + "-" + paths.fileName;
|
||||
s.displayName = sourceStem.empty() ? std::string("import") : sourceStem;
|
||||
s.relativePath = paths.relativePath; // project-relative (invariant)
|
||||
s.channelCount = channelCount;
|
||||
s.sampleRate = sampleRate;
|
||||
s.lengthSeconds = lengthSeconds;
|
||||
s.tier = Tier::Scratch; // imports land in scratch, like captures
|
||||
s.contentHash = contentHash;
|
||||
s.createdTimestamp = nowSec;
|
||||
|
||||
const AddResult r = book.activeIndex().add(s);
|
||||
// Record the written file as owned regardless of the add outcome — the tool WROTE it, so
|
||||
// Phase R prune must attribute it. (A Collapsed result here would mean another sample in
|
||||
// the active bank matched the hash after we passed the pre-write dedup check — a narrow
|
||||
// race window. Record + handle both honestly.)
|
||||
g_session->owned().add(paths.relativePath);
|
||||
|
||||
switch (r) {
|
||||
case AddResult::Added:
|
||||
out.sampleId = s.id;
|
||||
out.added = true;
|
||||
out.message = (isFloat32Wav ? "imported -> " : "converted + imported -> ") +
|
||||
paths.relativePath;
|
||||
break;
|
||||
case AddResult::Collapsed: {
|
||||
// The hash matched an existing entry (a race against our pre-write dedup check,
|
||||
// or an empty-hash edge). Assign the existing entry's id.
|
||||
const Sample* existing =
|
||||
contentHash.empty() ? nullptr : book.activeIndex().findByHash(contentHash);
|
||||
out.sampleId = existing ? existing->id : std::string{};
|
||||
out.added = false;
|
||||
out.message = "collapsed onto an existing bank sample";
|
||||
break;
|
||||
}
|
||||
case AddResult::RejectedAbsolutePath:
|
||||
case AddResult::RejectedEmptyId:
|
||||
// deriveBankPaths always yields a relative path and a non-empty id above, so
|
||||
// these are unreachable in practice — reported honestly rather than silently.
|
||||
out.message = "index rejected the import (internal path/id error)";
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Media-Explorer import action --------------------------------------------
|
||||
|
||||
// Import the Media Explorer's current last-played/selected file into the active bank and
|
||||
// assign it to the active instance (S8 surface 2). Single-file, pull-on-action:
|
||||
// MediaExplorerGetLastPlayedFileInfo returns the ONE last-played file (the whole ME
|
||||
// contract — no enumerate-selected API). The selection RANGE it reports is deliberately
|
||||
// IGNORED here: an import brings the whole file into the bank (the range is a preview
|
||||
// hint, and the fields are [0,1] fractions, not seconds — see the DAW-verify note); a
|
||||
// user wanting a sub-range captures it via the arrange path instead. Undo-wrapped.
|
||||
void doImportFromMediaExplorer() {
|
||||
// filemode/sel/pitch/vol/rate/bpm/extrainfo are read but only the filename is used for
|
||||
// the import. selstart/selend are [0,1] fractions (SDK header) — a preview hint, not a
|
||||
// bank-relevant range; left unused. extrainfo is documented "currently unused".
|
||||
std::vector<char> nameBuf(4096, '\0');
|
||||
int filemode = 0;
|
||||
double selStart = 0.0, selEnd = 0.0;
|
||||
double pitch = 0.0, vol = 0.0, rate = 0.0, srcbpm = 0.0;
|
||||
std::vector<char> extra(256, '\0'); // documented unused; sized generously to be safe
|
||||
const bool ok = MediaExplorerGetLastPlayedFileInfo(
|
||||
nameBuf.data(), static_cast<int>(nameBuf.size()), &filemode, &selStart, &selEnd,
|
||||
&pitch, &vol, &rate, &srcbpm, extra.data(), static_cast<int>(extra.size()));
|
||||
|
||||
const std::string path(nameBuf.data());
|
||||
if (!ok || path.empty()) {
|
||||
ShowConsoleMsg("ReaSampler ingest: no Media Explorer file to import -- open the "
|
||||
"Media Explorer and select (or preview) a file first.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const ImportResult r = importFileIntoActiveBank(path);
|
||||
if (r.sampleId.empty()) {
|
||||
ShowConsoleMsg(("ReaSampler ingest: Media Explorer import failed -- " + r.message +
|
||||
".\n").c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the bank add AND the assign request inside ONE undo block so Ctrl-Z rolls
|
||||
// back both keys atomically: undo restores `banks` (removing the new sample) AND
|
||||
// clears the `assign_request` that named it, so no stale request can survive.
|
||||
// The block is opened only when the index mutated (a dedup collapse changed nothing).
|
||||
// If saveToActiveProject() no-ops (unsaved project), we close with an empty label +
|
||||
// zero flag so REAPER discards the undo entry (the house pattern from actions.cpp).
|
||||
if (r.added) {
|
||||
Undo_BeginBlock2(nullptr);
|
||||
// S9: an ingest import adds a sample to the active bank -> bump inside the block so
|
||||
// the stamped generation refreshes the assigned instance hands-free (and undo rolls
|
||||
// the generation back with the banks/assign_request keys).
|
||||
g_session->bumpBankGeneration();
|
||||
const bool persisted = g_session->saveToActiveProject();
|
||||
// Assign request inside the same block: undo rolls back both keys together.
|
||||
ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId);
|
||||
if (persisted)
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer",
|
||||
UNDO_STATE_MISCCFG);
|
||||
else
|
||||
Undo_EndBlock2(nullptr, "", 0);
|
||||
} else {
|
||||
// Dedup collapse: index unchanged, no undo point. Assign request still written
|
||||
// (the user explicitly re-imported; they want the instance updated).
|
||||
ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId);
|
||||
}
|
||||
bankPanelRefresh();
|
||||
ShowConsoleMsg(("ReaSampler ingest: " + r.message + " (assigned to the active "
|
||||
"instance).\n").c_str());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- Assignment-request write ------------------------------------------------
|
||||
|
||||
void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId) {
|
||||
if (!g_session || sampleId.empty()) return; // nothing to assign
|
||||
|
||||
AssignmentRequest req;
|
||||
req.bankId = bankId;
|
||||
req.sampleId = sampleId;
|
||||
// Monotonic disambiguator: a wall-clock unix-epoch stamp so the reader tells a fresh
|
||||
// assign (even re-assigning the SAME id) from a stale value. NOT the S9 bank-generation
|
||||
// counter (a separate point) — this field is self-contained to the request.
|
||||
req.generation = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
g_session->writeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
}
|
||||
|
||||
// --- Drop-onto-panel ingest --------------------------------------------------
|
||||
|
||||
void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
|
||||
if (!g_session || absolutePaths.empty()) return;
|
||||
|
||||
// Import ALL dropped files; assign the FIRST successfully-imported one (documented
|
||||
// multi-file policy). Batch the persist + undo point: many imports are ONE undo entry.
|
||||
std::string firstAssignId;
|
||||
std::string firstAssignBank;
|
||||
int importedNew = 0;
|
||||
int importedTotal = 0; // includes dedup collapses that still yielded an id to assign
|
||||
std::string lastFailure;
|
||||
|
||||
for (const std::string& path : absolutePaths) {
|
||||
const ImportResult r = importFileIntoActiveBank(path);
|
||||
if (r.sampleId.empty()) {
|
||||
lastFailure = r.message;
|
||||
continue;
|
||||
}
|
||||
++importedTotal;
|
||||
if (r.added) ++importedNew;
|
||||
if (firstAssignId.empty()) {
|
||||
firstAssignId = r.sampleId;
|
||||
firstAssignBank = g_session->book().activeBankId();
|
||||
}
|
||||
}
|
||||
|
||||
// One undo point for the whole drop, opened only if a NEW index entry was created (a
|
||||
// drop that only re-hit existing content mutated nothing on the index). The assign
|
||||
// request is written INSIDE the same block so Ctrl-Z rolls back both keys together:
|
||||
// undo restores `banks` (removing the new samples) AND clears the `assign_request` that
|
||||
// named one of them, so no stale request survives pointing to a removed sample.
|
||||
// If saveToActiveProject() no-ops (unsaved project), we close with an empty label + zero
|
||||
// flag so REAPER discards the undo entry (house pattern from actions.cpp).
|
||||
if (!firstAssignId.empty()) {
|
||||
if (importedNew > 0) {
|
||||
Undo_BeginBlock2(nullptr);
|
||||
// S9: one coalesced bump for the whole drop (>=1 new sample landed) inside the
|
||||
// block so the generation refreshes the assigned instance and undo rolls it back.
|
||||
g_session->bumpBankGeneration();
|
||||
const bool persisted = g_session->saveToActiveProject();
|
||||
// Assign inside the block: undo restores both keys atomically.
|
||||
ingestAssignActiveInstance(firstAssignBank, firstAssignId);
|
||||
if (persisted)
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: import dropped file(s)",
|
||||
UNDO_STATE_MISCCFG);
|
||||
else
|
||||
Undo_EndBlock2(nullptr, "", 0);
|
||||
} else {
|
||||
// All dropped files deduplicated: index unchanged, no undo point needed. Still
|
||||
// assign so the user sees the sample is already in the bank.
|
||||
ingestAssignActiveInstance(firstAssignBank, firstAssignId);
|
||||
}
|
||||
bankPanelRefresh();
|
||||
const std::string msg =
|
||||
"ReaSampler ingest: imported " + std::to_string(importedTotal) +
|
||||
(importedTotal == 1 ? " file" : " files") +
|
||||
" and assigned the first to the active instance.\n";
|
||||
ShowConsoleMsg(msg.c_str());
|
||||
} else {
|
||||
ShowConsoleMsg(("ReaSampler ingest: nothing imported from the drop -- " +
|
||||
(lastFailure.empty() ? std::string("no usable files") : lastFailure) +
|
||||
".\n").c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Action registration ------------------------------------------------------
|
||||
|
||||
void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
|
||||
g_session = session; // shared with the capture / bank / Design-View families
|
||||
|
||||
g_idImportStr = channelCommandId(kIdImportMediaExplorer);
|
||||
g_cmdImportMediaExplorer = rec->Register("command_id", (void*)g_idImportStr.c_str());
|
||||
if (g_cmdImportMediaExplorer) {
|
||||
g_labelImportStr = channelActionName("import Media Explorer file into bank + assign");
|
||||
g_accelImportMediaExplorer.accel.cmd = g_cmdImportMediaExplorer;
|
||||
g_accelImportMediaExplorer.desc = g_labelImportStr.c_str();
|
||||
rec->Register("gaccel", (void*)&g_accelImportMediaExplorer);
|
||||
}
|
||||
}
|
||||
|
||||
bool ingestHandleCommand(int command) {
|
||||
if (command == 0 || !g_session) return false;
|
||||
if (command == g_cmdImportMediaExplorer) { doImportFromMediaExplorer(); return true; }
|
||||
return false; // not ours — caller's hookcommand keeps looking
|
||||
}
|
||||
|
||||
void ingestUnregisterActions(reaper_plugin_info_t* rec) {
|
||||
// Mirror-unregister with '-'-prefixed strings; the '-command_id' re-presents the SAME
|
||||
// interned channel-qualified id used at register (g_idImportStr).
|
||||
rec->Register("-gaccel", (void*)&g_accelImportMediaExplorer);
|
||||
rec->Register("-command_id", (void*)g_idImportStr.c_str());
|
||||
g_session = nullptr;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
// ingest — the S8 "ingest through the bank" shell (EXTENSION side).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. REAPER-facing (PCM_Source metadata reads,
|
||||
// Media-Explorer query, ext-state assignment write, action registration), so it is
|
||||
// DAW-verified, not unit-tested; the pure serialization it drives lives in
|
||||
// assignment_request (tested in CTest).
|
||||
//
|
||||
// -- The one gesture (CONTEXT.md §Ingest through the bank) --------------------
|
||||
//
|
||||
// Loading a sample into the sampler is ONE gesture: capture/import-into-bank AND
|
||||
// auto-assign to the active sampler instance. The EXTENSION owns ingest (it has arrange
|
||||
// access, Media-Explorer access, and the drop-target surface on its own panels); the
|
||||
// instrument stays a READ-ONLY bank consumer. Three ingest surfaces:
|
||||
//
|
||||
// 1. Arrange capture -> bank -> assign (a bindable action; reuses the capture path).
|
||||
// 2. Media-Explorer import -> bank -> assign (a bindable action; single-file, pull-on-
|
||||
// action via MediaExplorerGetLastPlayedFileInfo).
|
||||
// 3. Drop-onto-panel -> bank -> assign (an OS file drop on the docked bank_panel HWND;
|
||||
// multi-file: import all, assign the first).
|
||||
//
|
||||
// -- The load-bearing principle (restated) -----------------------------------
|
||||
//
|
||||
// Ingest NEVER inserts a timeline item. Capture writes a file + an index entry; import
|
||||
// copies a file + adds an index entry; assignment is a bank-index + instance-selection
|
||||
// act, not a placement. Any path here that calls InsertMedia would be a bug.
|
||||
//
|
||||
// -- Import semantics ---------------------------------------------------------
|
||||
//
|
||||
// A Media-Explorer/drop import is a FILE COPY into the project-relative bank folder +
|
||||
// an index add, mirroring how a capture lands (relative-paths-only, hash-dedup). If the
|
||||
// active bank already holds the imported content (by content hash), the import collapses
|
||||
// onto the existing sample and assigns THAT sample's id — no redundant on-disk copy.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Forward declarations keep this header REAPER-free at its own boundary (the .cpp pulls
|
||||
// the SDK). reaper_plugin_info_t is REAPER's dispatch struct; ReaSamplerSession owns the
|
||||
// book + persist bridge the ingest paths mutate.
|
||||
struct reaper_plugin_info_t;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// Registers the S8 ingest action family (command_id/gaccel per the house contract),
|
||||
// mirror of bankRegisterActions. `session` is the live session the ingest paths mutate
|
||||
// (shared with the capture / bank / Design-View families). The single hookcommand in
|
||||
// main.cpp routes fired ids here via ingestHandleCommand.
|
||||
void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
|
||||
|
||||
// Routes a fired command id to its ingest action. Returns true iff it was one of ours
|
||||
// (claim-only, per the hookcommand contract); false otherwise so the hook keeps looking.
|
||||
bool ingestHandleCommand(int command);
|
||||
|
||||
// Mirror-unregisters the ingest action family on unload (the '-'-prefixed strings).
|
||||
void ingestUnregisterActions(reaper_plugin_info_t* rec);
|
||||
|
||||
// Write the S8 assignment request for a just-ingested sample: "the active sampler
|
||||
// instance should now play (bankId, sampleId)." Encodes the pure assignment_request value
|
||||
// (with a fresh monotonic generation stamp) and routes it to ext state via the session.
|
||||
// Called by EVERY ingest surface after the sample lands in the bank — the arrange
|
||||
// capture+assign action (main.cpp, alongside the capture machinery it reuses), the ME
|
||||
// import action, and the drop path. A no-op-safe write: if there is no saved/active
|
||||
// project the request is silently dropped (nothing to signal into), matching the
|
||||
// book/manifest quiet-persist idiom. `sampleId` empty -> no write (nothing to assign).
|
||||
void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId);
|
||||
|
||||
// Ingest OS-dropped files onto a ReaSampler surface (S8 drop path). Called by the
|
||||
// bank_panel's WM_DROPFILES handler with the dropped file paths (absolute, OS-native).
|
||||
// Imports EVERY file into the active bank (copy + index add, hash-dedup) and assigns the
|
||||
// FIRST successfully-imported sample to the active instance. A no-op on an empty list or
|
||||
// an unsaved/no-active project (nothing to import into). Reports outcomes to the console.
|
||||
void ingestDroppedFiles(const std::vector<std::string>& absolutePaths);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,108 @@
|
||||
// instrument_drop — pure implementation. See instrument_drop.h.
|
||||
// NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer.
|
||||
|
||||
#include "instrument_drop.h"
|
||||
|
||||
#include "vst/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kB64Alphabet[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
// -1 = not a base64 char; index by unsigned byte. Built once.
|
||||
int b64Value(unsigned char c) {
|
||||
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||
if (c == '+') return 62;
|
||||
if (c == '/') return 63;
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId) {
|
||||
// The ONE fact the drop carries: this capture is the instance's selection. Everything
|
||||
// else stays at the fresh-instance defaults (no zones, mono, generation 0) — the same
|
||||
// ComponentState a browser click would produce. serializeComponentState is the
|
||||
// instrument's own writer (the single source of truth for the byte layout), so this is
|
||||
// NOT a parallel encoder — it IS the instrument's encoder.
|
||||
ComponentState cs;
|
||||
cs.selectionId = sampleId;
|
||||
return serializeComponentState(cs);
|
||||
}
|
||||
|
||||
std::string buildInstrumentDropChunk(const std::string& sampleId) {
|
||||
return encodeBase64(instrumentDropStateBytes(sampleId));
|
||||
}
|
||||
|
||||
std::string encodeBase64(const std::vector<std::uint8_t>& bytes) {
|
||||
std::string out;
|
||||
out.reserve(((bytes.size() + 2) / 3) * 4);
|
||||
std::size_t i = 0;
|
||||
const std::size_t n = bytes.size();
|
||||
while (i + 3 <= n) {
|
||||
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
|
||||
static_cast<std::uint32_t>(bytes[i + 2]);
|
||||
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
|
||||
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
|
||||
out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]);
|
||||
out.push_back(kB64Alphabet[triple & 0x3F]);
|
||||
i += 3;
|
||||
}
|
||||
const std::size_t rem = n - i;
|
||||
if (rem == 1) {
|
||||
const std::uint32_t triple = static_cast<std::uint32_t>(bytes[i]) << 16;
|
||||
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
|
||||
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
|
||||
out.push_back('=');
|
||||
out.push_back('=');
|
||||
} else if (rem == 2) {
|
||||
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 1]) << 8);
|
||||
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
|
||||
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
|
||||
out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]);
|
||||
out.push_back('=');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> decodeBase64(const std::string& b64) {
|
||||
std::vector<std::uint8_t> out;
|
||||
if (b64.size() % 4 != 0) return out; // malformed length -> empty (never throws)
|
||||
out.reserve((b64.size() / 4) * 3);
|
||||
for (std::size_t i = 0; i < b64.size(); i += 4) {
|
||||
const char c0 = b64[i], c1 = b64[i + 1], c2 = b64[i + 2], c3 = b64[i + 3];
|
||||
const int v0 = b64Value(static_cast<unsigned char>(c0));
|
||||
const int v1 = b64Value(static_cast<unsigned char>(c1));
|
||||
if (v0 < 0 || v1 < 0) return {}; // illegal char in a non-pad position -> empty
|
||||
// Padding is only legal in the last two positions of the last quad.
|
||||
const bool pad2 = (c2 == '=');
|
||||
const bool pad3 = (c3 == '=');
|
||||
if ((pad2 || pad3) && i + 4 != b64.size()) return {}; // pad before the final quad
|
||||
if (pad2 && !pad3) return {}; // "=X" is malformed
|
||||
std::uint32_t triple = (static_cast<std::uint32_t>(v0) << 18) |
|
||||
(static_cast<std::uint32_t>(v1) << 12);
|
||||
out.push_back(static_cast<std::uint8_t>((triple >> 16) & 0xFF));
|
||||
if (!pad2) {
|
||||
const int v2 = b64Value(static_cast<unsigned char>(c2));
|
||||
if (v2 < 0) return {};
|
||||
triple |= static_cast<std::uint32_t>(v2) << 6;
|
||||
out.push_back(static_cast<std::uint8_t>((triple >> 8) & 0xFF));
|
||||
if (!pad3) {
|
||||
const int v3 = b64Value(static_cast<unsigned char>(c3));
|
||||
if (v3 < 0) return {};
|
||||
triple |= static_cast<std::uint32_t>(v3);
|
||||
out.push_back(static_cast<std::uint8_t>(triple & 0xFF));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
// instrument_drop — the PURE blob-construction core of S17 drop-and-load.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK,
|
||||
// NO vendor/ includes. Standard library only (+ the pure sample_map it reuses). Unit-tested
|
||||
// outside the DAW — the same "small pure builder + round-trip proof" pattern as
|
||||
// assignment_request / provenance.
|
||||
//
|
||||
// -- What it is (the S17 seam, extension side) --------------------------------
|
||||
//
|
||||
// S17 drops a bank capture onto a track's FX button, which instantiates ReaSampler 9000 on
|
||||
// that track ALREADY PLAYING that capture. The SETTLED mechanism (PLAN.md §S17, mechanism
|
||||
// (B) — VST3 component-state injection) is: after TrackFX_AddByName creates the instance, the
|
||||
// extension writes the instance's component state directly via
|
||||
// TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)
|
||||
// with the dragged capture PRE-SELECTED.
|
||||
//
|
||||
// LOAD-BEARING CAVEAT (PLAN.md §S17): "vst_chunk" is the plugin's OWN base64-encoded
|
||||
// serialized chunk — the exact bytes ReaSampler 9000's setState/getState round-trips — NOT a
|
||||
// neutral representation REAPER re-marshals. So the extension must construct EXACTLY the
|
||||
// instrument's own state-blob bytes. This module does that WITHOUT hand-rolling a parallel
|
||||
// byte writer: it calls the instrument's OWN serializer, sample_map::serializeComponentState
|
||||
// (the single source of truth for the byte layout — the same function the processor's
|
||||
// getState calls), then base64-encodes the result. The shared-writer requirement (both
|
||||
// artifacts live in this repo → reuse the exact same code) is satisfied structurally: if the
|
||||
// instrument's format changes, this module changes with it because it CALLS it.
|
||||
//
|
||||
// The base64 encoding is what REAPER's vst_chunk write-parm documents it accepts (see
|
||||
// reaper_plugin_functions.h: "vst_chunk[_program] : base64-encoded VST-specific chunk").
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Build the base64 blob the extension writes to TrackFX_SetNamedConfigParm(..., "vst_chunk").
|
||||
// `sampleId` is the dragged capture's stable bank id — the ONLY thing the drop pre-selects.
|
||||
// The resulting ComponentState is the instrument's default face with just this one capture
|
||||
// picked: {selectionId = sampleId, no zones, mono, lastConsumedAssignGeneration = 0} — exactly
|
||||
// what a fresh instance would hold after the user clicked that capture in the browser. The
|
||||
// keymap builds under the product defaults (Gate + Preserve) from the bank's own S2 intrinsics,
|
||||
// so the sample plays MIDI-triggered immediately (the S17 "loaded, selected, playable" verify).
|
||||
//
|
||||
// An EMPTY sampleId yields the empty-state blob ({"", no zones}) — a drop of nothing selects
|
||||
// nothing (the S10 silent empty state); the shell guards against this upstream, but the pure
|
||||
// contract is defined.
|
||||
//
|
||||
// Deterministic: the same sampleId always yields the same blob (base64 of the same bytes).
|
||||
std::string buildInstrumentDropChunk(const std::string& sampleId);
|
||||
|
||||
// The raw (pre-base64) component-state bytes — exposed so the round-trip test can decode them
|
||||
// back through the instrument's OWN reader (sample_map::deserializeComponentState) and assert
|
||||
// the capture is selected, proving buildInstrumentDropChunk feeds the instrument exactly what
|
||||
// its setState expects. Not called by the shell (which uses the base64 form).
|
||||
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId);
|
||||
|
||||
// Standard base64 encode/decode (RFC 4648, '+' '/' alphabet, '=' padding). Exposed so the
|
||||
// round-trip test can decode buildInstrumentDropChunk's output. decodeBase64 returns the
|
||||
// decoded bytes; on malformed input (bad length / illegal char) it returns an EMPTY vector
|
||||
// (never throws) — the test asserts a clean decode, and the shell never decodes.
|
||||
std::string encodeBase64(const std::vector<std::uint8_t>& bytes);
|
||||
std::vector<std::uint8_t> decodeBase64(const std::string& b64);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,90 @@
|
||||
// instrument_drop_win — the REAPER shell for S17 drop-and-load. See instrument_drop_win.h.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the pointers; here they are extern via the WANT list).
|
||||
|
||||
#include "instrument_drop_win.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing)
|
||||
|
||||
#include "reaper_plugin.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetThingFromPoint
|
||||
#define REAPERAPI_WANT_TrackFX_AddByName
|
||||
#define REAPERAPI_WANT_TrackFX_Delete
|
||||
#define REAPERAPI_WANT_TrackFX_SetNamedConfigParm
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// GetThingFromPoint's info string prefixes (verified against reaper_plugin_functions.h:
|
||||
// "Updates infoOut with information such as 'arrange', 'fx_chain', 'fx_0' ... If a track
|
||||
// panel is hit, string will begin with 'tcp' or 'mcp' or 'tcp.mute' etc"). The FX region
|
||||
// reports "fx_chain" (the FX list area) or "fx_N" (a specific FX button). We treat either
|
||||
// as the FX hotspot — the S17 drop target.
|
||||
bool infoNamesFxHotspot(const char* info) {
|
||||
return std::strncmp(info, "fx_", 3) == 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FxDropTarget resolveFxDropTarget(int screenX, int screenY) {
|
||||
FxDropTarget out;
|
||||
char info[256] = {0};
|
||||
// GetThingFromPoint returns the track under the point (may be null for a non-track thing)
|
||||
// and fills `info` with what was hit. A non-empty info OR a non-null track means the point
|
||||
// is over REAPER's own UI; a null track with an empty info means the pointer has left
|
||||
// REAPER entirely (over another app / the desktop) — the OsDrag boundary.
|
||||
MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
|
||||
out.track = track;
|
||||
out.overReaperUi = (track != nullptr) || (info[0] != '\0');
|
||||
out.overFxHotspot = (track != nullptr) && infoNamesFxHotspot(info);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64) {
|
||||
if (!track || chunkBase64.empty()) return false;
|
||||
|
||||
// The CHANNEL-correct FX name: "VST3:ReaSampler 9000" on stable, "VST3:ReaSampler 9000
|
||||
// beta" on beta. Sourcing it from app_version::vstPluginName() (the same accessor the VST
|
||||
// factory display name derives from) keeps the pairing invariant intact — a beta extension
|
||||
// drops the beta VST, a stable extension the stable VST — with no literal to drift.
|
||||
const std::string fxName = "VST3:" + vstPluginName();
|
||||
|
||||
// One undo point for the whole gesture (mirrors the bank-verb undo discipline). Both the
|
||||
// FX add and the state write are REAPER-undoable, so Ctrl-Z removes the instance cleanly.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
|
||||
// Negative `instantiate` => always create a NEW instance (verified in the header). recFX
|
||||
// = false: a normal track FX chain instance, not a record/monitoring FX.
|
||||
const int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
|
||||
/*instantiate=*/-1);
|
||||
bool ok = false;
|
||||
if (fxIndex >= 0) {
|
||||
// Inject the instrument's OWN component-state blob (the dragged capture pre-selected)
|
||||
// via the documented vst_chunk write-parm. The blob was built by the shared writer
|
||||
// (instrument_drop::buildInstrumentDropChunk -> sample_map::serializeComponentState),
|
||||
// so these bytes are exactly what ReaSampler 9000's setState accepts.
|
||||
ok = TrackFX_SetNamedConfigParm(track, fxIndex, "vst_chunk", chunkBase64.c_str());
|
||||
if (!ok) {
|
||||
// All-or-nothing: if the chunk write fails, remove the empty FX instance we just
|
||||
// added so the track is left exactly as it was. TrackFX_Delete signature (verified
|
||||
// in reaper_plugin_functions.h:7236): bool TrackFX_Delete(MediaTrack*, int fx).
|
||||
TrackFX_Delete(track, fxIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// The undo label reflects the placement-of-the-player framing (not a capture, not an insert).
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
// instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture
|
||||
// decision lives in drag_out (DragGesture::InstrumentDrop) and the pure blob construction in
|
||||
// instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track +
|
||||
// its TCP FX-button hotspot via REAPER's hit-test API, and (b) on release adds a ReaSampler
|
||||
// 9000 instance to that track and injects the dragged capture as its component state.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. REAPER-facing (GetThingFromPoint, TrackFX_*,
|
||||
// Undo_*), so DAW-verified, not unit-tested; the pure decision + blob it drives are CTest'd.
|
||||
//
|
||||
// LOAD-BEARING (CONTEXT.md §Drop-and-load): this is an EXPLICIT user placement-of-the-player
|
||||
// gesture — it adds a READER of the bank on a track and points it at one already-captured
|
||||
// sample. It NEVER captures, NEVER writes the bank, and NEVER inserts a timeline item. The
|
||||
// only writes are: a new FX instance on the target track + that instance's own component
|
||||
// state — both REAPER-undoable, wrapped in one undo block so the whole gesture is one Ctrl-Z.
|
||||
|
||||
#include <string>
|
||||
|
||||
// Opaque REAPER track handle at the boundary so includers don't need the SDK. The SDK
|
||||
// declares it as a class (reaper_plugin.h) — match that spelling so the mangled name agrees.
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The result of hit-testing a screen point during a live InstrumentDrop drag.
|
||||
struct FxDropTarget {
|
||||
MediaTrack* track = nullptr; // the track under the pointer (null if none / not a track)
|
||||
bool overReaperUi = false; // the point is over REAPER's own window/UI at all
|
||||
bool overFxHotspot = false; // specifically over this track's TCP FX-button/-chain region
|
||||
|
||||
// A valid drop target: a resolved track whose FX hotspot is under the pointer.
|
||||
bool valid() const { return track != nullptr && overFxHotspot; }
|
||||
};
|
||||
|
||||
// Hit-test a screen point (REAPER screen coords) to an FX drop target. Wraps
|
||||
// GetThingFromPoint, whose info string tells us what was hit ("tcp"/"mcp" for a track panel,
|
||||
// "fx_chain"/"fx_N" for the FX area/button). `overReaperUi` is the shell-supplied predicate
|
||||
// the pure drag_out::decideGesture consumes (true when the point is over REAPER's own UI —
|
||||
// i.e. GetThingFromPoint returned a track OR a recognizable non-track thing, false when the
|
||||
// pointer has left REAPER entirely). `overFxHotspot` is true when the info string names the
|
||||
// FX region specifically — the S17 "FX-button hotspot vs. whole TCP" question is resolved to
|
||||
// the FX hotspot (the discoverable, unambiguous target), decided here from the SDK's own
|
||||
// hit-test string rather than a home-grown geometry guess.
|
||||
FxDropTarget resolveFxDropTarget(int screenX, int screenY);
|
||||
|
||||
// Perform the drop on `track`: add a fresh ReaSampler 9000 instance and inject `chunkBase64`
|
||||
// (the instrument_drop::buildInstrumentDropChunk output) as its component state so it plays
|
||||
// the dragged capture. `chunkBase64` is the base64 vst_chunk. Wraps the add + inject in one
|
||||
// REAPER undo block (mirrors the bank-verb undo discipline). Returns true on success (the FX
|
||||
// was added and the chunk written), false on any failure. All-or-nothing: if the chunk write
|
||||
// fails after a successful add, the freshly-added FX instance is removed via TrackFX_Delete
|
||||
// before returning false, leaving the track exactly as it was (no orphaned empty-state FX).
|
||||
// NEVER inserts a timeline item; the ONLY mutations are the FX instance + its state, both
|
||||
// undoable.
|
||||
bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64);
|
||||
|
||||
} // namespace reasampler
|
||||
+142
-9
@@ -32,6 +32,7 @@
|
||||
#include "bank_panel.h"
|
||||
#include "batch_capture.h"
|
||||
#include "capture.h"
|
||||
#include "ingest.h"
|
||||
#include "insert.h"
|
||||
#include "persist.h"
|
||||
#include "provenance.h"
|
||||
@@ -151,6 +152,16 @@ static int g_cmdCaptureTrackRealtime = 0;
|
||||
// explicit action, allowed by the console policy).
|
||||
static int g_cmdRecaptureFromSource = 0;
|
||||
|
||||
// Command id for the S8 "capture selected item / time-selection into bank + assign"
|
||||
// action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline
|
||||
// Item-scope capture path (RunCapture) verbatim — same razor-else-time range, same
|
||||
// FX-scope neutralize, same bank/persist landing — then writes an S8 assignment request
|
||||
// so the active sampler instance plays the just-captured sample on its next reload. NEVER
|
||||
// inserts a timeline item (the capture/placement separation holds; assign is a bank-index
|
||||
// + instance-selection act). Lives in the capture family (not the ingest family) because
|
||||
// it leans on main.cpp's capture render machinery, which is not exposed cross-module.
|
||||
static int g_cmdCaptureItemAssign = 0;
|
||||
|
||||
// Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string.
|
||||
// Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user
|
||||
// who started a long capture can bail without waiting for the range end or hunting for
|
||||
@@ -205,7 +216,10 @@ static void CommitRealtimeResult(const reasampler::CaptureResult& res)
|
||||
// index AddResult — even a hash-collapse still WROTE a file the tool owns, and the
|
||||
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
|
||||
g_session.owned().add(res.sample.relativePath);
|
||||
g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp)
|
||||
// S9: a capture add changes what a live instance could play (a new sample landed in the
|
||||
// active bank) -> bump before the persist so the stamped generation refreshes instances.
|
||||
g_session.bumpBankGeneration();
|
||||
g_session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp)
|
||||
}
|
||||
|
||||
// Advance any in-flight realtime capture one tick. Cheap when none is running (a
|
||||
@@ -738,6 +752,11 @@ static reasampler::CaptureResult renderOffline(
|
||||
// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the
|
||||
// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard),
|
||||
// and the backend restores every RENDER_* setting.
|
||||
//
|
||||
// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id
|
||||
// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8
|
||||
// capture+assign path can target the sample actually in the bank. Batch callers ignore
|
||||
// it; the plain capture actions are unaffected.
|
||||
static reasampler::CaptureResult captureAndIndexOne(
|
||||
reasampler::CaptureScope scope,
|
||||
const ResolvedSource& src,
|
||||
@@ -780,13 +799,26 @@ static reasampler::CaptureResult captureAndIndexOne(
|
||||
// resample-from-sample; otherwise the optional stays empty, per M1's contract).
|
||||
res.sample.provenance = prov;
|
||||
|
||||
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
|
||||
g_session.bank().add(res.sample);
|
||||
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). The
|
||||
// AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can
|
||||
// target the sample actually in the bank (the existing entry on a collapse).
|
||||
const reasampler::AddResult addResult = g_session.bank().add(res.sample);
|
||||
// B-cap: record the created file in the owned-file manifest, at the same point the
|
||||
// Sample is added. Recorded regardless of the index AddResult — even a hash-collapse
|
||||
// still WROTE a file the tool owns, and the manifest dedups a repeat path itself
|
||||
// (Phase R prune reconciles manifest vs index later).
|
||||
g_session.owned().add(res.sample.relativePath);
|
||||
|
||||
// Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new
|
||||
// id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a
|
||||
// Collapsed (the file we just rendered deduped onto an already-present sample — assign
|
||||
// THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged.
|
||||
if (addResult == reasampler::AddResult::Collapsed && !res.sample.contentHash.empty())
|
||||
{
|
||||
if (const reasampler::Sample* existing =
|
||||
g_session.bank().findByHash(res.sample.contentHash))
|
||||
res.sample.id = existing->id;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -794,14 +826,19 @@ static reasampler::CaptureResult captureAndIndexOne(
|
||||
// record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle
|
||||
// holds structurally — this path writes a file + a bank index entry ONLY; it never
|
||||
// calls InsertMedia or touches the arrange/timeline.
|
||||
static void RunCapture(const reasampler::CaptureActionDef& def)
|
||||
// Returns the bank-index id of the sample the capture landed on: the newly-added id on a
|
||||
// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign
|
||||
// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange
|
||||
// capture+assign path reads this to write an assignment request; the plain capture actions
|
||||
// ignore it (their behaviour is unchanged — capture still writes a file + index entry only).
|
||||
static std::string RunCapture(const reasampler::CaptureActionDef& def)
|
||||
{
|
||||
ResolvedSource src;
|
||||
std::string why;
|
||||
if (!ResolveScopeSource(def.scope, src, why))
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
|
||||
reasampler::CaptureResult res =
|
||||
@@ -809,14 +846,64 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
|
||||
if (res.status != reasampler::CaptureStatus::Ok)
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str());
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
|
||||
// captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE
|
||||
// bank, and recorded the created file in the owned-file manifest (WITHOUT persisting).
|
||||
// Persist the updated book AND manifest into the active project's ext state (the
|
||||
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
|
||||
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
|
||||
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
|
||||
// S9: a capture add is a bank-content change -> bump before the persist so an assigned
|
||||
// live instance refreshes hands-free (the S8 capture+assign path builds on this).
|
||||
g_session.bumpBankGeneration();
|
||||
g_session.saveToActiveProject();
|
||||
|
||||
// Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne
|
||||
// resolved res.sample.id to the fresh id on a new add or the existing entry's id on a
|
||||
// hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above).
|
||||
return res.sample.id;
|
||||
}
|
||||
|
||||
// S8 arrange ingest: capture the selected item / time-selection into the active bank
|
||||
// (reusing the Item-scope capture path verbatim) and, on success, write an assignment
|
||||
// request so the active sampler instance plays the new sample on its next reload. The
|
||||
// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER
|
||||
// inserts a timeline item (load-bearing principle); the only addition here is the
|
||||
// bank-index-id -> assignment-request write after the sample lands. If the capture
|
||||
// failed / no-op'd (empty id), no assignment is written (nothing to assign).
|
||||
//
|
||||
// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the
|
||||
// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are
|
||||
// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically.
|
||||
// An undo that removes the captured sample also clears the assign_request that named it,
|
||||
// preventing a stale request from pointing at a removed sample. The block uses the house
|
||||
// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero
|
||||
// flag) matching the bank-op family in actions.cpp.
|
||||
static void RunCaptureItemAssign()
|
||||
{
|
||||
// Reuse the Item-scope def from the capture table (index 0) — same range logic, same
|
||||
// FX-scope neutralize, same bank/persist landing as the plain "capture item" action.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
|
||||
const std::string sampleId =
|
||||
RunCapture(reasampler::captureActionTable()[0]);
|
||||
if (sampleId.empty())
|
||||
{
|
||||
// Capture failed or no-op'd — RunCapture already reported. Discard the empty point.
|
||||
Undo_EndBlock2(nullptr, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Assign inside the same block so undo clears both keys together.
|
||||
reasampler::ingestAssignActiveInstance(g_session.book().activeBankId(), sampleId);
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance",
|
||||
UNDO_STATE_MISCCFG);
|
||||
|
||||
reasampler::bankPanelRefresh();
|
||||
ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active "
|
||||
"instance.\n");
|
||||
}
|
||||
|
||||
// --- M11: batch capture (per selected item / per razor area) ----------------
|
||||
@@ -961,8 +1048,12 @@ static void RunBatchCaptureItems()
|
||||
} // selGuard restores the original selection here, on every path
|
||||
|
||||
// Persist ONCE for the whole batch (one ext-state write) — only if something landed.
|
||||
if (anyAdded)
|
||||
// S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample,
|
||||
// so a single increment past the last-seen value is enough to trigger one instance reload.
|
||||
if (anyAdded) {
|
||||
g_session.bumpBankGeneration();
|
||||
g_session.saveToActiveProject();
|
||||
}
|
||||
|
||||
ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str());
|
||||
}
|
||||
@@ -1078,8 +1169,11 @@ static void RunBatchCaptureRazor()
|
||||
}
|
||||
} // selGuard restores the original track selection here, on every path
|
||||
|
||||
if (anyAdded)
|
||||
// S9: one coalesced bump for the whole razor batch (see the item-batch note above).
|
||||
if (anyAdded) {
|
||||
g_session.bumpBankGeneration();
|
||||
g_session.saveToActiveProject();
|
||||
}
|
||||
|
||||
ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str());
|
||||
}
|
||||
@@ -1263,7 +1357,12 @@ static void RunRecaptureFromSource()
|
||||
// Record the regenerated file in the owned manifest (a new file the tool wrote);
|
||||
// the superseded old file becomes an orphan reclaimed by Phase R prune.
|
||||
g_session.owned().add(updated.relativePath);
|
||||
const bool persisted = g_session.saveToActiveProject(); // book + manifest + MarkProjectDirty
|
||||
// S9: re-capture-in-place regenerates the SAME id's audio — the exact case the
|
||||
// hands-free refresh exists for (an instance referencing this id keeps playing the
|
||||
// OLD audio until it reloads). Bump inside the undo block so undo rolls back the
|
||||
// generation with the rest of the blob.
|
||||
g_session.bumpBankGeneration();
|
||||
const bool persisted = g_session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty
|
||||
Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
|
||||
persisted ? UNDO_STATE_MISCCFG : 0);
|
||||
}
|
||||
@@ -1422,6 +1521,7 @@ static bool OnHookCommand(int command, int /*flag*/)
|
||||
return true;
|
||||
}
|
||||
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
|
||||
if (command == g_cmdCaptureItemAssign) { RunCaptureItemAssign(); return true; }
|
||||
if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; }
|
||||
if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; }
|
||||
if (command == g_cmdCaptureBatchItems) { RunBatchCaptureItems(); return true; }
|
||||
@@ -1440,6 +1540,8 @@ static bool OnHookCommand(int command, int /*flag*/)
|
||||
if (reasampler::designViewHandleCommand(command)) return true;
|
||||
// Multi-bank action family (B3). Same contract: claims only its own ids.
|
||||
if (reasampler::bankHandleCommand(command)) return true;
|
||||
// S8 ingest action family (Media-Explorer import). Same contract.
|
||||
if (reasampler::ingestHandleCommand(command)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1455,6 +1557,7 @@ static int OnToggleAction(int command)
|
||||
// gaccel storage must outlive registration — REAPER holds the pointer.
|
||||
// (The capture family's accels live in g_captureAccels, sized to the table.)
|
||||
static gaccel_register_t g_accelToggleBankPanel{};
|
||||
static gaccel_register_t g_accelCaptureItemAssign{};
|
||||
static gaccel_register_t g_accelInsertSelected{};
|
||||
static gaccel_register_t g_accelInsertSelectedConform{};
|
||||
static gaccel_register_t g_accelCaptureBatchItems{};
|
||||
@@ -1468,6 +1571,7 @@ static gaccel_register_t g_accelShowVersion{};
|
||||
// (channelActionName) so it cannot be a string literal; REAPER holds the gaccel's `desc`
|
||||
// pointer, so each label lives here for the module lifetime. Composed once at registration.
|
||||
static std::string g_descToggleBankPanel;
|
||||
static std::string g_descCaptureItemAssign;
|
||||
static std::string g_descInsertSelected;
|
||||
static std::string g_descInsertSelectedConform;
|
||||
static std::string g_descCaptureBatchItems;
|
||||
@@ -1480,6 +1584,7 @@ static std::string g_descShowVersion;
|
||||
// Composed command-id strings (channel-qualified), interned so register and the mirroring
|
||||
// '-command_id' unregister pass the SAME pointer. Set during registration; read on unload.
|
||||
static const char* g_idToggleBankPanel = nullptr;
|
||||
static const char* g_idCaptureItemAssign = nullptr;
|
||||
static const char* g_idInsertSelected = nullptr;
|
||||
static const char* g_idInsertSelectedConform = nullptr;
|
||||
static const char* g_idCaptureBatchItems = nullptr;
|
||||
@@ -1519,6 +1624,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
reasampler::designViewUnregisterActions(g_rec);
|
||||
// Tear down the multi-bank action family (B3) — same mirror-unregister.
|
||||
reasampler::bankUnregisterActions(g_rec);
|
||||
// Tear down the S8 ingest action family — same mirror-unregister.
|
||||
reasampler::ingestUnregisterActions(g_rec);
|
||||
// Each '-command_id' re-presents the SAME interned, channel-qualified pointer
|
||||
// used at register (g_id*), so the mirror-unregister matches exactly.
|
||||
g_rec->Register("-gaccel", (void*)&g_accelShowVersion);
|
||||
@@ -1537,6 +1644,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
g_rec->Register("-command_id", (void*)g_idInsertSelectedConform);
|
||||
g_rec->Register("-gaccel", (void*)&g_accelInsertSelected);
|
||||
g_rec->Register("-command_id", (void*)g_idInsertSelected);
|
||||
g_rec->Register("-gaccel", (void*)&g_accelCaptureItemAssign);
|
||||
g_rec->Register("-command_id", (void*)g_idCaptureItemAssign);
|
||||
g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel);
|
||||
g_rec->Register("-command_id", (void*)g_idToggleBankPanel);
|
||||
// Mirror-unregister the capture family: gaccel + command_id per row, with
|
||||
@@ -1627,6 +1736,24 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
rec->Register("toggleaction", (void*)&OnToggleAction);
|
||||
}
|
||||
|
||||
// Register the S8 "capture selected item / time-selection into bank + assign" action
|
||||
// (command_id -> gaccel -> hookcommand). Reuses the Item-scope offline capture path and
|
||||
// writes an assignment request so the active instance plays the new sample. Channel-
|
||||
// qualified FOREVER-STABLE id (suffix CAPTURE_ITEM_ASSIGN). MIDI-bindable like every
|
||||
// capture action. Registered in the capture family (main.cpp) because it leans on the
|
||||
// capture render machinery here; the other two ingest surfaces live in the ingest family
|
||||
// (Media-Explorer import) and the panel drop callback.
|
||||
g_idCaptureItemAssign = internCmdId("CAPTURE_ITEM_ASSIGN");
|
||||
g_cmdCaptureItemAssign = rec->Register("command_id", (void*)g_idCaptureItemAssign);
|
||||
if (g_cmdCaptureItemAssign)
|
||||
{
|
||||
g_descCaptureItemAssign = reasampler::channelActionName(
|
||||
"capture selected item into bank + assign to active instance");
|
||||
g_accelCaptureItemAssign.accel.cmd = g_cmdCaptureItemAssign;
|
||||
g_accelCaptureItemAssign.desc = g_descCaptureItemAssign.c_str();
|
||||
rec->Register("gaccel", (void*)&g_accelCaptureItemAssign);
|
||||
}
|
||||
|
||||
// Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two
|
||||
// variants: native-length (default, no stretch) and the EXPLICIT conform-to-
|
||||
// tempo opt-in. Both read the bank panel selection and place at the edit cursor.
|
||||
@@ -1745,6 +1872,12 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
// by the same hookcommand via bankHandleCommand. Registered before the hook.
|
||||
reasampler::bankRegisterActions(rec, &g_session);
|
||||
|
||||
// Register the S8 ingest action family: the Media-Explorer import-into-bank+assign
|
||||
// action. Shares g_session with the other families; routed by the same hookcommand via
|
||||
// ingestHandleCommand. (The arrange capture+assign action is registered in the capture
|
||||
// family above; the drop path is a bank_panel callback, not a bindable action.)
|
||||
reasampler::ingestRegisterActions(rec, &g_session);
|
||||
|
||||
// One hookcommand routes every ReaSampler action (spike + toggle + Design View).
|
||||
// Registered once, after all command ids are minted.
|
||||
rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||
|
||||
+42
-4
@@ -96,6 +96,7 @@
|
||||
#include "app_version.h"
|
||||
#include "capture_paths.h"
|
||||
#include "prune_reconcile.h"
|
||||
#include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
@@ -128,11 +129,11 @@ void* readActiveProject(std::string& rppPathOut) {
|
||||
// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
|
||||
// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the
|
||||
// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp
|
||||
// for the full rationale). normalizeSlashes lives in capture_paths (pure).
|
||||
// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths
|
||||
// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both
|
||||
// artifacts share one implementation rather than duplicating the parent-of-.rpp step.
|
||||
std::string projectDirOf(const std::string& rppPath) {
|
||||
if (rppPath.empty()) return {};
|
||||
std::string dir = fs::path(rppPath).parent_path().string();
|
||||
return normalizeSlashes(dir);
|
||||
return projectDirOfRpp(rppPath);
|
||||
}
|
||||
|
||||
// GetProjExtState needs a caller-supplied buffer; the index JSON can be large
|
||||
@@ -252,6 +253,33 @@ bool ReaSamplerSession::saveToActiveProject() {
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtVersionKey, stampVersion().c_str());
|
||||
|
||||
// S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME
|
||||
// seam so the counter and MarkProjectDirty stay paired. The value is whatever
|
||||
// bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so
|
||||
// every content mutation's own save carries the fresh generation the instrument reads. The
|
||||
// format is the SHARED pure encoder (vst::formatBankGeneration) so writer and reader agree
|
||||
// byte-for-byte — a decimal integer. Additive: does not disturb the blobs above.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBankGenKey,
|
||||
vst::formatBankGeneration(bankGeneration_).c_str());
|
||||
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj) return false; // no active project — nothing to signal
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
|
||||
// One-shot write of the ingest assignment request under its own key (S8). Independent
|
||||
// of the book/view/tail blobs — this is a transient signal to the instrument, not
|
||||
// session state that must ride every save. Uses the channel-derived namespace
|
||||
// (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta
|
||||
// instrument reads only a beta extension's assignment requests.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtAssignKey, wire.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
return true;
|
||||
}
|
||||
@@ -563,6 +591,16 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
|
||||
kProjExtVersionKey)
|
||||
: std::string{});
|
||||
|
||||
// S9: recover the bank-generation counter on EVERY load path (peer-symmetry with
|
||||
// writingVersion_/tail_/view_ above), so it continues monotonic from the stored value
|
||||
// rather than resetting to 0 on reopen — a next bump then reads > the stored value. A
|
||||
// project switch reads THAT project's counter, not the previous one's; an absent/malformed
|
||||
// stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0.
|
||||
bankGeneration_ = vst::parseBankGeneration(
|
||||
proj ? getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBankGenKey)
|
||||
: std::string{});
|
||||
|
||||
if (!proj) {
|
||||
book_ = BankBook{};
|
||||
return;
|
||||
|
||||
+80
-58
@@ -17,11 +17,13 @@
|
||||
// calls live in persist.cpp. It depends on bank_model (pure) for JSON round-trip
|
||||
// and capture_paths (pure) for the path arithmetic it drives.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "app_version.h"
|
||||
#include "bank_book.h"
|
||||
#include "bank_model.h"
|
||||
#include "ext_keys.h"
|
||||
#include "owned_manifest.h"
|
||||
#include "prune_reconcile.h"
|
||||
#include "tail_control.h"
|
||||
@@ -29,71 +31,50 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The ext-state namespace every ReaSampler key is stored under. CHANNEL-DERIVED (Phase V,
|
||||
// V4): the pure app_version module owns the one channel-qualified string — "reasampler" on
|
||||
// stable (byte-identical to the pre-V4 build) or "reasampler_beta" on the isolated beta
|
||||
// build. FOREVER-STABLE per channel once shipped: changing either orphans every already-
|
||||
// saved project's state. Beta reads/writes ONLY its own namespace — a project saved by
|
||||
// stable shows empty/default state in beta and vice versa; that isolation is the accepted
|
||||
// V4 safety property (no cross-namespace read, migration, or fallback), not a bug.
|
||||
// Returns const char* (not a constexpr literal) because the string is channel-derived at
|
||||
// build time; the accessor is the single call point for all persist reads/writes below.
|
||||
// The ext-state namespace + the WIRE-SHARED key names are the contract between this
|
||||
// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h
|
||||
// (pure, REAPER-free) and are included above — not duplicated here. The namespace is
|
||||
// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace()
|
||||
// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte-
|
||||
// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both
|
||||
// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace
|
||||
// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project
|
||||
// saved by stable shows empty/default state in beta and vice versa; that isolation is the
|
||||
// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug.
|
||||
// The per-key semantics persist relies on (spellings owned by ext_keys.h):
|
||||
// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks).
|
||||
// AUTHORITATIVE going forward; the VST reads this key to see the live bank.
|
||||
// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared
|
||||
// on save); READ once on load to migrate a legacy project into the pool.
|
||||
// * kProjExtViewKey : the Design-View ViewModeModel JSON.
|
||||
// * kProjExtTailKey : the docked panel's TailSetting JSON.
|
||||
// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll()
|
||||
// tells a Save-As from a recycled-pointer project switch by it).
|
||||
// All are FOREVER-STABLE once shipped: changing any strands every already-saved
|
||||
// project's stored state under that key.
|
||||
//
|
||||
// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this
|
||||
// is the const char* the SetProjExtState/GetProjExtState calls in persist.cpp pass. Kept
|
||||
// as an accessor (not a literal) because the string is channel-derived at build time.
|
||||
inline const char* projExtNamespace() { return extStateNamespace().c_str(); }
|
||||
|
||||
// The RETIRED legacy ext-state key: pre-multi-bank projects stored the whole
|
||||
// serialized BankIndex here (single bank). Phase B2 no longer WRITES it — on save
|
||||
// the key is cleared (SetProjExtState with "" deletes it) and the book is written
|
||||
// under kProjExtBanksKey instead. It is still READ once, on load of a legacy
|
||||
// project, to migrate its single index into the pool (BankBook's parse-time
|
||||
// promotion). FOREVER-STABLE as a read key for that migration path.
|
||||
inline constexpr const char* kProjExtIndexKey = "bank_index";
|
||||
|
||||
// The multi-bank ext-state key (Phase B): one key holds the whole serialized
|
||||
// BankBook — the pool folded in as bank-zero plus every named bank, each with its
|
||||
// own BankIndex, ordinals, and the active-bank id. AUTHORITATIVE going forward;
|
||||
// supersedes kProjExtIndexKey. FOREVER-STABLE once shipped: changing it orphans
|
||||
// every already-saved project's banks.
|
||||
inline constexpr const char* kProjExtBanksKey = "banks";
|
||||
|
||||
// The ext-state key the Design-View ViewModeModel JSON is stored under (one key
|
||||
// holds the whole serialized model: modes + membership + show-both + snapshots +
|
||||
// active mode). Distinct from kProjExtIndexKey — one namespace, two keys.
|
||||
// FOREVER-STABLE: changing it orphans every already-saved project's view state.
|
||||
inline constexpr const char* kProjExtViewKey = "view_state";
|
||||
|
||||
// The ext-state key the docked panel's TailSetting JSON (mode + manualMs) is stored
|
||||
// under, so the tail choice travels inside the .rpp and loads per project. Distinct
|
||||
// from the index/view keys — one namespace, three keys. FOREVER-STABLE: changing it
|
||||
// orphans every already-saved project's tail setting (which then falls back to the
|
||||
// default — graceful, but the user's saved choice would be lost).
|
||||
inline constexpr const char* kProjExtTailKey = "tail_setting";
|
||||
|
||||
// The ext-state key holding the owned-file manifest JSON (the set of project-relative
|
||||
// files the capture path itself created — Phase B B-cap seam, consumed by Phase R
|
||||
// prune to distinguish the bank system's own orphans from hand-dropped files). A
|
||||
// SIBLING key alongside banks/view_state/tail_setting — NOT folded into the `banks`
|
||||
// blob, so it stays decoupled from bank membership (removing an index entry is not a
|
||||
// manifest removal). One namespace, four content keys. FOREVER-STABLE: changing it
|
||||
// strands every already-saved project's ownership record, so Phase R prune could no
|
||||
// longer tell the tool's own files apart (it would fall back to an empty manifest —
|
||||
// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument
|
||||
// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h:
|
||||
//
|
||||
// owned_files — the owned-file manifest JSON (project-relative files the capture path
|
||||
// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's
|
||||
// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT
|
||||
// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it
|
||||
// strands every saved project's ownership record (prune falls back to an empty manifest —
|
||||
// graceful, but the attribution safety net is lost until the next capture rebuilds it).
|
||||
inline constexpr const char* kProjExtOwnedKey = "owned_files";
|
||||
|
||||
// The ext-state key holding the ReaSampler version that last WROTE this project
|
||||
// (Phase V, V1). Written on every save alongside the banks/view/tail keys, so every
|
||||
// saved .rpp records which build produced its state — the seam a future within-channel
|
||||
// forward migration keys off ("this was written by 0.9.01, I am 0.9.05"). An absent
|
||||
// key is the explicit pre-versioning case (a project saved before this shipped), read
|
||||
// silently, never an error. FOREVER-STABLE key string once shipped.
|
||||
// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on
|
||||
// every save, so every saved .rpp records which build produced its state — the seam a
|
||||
// future within-channel forward migration keys off. An absent key is the explicit
|
||||
// pre-versioning case, read silently, never an error. FOREVER-STABLE key string.
|
||||
inline constexpr const char* kProjExtVersionKey = "version";
|
||||
|
||||
// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED
|
||||
// project identity (REAPER exposes no stable per-project GUID). poll() uses it to
|
||||
// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch
|
||||
// onto a recycled ReaProject* pointer (different GUID). FOREVER-STABLE: changing
|
||||
// it strands the identity of every already-saved project. See persist.cpp.
|
||||
inline constexpr const char* kProjExtGuidKey = "project_guid";
|
||||
|
||||
// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence
|
||||
// against the active REAPER project. One instance lives for the extension's
|
||||
// lifetime (main.cpp). It tracks
|
||||
@@ -169,6 +150,24 @@ public:
|
||||
// reason about the origin build without re-reading ext state.
|
||||
const WritingVersion& writingVersion() const { return writingVersion_; }
|
||||
|
||||
// The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic
|
||||
// per project: recovered on load (so it continues from the stored value rather than
|
||||
// resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on
|
||||
// every saveToActiveProject(). Exposed const for the writer sites to read/log.
|
||||
std::int64_t bankGeneration() const { return bankGeneration_; }
|
||||
|
||||
// Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes
|
||||
// what a live instance would PLAY (capture add, re-capture-in-place, sample remove,
|
||||
// move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create /
|
||||
// rename / activate / reorder a bank), which change no existing (bankId, sampleId) ->
|
||||
// content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call
|
||||
// the same mutation already makes (the counter rides the persist blob, so there is no
|
||||
// separate write). In-memory only here — cheap and REAPER-free; the persist is the write.
|
||||
// Over-bumping is safe (a reload that finds unchanged content atomically re-installs the
|
||||
// same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err
|
||||
// toward bumping. Idempotent per logical op — call once per mutation, before the persist.
|
||||
void bumpBankGeneration() { ++bankGeneration_; }
|
||||
|
||||
// Serialize the current book (under the `banks` key), view model, and tail setting
|
||||
// to the active project's ext state (namespace "reasampler"), and clear the retired
|
||||
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
|
||||
@@ -227,6 +226,21 @@ public:
|
||||
// shown the confirm; this method does NOT prompt.
|
||||
PruneDeletionResult pruneReclaim(const std::vector<std::string>& confirmed) const;
|
||||
|
||||
// Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the
|
||||
// `assign_request` key, namespace "reasampler"): the extension telling the active
|
||||
// sampler instance "play THIS sample now." `wire` is the pure assignment_request
|
||||
// encoding (assignment_request.h); this method only routes the already-encoded value
|
||||
// to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and
|
||||
// the encode live in the ingest shell (the pure module) so persist stays a thin bridge.
|
||||
//
|
||||
// A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an
|
||||
// assignment request is a transient "just assigned" signal the instrument reads and
|
||||
// acts on, so it rides its own key and is written only at ingest time, never on every
|
||||
// book save. Returns true iff written (an active, SAVED project existed); false on a
|
||||
// no-active / unsaved project (nothing to write into — the assign is dropped, matching
|
||||
// the book/manifest quiet-persist idiom the ingest add-path already tolerates).
|
||||
bool writeAssignmentRequest(const std::string& wire);
|
||||
|
||||
// Poll the active project. Detects a project load (active project changed)
|
||||
// and a Save-As (active project's .rpp path changed) and reacts accordingly.
|
||||
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
|
||||
@@ -287,6 +301,14 @@ private:
|
||||
// the previous project's stamp. Read-only to consumers via writingVersion().
|
||||
WritingVersion writingVersion_;
|
||||
|
||||
// The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path
|
||||
// from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it
|
||||
// continues monotonic from the persisted value across reopen and resets cleanly on a
|
||||
// project switch (a different project's counter, not the previous project's). bumped by
|
||||
// bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject().
|
||||
// Default 0 for an unsaved / never-loaded / pre-S9 session.
|
||||
std::int64_t bankGeneration_ = 0;
|
||||
|
||||
// The project identity last observed by poll(), used to detect load/Save-As.
|
||||
// The GUID is the PRIMARY signal (a different stored GUID = a different project
|
||||
// of record = Load, immune to pointer recycling). The pointer disambiguates the
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// bank_sync.cpp — see bank_sync.h. Pure; standard library only.
|
||||
|
||||
#include "bank_sync.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
std::int64_t parseBankGeneration(const std::string& raw) {
|
||||
if (raw.empty()) return kBankGenerationAbsent;
|
||||
|
||||
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises.
|
||||
// A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max
|
||||
// all reject to the absent default (0). Manual accumulation with an overflow guard so a
|
||||
// pathologically long digit run can never wrap into a bogus small value.
|
||||
std::int64_t value = 0;
|
||||
constexpr std::int64_t kMax = std::numeric_limits<std::int64_t>::max();
|
||||
for (const char c : raw) {
|
||||
if (c < '0' || c > '9') return kBankGenerationAbsent; // any non-digit -> reject whole
|
||||
const int digit = c - '0';
|
||||
// Guard value*10 + digit against overflow before performing it.
|
||||
if (value > (kMax - digit) / 10) return kBankGenerationAbsent; // would overflow -> reject
|
||||
value = value * 10 + digit;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string formatBankGeneration(std::int64_t generation) {
|
||||
// Non-negative decimal; a negative (should never be produced by the writer) formats as
|
||||
// its std::to_string form and would parse back to 0, so the writer's monotonic counter
|
||||
// stays in the >= 0 domain by construction.
|
||||
return std::to_string(generation);
|
||||
}
|
||||
|
||||
bool bankGenerationChanged(std::int64_t seen, std::int64_t current) {
|
||||
return current != seen;
|
||||
}
|
||||
|
||||
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
||||
std::int64_t lastConsumed, bool resolves,
|
||||
bool isFocusedTarget) {
|
||||
AssignConsumeDecision d;
|
||||
d.consumedGeneration = lastConsumed; // default: nothing changes
|
||||
|
||||
// Rule 1: no request, or not newer than what we already consumed -> nothing new.
|
||||
if (!request) return d;
|
||||
if (request->generation <= lastConsumed) return d;
|
||||
|
||||
// Rule 2: a new request, but this instance is not the target -> do not act, do NOT
|
||||
// advance the marker (stay eligible if focus later lands here). No thundering herd.
|
||||
if (!isFocusedTarget) return d;
|
||||
|
||||
// The request is new AND we are the target: it will be consumed-as-seen either way, so
|
||||
// advance the marker to its generation so it is never re-evaluated.
|
||||
d.consumedGeneration = request->generation;
|
||||
|
||||
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker
|
||||
// advanced above, but no selection change.
|
||||
if (!resolves) return d;
|
||||
|
||||
// Rule 4: new, target, resolvable -> apply the selection.
|
||||
d.apply = true;
|
||||
d.bankId = request->bankId;
|
||||
d.sampleId = request->sampleId;
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,105 @@
|
||||
#pragma once
|
||||
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the
|
||||
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of
|
||||
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a
|
||||
// host-facing shell.
|
||||
//
|
||||
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state
|
||||
// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and
|
||||
// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string
|
||||
// read crosses the bridge in the shell; every DECISION after — parse the generation
|
||||
// stamp, decide whether it differs from what we last saw, decide whether a decoded
|
||||
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
|
||||
//
|
||||
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
|
||||
// effects (reloadFromBank, setSelectedSampleId); this module owns only the yes/no maths so
|
||||
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
|
||||
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "assignment_request.h" // AssignmentRequest (the decoded request this consumes)
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before
|
||||
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value
|
||||
// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the
|
||||
// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation).
|
||||
inline constexpr std::int64_t kBankGenerationAbsent = 0;
|
||||
|
||||
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a
|
||||
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative
|
||||
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp
|
||||
// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never
|
||||
// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing
|
||||
// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored
|
||||
// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT
|
||||
// poll once the value is clean).
|
||||
std::int64_t parseBankGeneration(const std::string& raw);
|
||||
|
||||
// Format a bank-generation counter for the ext-state stamp. The inverse of
|
||||
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so
|
||||
// the stamp is byte-stable across writes of the same value.
|
||||
std::string formatBankGeneration(std::int64_t generation);
|
||||
|
||||
// Has the bank generation changed since the reader last saw `seen`? True when `current`
|
||||
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just
|
||||
// an increase): the writer is monotonic, but a project switch or reload can legitimately
|
||||
// lower the value, and the reader should re-read the bank in that case too. `seen` starts at
|
||||
// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 /
|
||||
// first-bump refresh the spec requires).
|
||||
bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
|
||||
|
||||
// The verdict of the S8 assignment-request consume decision (below). A pure value the
|
||||
// processor shell acts on: apply the selection (or not) and advance the consumed marker
|
||||
// (or not). Distinct booleans because the two are NOT the same event — a request may be
|
||||
// consumed-as-seen (marker advances) without being applied (it named an unresolvable
|
||||
// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it
|
||||
// every poll.
|
||||
struct AssignConsumeDecision {
|
||||
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
|
||||
std::string bankId; // the request's bank (valid only when apply)
|
||||
std::string sampleId; // the request's sample (valid only when apply)
|
||||
std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new)
|
||||
};
|
||||
|
||||
// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader).
|
||||
//
|
||||
// `request` — the decoded assignment request (nullopt when the assign_request key
|
||||
// is absent / malformed — nothing pending).
|
||||
// `lastConsumed` — the generation this instance last consumed (persisted in component
|
||||
// state so a re-open does not re-apply a request the user already got,
|
||||
// then manually changed away from). Defaults to 0 for a fresh instance.
|
||||
// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank
|
||||
// sample RIGHT NOW (the shell computed this against the live bank blob).
|
||||
// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's
|
||||
// thundering-herd policy (e.g. only the focused-editor instance applies).
|
||||
// The shell passes true when this instance should act; false suppresses
|
||||
// consumption entirely so a non-target instance neither applies nor
|
||||
// advances its marker (it stays eligible if it later becomes the target).
|
||||
//
|
||||
// RULES (all pure, order matters):
|
||||
// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not
|
||||
// apply, marker unchanged. (Covers the re-open case: the persisted marker == the
|
||||
// request's generation, so it is not re-applied.)
|
||||
// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not
|
||||
// apply and do NOT advance the marker — a non-target instance must stay able to consume
|
||||
// the request if focus later lands on it. (No thundering herd: only the target acts.)
|
||||
// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT
|
||||
// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply,
|
||||
// but DO advance the marker to the request's generation so a stale/unresolvable request
|
||||
// is consumed-as-seen and never re-evaluated (no error state, no selection change).
|
||||
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
|
||||
// advance the marker to the request's generation.
|
||||
//
|
||||
// The shell then: if apply, setSelectedSampleId + reloadFromBank; always persist
|
||||
// consumedGeneration into component state when it advanced.
|
||||
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
||||
std::int64_t lastConsumed, bool resolves,
|
||||
bool isFocusedTarget);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,16 @@
|
||||
// bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types.
|
||||
|
||||
#include "bridge_marshal.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||
const std::string& buffer) {
|
||||
// REAPER returns the length of the stored value; 0 means the key is absent. Guard
|
||||
// both the return AND the buffer: a caller that reused a dirty buffer must not
|
||||
// surface stale bytes as a value when the API reported nothing.
|
||||
if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,37 @@
|
||||
// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read.
|
||||
// NO VST3, NO REAPER types at the boundary.
|
||||
//
|
||||
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
|
||||
// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around
|
||||
// GetProjExtState — interpreting its int return against the buffer it filled — is pure
|
||||
// and unit-tested here. Mirror of capture_paths / wav_trim splitting the arithmetic out
|
||||
// of a REAPER-facing shell.
|
||||
//
|
||||
// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a
|
||||
// stand-in until the instrument could parse the bank properly. S4 retired it: the
|
||||
// instrument now parses the "reasampler" bank blob through the SHARED bank_book /
|
||||
// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module
|
||||
// is back to its one honest job — the API-return decode.
|
||||
//
|
||||
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
|
||||
// -- returns the length written (0 when the key is absent).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Interpret a GetProjExtState result: the int return value (bytes the API reports for
|
||||
// the key) and the buffer it filled. Returns the value only when the API reported a
|
||||
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer
|
||||
// untouched for an absent key, and we must not treat stale buffer contents as a hit.
|
||||
//
|
||||
// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it
|
||||
// wrote (already truncated to the C string by the caller).
|
||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||
const std::string& buffer);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,158 @@
|
||||
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
|
||||
// capture_browser. No host types; only the shared Rect + BrowserLayout.
|
||||
|
||||
#include "browser_scroll.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
// The minimum thumb height so a very long bank still yields a grabbable thumb.
|
||||
constexpr int kMinThumbHeight = 20;
|
||||
|
||||
char asciiLower(char c) {
|
||||
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int scrollContentHeight(const BrowserLayout& layout, int cardCount) {
|
||||
if (cardCount <= 0) return 0;
|
||||
const int columns = (std::max)(1, layout.columns);
|
||||
const int rows = (cardCount + columns - 1) / columns; // ceil
|
||||
return rows * kBrowserCardHeight;
|
||||
}
|
||||
|
||||
int scrollMaxOffset(const BrowserLayout& layout, int cardCount) {
|
||||
const int content = scrollContentHeight(layout, cardCount);
|
||||
const int gridH = (std::max)(0, layout.grid.height());
|
||||
return (std::max)(0, content - gridH);
|
||||
}
|
||||
|
||||
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset) {
|
||||
const int maxOff = scrollMaxOffset(layout, cardCount);
|
||||
if (proposedOffset < 0) return 0;
|
||||
if (proposedOffset > maxOff) return maxOff;
|
||||
return proposedOffset;
|
||||
}
|
||||
|
||||
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset) {
|
||||
VisibleRange vr;
|
||||
if (cardCount <= 0) return vr;
|
||||
const int columns = (std::max)(1, layout.columns);
|
||||
const int gridH = (std::max)(0, layout.grid.height());
|
||||
if (gridH <= 0 || kBrowserCardHeight <= 0) {
|
||||
vr.first = 0;
|
||||
vr.last = 0;
|
||||
return vr;
|
||||
}
|
||||
if (offset < 0) offset = 0;
|
||||
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row
|
||||
// partially scrolled off the top still draws (its lower part is visible).
|
||||
const int firstRow = offset / kBrowserCardHeight;
|
||||
// Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for
|
||||
// the exclusive end. A row straddling the bottom edge still draws.
|
||||
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
|
||||
int first = firstRow * columns;
|
||||
int last = lastRow * columns;
|
||||
if (first > cardCount) first = cardCount;
|
||||
if (last > cardCount) last = cardCount;
|
||||
if (last < first) last = first;
|
||||
vr.first = first;
|
||||
vr.last = last;
|
||||
return vr;
|
||||
}
|
||||
|
||||
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) {
|
||||
Rect r = cardCellRect(layout, index);
|
||||
if (r.right <= r.left && r.bottom <= r.top) return r; // empty (negative index) stays empty
|
||||
return Rect{r.left, r.top - offset, r.right, r.bottom - offset};
|
||||
}
|
||||
|
||||
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
|
||||
const int content = scrollContentHeight(layout, cardCount);
|
||||
const int gridH = (std::max)(0, layout.grid.height());
|
||||
if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar
|
||||
const int maxOff = content - gridH;
|
||||
if (offset < 0) offset = 0;
|
||||
if (offset > maxOff) offset = maxOff;
|
||||
|
||||
const int trackRight = layout.grid.right;
|
||||
const int trackLeft = trackRight - kScrollbarWidth;
|
||||
const int trackTop = layout.grid.top;
|
||||
|
||||
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
|
||||
// never taller than the track.
|
||||
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||
thumbH = (std::min)(thumbH, gridH);
|
||||
|
||||
// Thumb top proportional to the offset over the movable track span.
|
||||
const int trackSpan = gridH - thumbH; // >= 0
|
||||
int thumbTop = trackTop;
|
||||
if (maxOff > 0 && trackSpan > 0) {
|
||||
thumbTop = trackTop + static_cast<int>(
|
||||
static_cast<long long>(offset) * trackSpan / maxOff);
|
||||
}
|
||||
return Rect{trackLeft, thumbTop, trackRight, thumbTop + thumbH};
|
||||
}
|
||||
|
||||
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset,
|
||||
int dyPixels) {
|
||||
const int content = scrollContentHeight(layout, cardCount);
|
||||
const int gridH = (std::max)(0, layout.grid.height());
|
||||
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
|
||||
|
||||
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels.
|
||||
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||
thumbH = (std::min)(thumbH, gridH);
|
||||
const int trackSpan = gridH - thumbH;
|
||||
if (trackSpan <= 0) return clampScrollOffset(layout, cardCount, startOffset);
|
||||
|
||||
const int maxOff = content - gridH;
|
||||
// A 1px thumb move covers maxOff/trackSpan content px. Round to nearest for symmetry.
|
||||
const long long deltaOffset =
|
||||
(static_cast<long long>(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) /
|
||||
trackSpan;
|
||||
const long long proposed = static_cast<long long>(startOffset) + deltaOffset;
|
||||
if (proposed < 0) return 0;
|
||||
if (proposed > maxOff) return maxOff;
|
||||
return static_cast<int>(proposed);
|
||||
}
|
||||
|
||||
Rect searchBoxRect(int w) {
|
||||
if (w <= 0) return Rect{};
|
||||
return Rect{0, 0, w, kSearchBoxHeight};
|
||||
}
|
||||
|
||||
bool nameMatchesQuery(const std::string& name, const std::string& query) {
|
||||
if (query.empty()) return true;
|
||||
if (query.size() > name.size()) return false;
|
||||
// Case-insensitive substring scan (ASCII fold). Small strings; a naive scan is fine.
|
||||
for (std::size_t i = 0; i + query.size() <= name.size(); ++i) {
|
||||
bool match = true;
|
||||
for (std::size_t j = 0; j < query.size(); ++j) {
|
||||
if (asciiLower(name[i + j]) != asciiLower(query[j])) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
||||
const std::string& query) {
|
||||
std::vector<int> out;
|
||||
out.reserve(names.size());
|
||||
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
|
||||
if (nameMatchesQuery(names[static_cast<std::size_t>(i)], query))
|
||||
out.push_back(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,107 @@
|
||||
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10
|
||||
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
|
||||
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box
|
||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the
|
||||
// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes
|
||||
// into these functions.
|
||||
//
|
||||
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell
|
||||
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach
|
||||
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry:
|
||||
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the
|
||||
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a
|
||||
// wheel tick or a thumb drag reaches every card; and
|
||||
// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards,
|
||||
// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first,
|
||||
// then this search narrows within it) + the search-box rect the shell draws the field in.
|
||||
//
|
||||
// It holds NO card data and draws nothing — it knows only the browser layout (from
|
||||
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It
|
||||
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell
|
||||
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so
|
||||
// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is
|
||||
// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter.
|
||||
inline constexpr int kScrollbarWidth = 10;
|
||||
|
||||
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a
|
||||
// thin band spanning the browser width). Exposed so the shell reserves the band and tests
|
||||
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
|
||||
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
|
||||
inline constexpr int kSearchBoxHeight = 22;
|
||||
|
||||
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s
|
||||
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height.
|
||||
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
|
||||
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
|
||||
|
||||
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0.
|
||||
// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp
|
||||
// ceiling for every offset the shell tracks.
|
||||
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
|
||||
|
||||
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel
|
||||
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
|
||||
// or above the first. Pure.
|
||||
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
|
||||
|
||||
// The half-open range of card INDICES [first, last) at least partially visible in the grid at
|
||||
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every
|
||||
// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row
|
||||
// yields an empty range (first==last==cardCount). Pure.
|
||||
struct VisibleRange {
|
||||
int first = 0; // first card index drawn (inclusive)
|
||||
int last = 0; // one past the last card index drawn (exclusive)
|
||||
};
|
||||
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
|
||||
|
||||
// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell
|
||||
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with
|
||||
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
|
||||
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
|
||||
|
||||
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to
|
||||
// the visible fraction (grid height / content height) and positioned proportional to the
|
||||
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell
|
||||
// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very
|
||||
// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against.
|
||||
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
|
||||
|
||||
// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time
|
||||
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new
|
||||
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to
|
||||
// content pixels (a 1px thumb move covers content/track px of content). A degenerate track /
|
||||
// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map.
|
||||
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
|
||||
|
||||
// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser
|
||||
// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width;
|
||||
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
|
||||
Rect searchBoxRect(int w);
|
||||
|
||||
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query
|
||||
// matches everything (the no-filter identity). Matching is ASCII case-folded (the display
|
||||
// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only
|
||||
// text). Pure — the single match predicate the shell's search narrow is built from.
|
||||
bool nameMatchesQuery(const std::string& name, const std::string& query);
|
||||
|
||||
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving
|
||||
// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank
|
||||
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this
|
||||
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list
|
||||
// and applies the bank filter FIRST, then feeds the surviving display names here (search
|
||||
// narrows within the bank). Pure.
|
||||
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
||||
const std::string& query);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,96 @@
|
||||
// capture_browser.cpp — see capture_browser.h. Pure math; no host types.
|
||||
|
||||
#include "capture_browser.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
// The left edge of tab i in a strip of the given x-origin and width divided into `count`
|
||||
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same
|
||||
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly.
|
||||
int tabEdge(int x, int width, int i, int count) {
|
||||
return x + (i * width) / count;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BrowserLayout layoutBrowser(int w, int h) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
BrowserLayout out;
|
||||
const int tabH = std::min(kBrowserTabHeight, ch);
|
||||
out.tabStrip = Rect{0, 0, cw, tabH};
|
||||
out.grid = Rect{0, tabH, cw, ch};
|
||||
|
||||
const int gridW = std::max(0, out.grid.width());
|
||||
out.columns = std::max(1, gridW / kBrowserCardWidth);
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect cardCellRect(const BrowserLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int cols = std::max(1, layout.columns);
|
||||
const int col = index % cols;
|
||||
const int row = index / cols;
|
||||
const int left = layout.grid.left + col * kBrowserCardWidth;
|
||||
const int top = layout.grid.top + row * kBrowserCardHeight;
|
||||
return Rect{left, top, left + kBrowserCardWidth, top + kBrowserCardHeight};
|
||||
}
|
||||
|
||||
Rect cardContentRect(const BrowserLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const Rect cell = cardCellRect(layout, index);
|
||||
return Rect{cell.left + kBrowserCardGutter, cell.top + kBrowserCardGutter,
|
||||
cell.right - kBrowserCardGutter, cell.bottom - kBrowserCardGutter};
|
||||
}
|
||||
|
||||
Rect cardThumbnailRect(const BrowserLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const Rect content = cardContentRect(layout, index);
|
||||
const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height()));
|
||||
return Rect{content.left, content.top, content.right, content.top + thumbH};
|
||||
}
|
||||
|
||||
Rect cardLabelRect(const BrowserLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const Rect content = cardContentRect(layout, index);
|
||||
const Rect thumb = cardThumbnailRect(layout, index);
|
||||
return Rect{content.left, thumb.bottom, content.right, content.bottom};
|
||||
}
|
||||
|
||||
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) {
|
||||
if (cardCount <= 0) return -1;
|
||||
if (!contains(layout.grid, x, y)) return -1;
|
||||
const int cols = std::max(1, layout.columns);
|
||||
const int col = (x - layout.grid.left) / kBrowserCardWidth;
|
||||
const int row = (y - layout.grid.top) / kBrowserCardHeight;
|
||||
if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone)
|
||||
const int index = row * cols + col;
|
||||
if (index < 0 || index >= cardCount) return -1;
|
||||
// Only a hit inside the card CONTENT counts — a click in the inter-card gutter misses.
|
||||
if (!contains(cardContentRect(layout, index), x, y)) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) {
|
||||
if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{};
|
||||
const Rect& strip = layout.tabStrip;
|
||||
const int left = tabEdge(strip.left, std::max(0, strip.width()), index, tabCount);
|
||||
const int right = tabEdge(strip.left, std::max(0, strip.width()), index + 1, tabCount);
|
||||
return Rect{left, strip.top, right, strip.bottom};
|
||||
}
|
||||
|
||||
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) {
|
||||
if (tabCount <= 0) return -1;
|
||||
if (!contains(layout.tabStrip, x, y)) return -1;
|
||||
for (int i = 0; i < tabCount; ++i) {
|
||||
if (contains(filterTabRect(layout, tabCount, i), x, y)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -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
|
||||
@@ -0,0 +1,164 @@
|
||||
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
|
||||
|
||||
#include "editor_geometry.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
// Spike editor layout constants. These are the editor's fixed metrics; the real
|
||||
// editor (S4/S5) will parameterize as its content demands.
|
||||
constexpr int kTitleBarHeight = 28;
|
||||
constexpr int kButtonMargin = 10;
|
||||
constexpr int kButtonWidth = 120;
|
||||
constexpr int kButtonHeight = 24;
|
||||
|
||||
} // namespace
|
||||
|
||||
bool contains(const Rect& r, int x, int y) {
|
||||
if (r.width() <= 0 || r.height() <= 0) return false;
|
||||
return x >= r.left && x < r.right && y >= r.top && y < r.bottom;
|
||||
}
|
||||
|
||||
EditorLayout layoutEditor(int w, int h) {
|
||||
// Clamp the surface to non-negative extents so a degenerate view can't produce
|
||||
// inverted rects.
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
EditorLayout out;
|
||||
|
||||
// Title bar spans the top, clamped so it never exceeds the client height.
|
||||
const int titleH = std::min(kTitleBarHeight, ch);
|
||||
out.titleBar = Rect{0, 0, cw, titleH};
|
||||
|
||||
// Canvas is everything below the title bar.
|
||||
out.canvas = Rect{0, titleH, cw, ch};
|
||||
|
||||
// Button sits at the top-left of the canvas, inset by a margin, and is clamped to
|
||||
// fit inside the canvas so it never overhangs on a small view.
|
||||
const int bx = out.canvas.left + kButtonMargin;
|
||||
const int by = out.canvas.top + kButtonMargin;
|
||||
const int bRight = std::min(bx + kButtonWidth, out.canvas.right);
|
||||
const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom);
|
||||
out.button = Rect{bx, by, std::max(bx, bRight), std::max(by, bBottom)};
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y) {
|
||||
if (contains(layout.button, x, y)) return HitTarget::kButton;
|
||||
return HitTarget::kNone;
|
||||
}
|
||||
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.canvas.top + index * kSampleRowHeight;
|
||||
return Rect{layout.canvas.left, top, layout.canvas.right, top + kSampleRowHeight};
|
||||
}
|
||||
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
// Must be within the canvas horizontally and at/below its top.
|
||||
if (x < layout.canvas.left || x >= layout.canvas.right) return -1;
|
||||
if (y < layout.canvas.top) return -1;
|
||||
// Clip at the canvas bottom: clicks in the canvas's dead-zone below the last
|
||||
// visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom.
|
||||
if (y >= layout.canvas.bottom) return -1;
|
||||
const int index = (y - layout.canvas.top) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
// Guard the bottom edge: a click below the last row's bottom is outside.
|
||||
const Rect r = sampleRowRect(layout, index);
|
||||
if (y >= r.bottom) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
// --- Keymap editor -----------------------------------------------------------
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h) {
|
||||
KeymapEditorLayout out;
|
||||
out.base = layoutEditor(w, h);
|
||||
const Rect& canvas = out.base.canvas;
|
||||
|
||||
// Split the canvas vertically: the left column is the bank-sample list, the right
|
||||
// column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so
|
||||
// the split point never crosses the canvas edges.
|
||||
const int canvasW = std::max(0, canvas.width());
|
||||
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
|
||||
const int splitX = std::max(canvas.left, canvas.right - splitW);
|
||||
|
||||
out.sampleList = Rect{canvas.left, canvas.top, splitX, canvas.bottom};
|
||||
out.zonePanel = Rect{splitX, canvas.top, canvas.right, canvas.bottom};
|
||||
|
||||
// "Add Zone" button spans the top of the zone panel, clamped to its height.
|
||||
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height()));
|
||||
out.addZoneButton =
|
||||
Rect{out.zonePanel.left, out.zonePanel.top, out.zonePanel.right,
|
||||
out.zonePanel.top + addH};
|
||||
|
||||
// Zone rows stack below the button.
|
||||
out.zoneRowArea = Rect{out.zonePanel.left, out.addZoneButton.bottom,
|
||||
out.zonePanel.right, out.zonePanel.bottom};
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.sampleList.top + index * kSampleRowHeight;
|
||||
return Rect{layout.sampleList.left, top, layout.sampleList.right,
|
||||
top + kSampleRowHeight};
|
||||
}
|
||||
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
const Rect& list = layout.sampleList;
|
||||
if (x < list.left || x >= list.right) return -1;
|
||||
if (y < list.top || y >= list.bottom) return -1;
|
||||
const int index = (y - list.top) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
const Rect r = keymapSampleRowRect(layout, index);
|
||||
if (y >= r.bottom) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.zoneRowArea.top + index * kZoneRowHeight;
|
||||
return Rect{layout.zoneRowArea.left, top, layout.zoneRowArea.right,
|
||||
top + kZoneRowHeight};
|
||||
}
|
||||
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) {
|
||||
if (zoneCount <= 0) return ZoneHit{};
|
||||
const Rect& area = layout.zoneRowArea;
|
||||
if (x < area.left || x >= area.right) return ZoneHit{};
|
||||
if (y < area.top || y >= area.bottom) return ZoneHit{};
|
||||
const int index = (y - area.top) / kZoneRowHeight;
|
||||
if (index < 0 || index >= zoneCount) return ZoneHit{};
|
||||
const Rect row = zoneRowRect(layout, index);
|
||||
if (y >= row.bottom) return ZoneHit{};
|
||||
|
||||
// Seven mini-buttons pinned to the right edge, right-to-left:
|
||||
// delete, root+, root-, high+, high-, low+, low-
|
||||
// Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select").
|
||||
// The fields laid out LEFT-TO-RIGHT in slot order 0..6.
|
||||
const ZoneField fields[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
ZoneField::kDelete,
|
||||
};
|
||||
const int slots = 7;
|
||||
const int ctrlBlockLeft = row.right - slots * kZoneCtrlWidth;
|
||||
if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select
|
||||
const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth;
|
||||
if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone};
|
||||
return ZoneHit{index, fields[slot]};
|
||||
}
|
||||
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
|
||||
return contains(layout.addZoneButton, x, y);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -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
|
||||
@@ -0,0 +1,86 @@
|
||||
// embed_strip.cpp — see embed_strip.h. Pure math; no host types.
|
||||
|
||||
#include "embed_strip.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
// Clamp a MIDI note to [0, kEmbedKeyCount-1].
|
||||
int clampNote(int n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given
|
||||
// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right.
|
||||
// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1),
|
||||
// which tiles adjacent zones without a seam.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth;
|
||||
return bandLeft + (keyEdge * bandWidth) / kEmbedKeyCount;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
EmbedLayout layoutEmbed(int w, int h) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
EmbedLayout out;
|
||||
|
||||
// The level band takes a fixed height at the bottom, but never so much that the keymap
|
||||
// above it falls below its minimum (or that the band exceeds the area). On a very short
|
||||
// area the band yields to the keymap entirely.
|
||||
int bandH = std::min(kEmbedLevelBandHeight, ch);
|
||||
if (ch - bandH < kEmbedKeymapMinHeight) {
|
||||
bandH = std::max(0, ch - kEmbedKeymapMinHeight);
|
||||
}
|
||||
const int keymapBottom = ch - bandH;
|
||||
|
||||
out.keymap = Rect{0, 0, cw, keymapBottom};
|
||||
out.levelBand = Rect{0, keymapBottom, cw, ch};
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) {
|
||||
const Rect& band = layout.keymap;
|
||||
const int bandWidth = std::max(0, band.width());
|
||||
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
|
||||
|
||||
const int leftX = keyEdgeToX(band.left, bandWidth, lo);
|
||||
const int rightX = keyEdgeToX(band.left, bandWidth, hi + 1);
|
||||
return Rect{leftX, band.top, std::max(leftX, rightX), band.bottom};
|
||||
}
|
||||
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y) {
|
||||
if (zoneCount <= 0 || zones == nullptr) return -1;
|
||||
if (!contains(layout.keymap, x, y)) return -1;
|
||||
// First covering zone in draw order wins (first-match, mirroring the core's resolve).
|
||||
for (int i = 0; i < zoneCount; ++i) {
|
||||
const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote);
|
||||
if (contains(r, x, y)) return i;
|
||||
}
|
||||
return -1; // on the band but on an uncovered key
|
||||
}
|
||||
|
||||
Rect levelFillRect(const EmbedLayout& layout, double level) {
|
||||
const Rect& band = layout.levelBand;
|
||||
if (band.width() <= 0 || band.height() <= 0) return Rect{};
|
||||
double l = level;
|
||||
if (l < 0.0) l = 0.0;
|
||||
if (l > 1.0) l = 1.0;
|
||||
const int fillW = static_cast<int>(l * band.width());
|
||||
if (fillW <= 0) return Rect{};
|
||||
return Rect{band.left, band.top, band.left + fillW, band.bottom};
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -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
|
||||
@@ -0,0 +1,126 @@
|
||||
// keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types.
|
||||
|
||||
#include "keyboard_strip.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
int clampNote(int n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > kStripKeyCount - 1) return kStripKeyCount - 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given
|
||||
// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer
|
||||
// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling
|
||||
// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX).
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
|
||||
return bandLeft + (keyEdge * bandWidth) / kStripKeyCount;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
StripLayout layoutStrip(int w, int h) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
StripLayout out;
|
||||
out.keys = Rect{0, 0, cw, ch};
|
||||
return out;
|
||||
}
|
||||
|
||||
int keyLeftX(const StripLayout& layout, int note) {
|
||||
const Rect& band = layout.keys;
|
||||
const int bandWidth = std::max(0, band.width());
|
||||
// note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to
|
||||
// get a key's right edge, and 128 maps to the band right.
|
||||
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
|
||||
return keyEdgeToX(band.left, bandWidth, edge);
|
||||
}
|
||||
|
||||
Rect keyRect(const StripLayout& layout, int note) {
|
||||
const int n = clampNote(note);
|
||||
const int leftX = keyLeftX(layout, n);
|
||||
const int rightX = keyLeftX(layout, n + 1);
|
||||
return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom};
|
||||
}
|
||||
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote) {
|
||||
return keyRect(layout, rootNote);
|
||||
}
|
||||
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y) {
|
||||
const Rect& band = layout.keys;
|
||||
if (!contains(band, x, y)) return -1;
|
||||
const int bandWidth = std::max(0, band.width());
|
||||
if (bandWidth <= 0) return -1;
|
||||
// Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide
|
||||
// the pixel offset back to a key; clamp defensively (a point on band.right-1 maps to 127).
|
||||
const int offset = x - band.left;
|
||||
int note = (offset * kStripKeyCount) / bandWidth;
|
||||
return clampNote(note);
|
||||
}
|
||||
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
|
||||
const int leftX = keyLeftX(layout, lo);
|
||||
const int rightX = keyLeftX(layout, hi + 1);
|
||||
return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom};
|
||||
}
|
||||
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) {
|
||||
const Rect bar = zoneBarRect(layout, lowNote, highNote);
|
||||
if (!contains(bar, x, y)) return ZoneGrab::kNone;
|
||||
|
||||
const int barW = bar.width();
|
||||
// A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so
|
||||
// a click exactly on the midpoint resizes low (deterministic).
|
||||
if (barW < 2 * kStripEdgeGrabWidth) {
|
||||
const int mid = bar.left + barW / 2;
|
||||
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
|
||||
}
|
||||
if (x < bar.left + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge;
|
||||
if (x >= bar.right - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge;
|
||||
return ZoneGrab::kBody;
|
||||
}
|
||||
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{};
|
||||
if (!contains(layout.keys, x, y)) return ZoneBarHit{};
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y);
|
||||
if (g != ZoneGrab::kNone) return ZoneBarHit{i, g};
|
||||
}
|
||||
return ZoneBarHit{}; // on the band but on no bar
|
||||
}
|
||||
|
||||
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
|
||||
if (dxPixels == 0) return clampNote(startNote);
|
||||
const int bandWidth = std::max(0, layout.keys.width());
|
||||
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
|
||||
// Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag
|
||||
// agree across the full strip, even on non-divisible-by-128 widths. The proportional
|
||||
// key width is (bandWidth / kStripKeyCount) in exact rational arithmetic; rounding to
|
||||
// the nearest key (half-key drag flips at the key centre) is achieved by adding
|
||||
// bandWidth/2 to the absolute pixel delta before dividing — identical to the old
|
||||
// formula except keyWidth is now derived from the same linear map (exact rational)
|
||||
// rather than the truncated-integer bandWidth/128 that caused drift at the far end.
|
||||
const int half = bandWidth / 2;
|
||||
int shift;
|
||||
if (dxPixels > 0) {
|
||||
shift = (dxPixels * kStripKeyCount + half) / bandWidth;
|
||||
} else {
|
||||
shift = -(((-dxPixels) * kStripKeyCount + half) / bandWidth);
|
||||
}
|
||||
return clampNote(startNote + shift);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -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
|
||||
@@ -0,0 +1,113 @@
|
||||
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
|
||||
|
||||
#include "note_entry.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
char asciiUpper(char c) {
|
||||
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
}
|
||||
|
||||
std::string trim(const std::string& s) {
|
||||
std::size_t a = 0;
|
||||
std::size_t b = s.size();
|
||||
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
||||
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
||||
return s.substr(a, b - a);
|
||||
}
|
||||
|
||||
int clampNote(long long n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > 127) return 127;
|
||||
return static_cast<int>(n);
|
||||
}
|
||||
|
||||
// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter.
|
||||
int letterSemitone(char up) {
|
||||
switch (up) {
|
||||
case 'C': return 0;
|
||||
case 'D': return 2;
|
||||
case 'E': return 4;
|
||||
case 'F': return 5;
|
||||
case 'G': return 7;
|
||||
case 'A': return 9;
|
||||
case 'B': return 11;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4
|
||||
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name.
|
||||
std::optional<int> parseNoteName(const std::string& s) {
|
||||
if (s.empty()) return std::nullopt;
|
||||
std::size_t i = 0;
|
||||
const int base = letterSemitone(asciiUpper(s[i]));
|
||||
if (base < 0) return std::nullopt; // not a letter -> not a note name
|
||||
++i;
|
||||
int semitone = base;
|
||||
// Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs).
|
||||
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
|
||||
// A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is
|
||||
// an accidental. '#' raises, 'b'/'B' lowers.
|
||||
if (s[i] == '#') ++semitone;
|
||||
else --semitone;
|
||||
++i;
|
||||
}
|
||||
// The octave: an optional sign then digits, running to the end.
|
||||
if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous)
|
||||
bool neg = false;
|
||||
if (s[i] == '+' || s[i] == '-') {
|
||||
neg = (s[i] == '-');
|
||||
++i;
|
||||
}
|
||||
if (i >= s.size()) return std::nullopt;
|
||||
int octave = 0;
|
||||
bool anyDigit = false;
|
||||
for (; i < s.size(); ++i) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||
octave = octave * 10 + (s[i] - '0');
|
||||
anyDigit = true;
|
||||
}
|
||||
if (!anyDigit) return std::nullopt;
|
||||
if (neg) octave = -octave;
|
||||
// MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60).
|
||||
const long long note = static_cast<long long>(octave + 1) * 12 + semitone;
|
||||
return clampNote(note);
|
||||
}
|
||||
|
||||
std::optional<int> parseInteger(const std::string& s) {
|
||||
if (s.empty()) return std::nullopt;
|
||||
std::size_t i = 0;
|
||||
bool neg = false;
|
||||
if (s[i] == '+' || s[i] == '-') {
|
||||
neg = (s[i] == '-');
|
||||
++i;
|
||||
}
|
||||
if (i >= s.size()) return std::nullopt;
|
||||
long long v = 0;
|
||||
for (; i < s.size(); ++i) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||
v = v * 10 + (s[i] - '0');
|
||||
if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway
|
||||
}
|
||||
if (neg) v = -v;
|
||||
return clampNote(v);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<int> parseNoteEntry(const std::string& text) {
|
||||
const std::string s = trim(text);
|
||||
if (s.empty()) return std::nullopt;
|
||||
// Try a plain integer first (the common MIDI-number case); fall back to a note name.
|
||||
if (std::isdigit(static_cast<unsigned char>(s[0])) || s[0] == '+' ||
|
||||
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
|
||||
if (auto n = parseInteger(s)) return n;
|
||||
}
|
||||
return parseNoteName(s);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,33 @@
|
||||
// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's
|
||||
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The
|
||||
// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit-
|
||||
// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control
|
||||
// or a LICE text-entry idiom) and feeds the committed string here on Enter.
|
||||
//
|
||||
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't
|
||||
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a
|
||||
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI
|
||||
// note [0,127] and commits via the same off-thread reload as every other edit.
|
||||
//
|
||||
// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served):
|
||||
// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and
|
||||
// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60
|
||||
// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel).
|
||||
// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than
|
||||
// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns
|
||||
// nullopt (the shell keeps the old value + may flash the field).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal
|
||||
// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is
|
||||
// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps
|
||||
// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types.
|
||||
std::optional<int> parseNoteEntry(const std::string& text);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,92 @@
|
||||
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16
|
||||
// editor parameter panel. No host types; only the shared Rect + contains().
|
||||
|
||||
#include "param_slider.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
std::vector<ControlRow> layoutControls(const Rect& panel,
|
||||
const std::vector<ControlDesc>& controls) {
|
||||
std::vector<ControlRow> out;
|
||||
if (controls.empty() || panel.width() <= 0 || panel.height() <= 0) return out;
|
||||
out.reserve(controls.size());
|
||||
|
||||
// The label column is clamped so a narrow panel still leaves a control column.
|
||||
const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width() / 2));
|
||||
int rowTop = panel.top;
|
||||
for (const ControlDesc& d : controls) {
|
||||
ControlRow r;
|
||||
r.id = d.id;
|
||||
r.kind = d.kind;
|
||||
const int rowBottom = rowTop + kControlRowHeight;
|
||||
r.row = Rect{panel.left, rowTop, panel.right, rowBottom};
|
||||
r.label = Rect{panel.left, rowTop, panel.left + labelW, rowBottom};
|
||||
r.control = Rect{panel.left + labelW, rowTop, panel.right, rowBottom};
|
||||
out.push_back(r);
|
||||
rowTop = rowBottom + kControlRowGap;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect toggleSegmentRect(const Rect& control, int seg) {
|
||||
if (seg < 0 || seg >= kToggleSegments) return Rect{};
|
||||
const int w = control.width();
|
||||
if (w <= 0 || control.height() <= 0) return Rect{};
|
||||
const int segW = w / kToggleSegments;
|
||||
const int left = control.left + seg * segW;
|
||||
// The last segment absorbs the width remainder so the segments tile the whole control.
|
||||
const int right = (seg == kToggleSegments - 1) ? control.right : left + segW;
|
||||
return Rect{left, control.top, right, control.bottom};
|
||||
}
|
||||
|
||||
int toggleSegmentHitTest(const Rect& control, int x, int y) {
|
||||
if (!contains(control, x, y)) return -1;
|
||||
for (int seg = 0; seg < kToggleSegments; ++seg) {
|
||||
if (contains(toggleSegmentRect(control, seg), x, y)) return seg;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
Rect sliderTrackRect(const Rect& control) {
|
||||
// Inset a half-handle at each end so the handle stays fully inside the control at value
|
||||
// 0 and 1. The handle CENTER ranges across [track.left, track.right].
|
||||
const int half = kSliderHandleWidth / 2;
|
||||
if (control.width() <= kSliderHandleWidth || control.height() <= 0) return Rect{};
|
||||
return Rect{control.left + half, control.top, control.right - half, control.bottom};
|
||||
}
|
||||
|
||||
Rect sliderHandleRect(const Rect& control, double value) {
|
||||
const Rect track = sliderTrackRect(control);
|
||||
if (track.width() <= 0) return Rect{};
|
||||
if (value < 0.0) value = 0.0;
|
||||
if (value > 1.0) value = 1.0;
|
||||
const int span = track.width(); // handle-center movable span
|
||||
const int centerX = track.left + static_cast<int>(value * span + 0.5);
|
||||
const int half = kSliderHandleWidth / 2;
|
||||
return Rect{centerX - half, control.top, centerX - half + kSliderHandleWidth,
|
||||
control.bottom};
|
||||
}
|
||||
|
||||
double valueAtPoint(const Rect& control, int x) {
|
||||
const Rect track = sliderTrackRect(control);
|
||||
const int span = track.width();
|
||||
if (span <= 0) return 0.0;
|
||||
if (x <= track.left) return 0.0;
|
||||
if (x >= track.right) return 1.0;
|
||||
return static_cast<double>(x - track.left) / static_cast<double>(span);
|
||||
}
|
||||
|
||||
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
|
||||
for (const ControlRow& r : rows) {
|
||||
if (r.kind == ControlKind::Toggle) {
|
||||
if (contains(r.control, x, y)) return r.id;
|
||||
} else { // Slider — the interactive area is the track
|
||||
if (contains(sliderTrackRect(r.control), x, y)) return r.id;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,104 @@
|
||||
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the
|
||||
// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the
|
||||
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The
|
||||
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and
|
||||
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell
|
||||
// draws each row (label + track/segments + handle) and routes clicks/drags into these
|
||||
// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping.
|
||||
//
|
||||
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface
|
||||
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR
|
||||
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade
|
||||
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope
|
||||
// enable/attack/decay/depth. They are two shapes only — a two-segment TOGGLE and a
|
||||
// horizontal SLIDER — laid out as a vertical stack of fixed-height rows. This module lays out
|
||||
// that stack and maps a slider's NORMALIZED value (0..1) to/from its handle pixel; the shell
|
||||
// converts each control's engine value (frames, seconds, a fraction, a signed semitone
|
||||
// depth) to/from that 0..1 with its own domain knowledge (this module stays engine-free so it
|
||||
// tests without the audio core).
|
||||
//
|
||||
// It reuses editor_geometry's Rect + contains() (one shared geometry idiom).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Fixed control-panel metrics, exposed so the shell and tests agree.
|
||||
inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap)
|
||||
inline constexpr int kControlRowGap = 4; // vertical gap below each row
|
||||
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left
|
||||
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px)
|
||||
inline constexpr int kToggleSegments = 2; // a toggle is always two segments
|
||||
|
||||
// A control is one of two shapes. Toggle = a two-segment selector (the active segment
|
||||
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value.
|
||||
enum class ControlKind { Toggle, Slider };
|
||||
|
||||
// One control the shell places in the panel, in stack order. `id` is the shell's own control
|
||||
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the
|
||||
// shell routes the interaction to the right engine param — this module never interprets it.
|
||||
struct ControlDesc {
|
||||
int id = 0;
|
||||
ControlKind kind = ControlKind::Slider;
|
||||
};
|
||||
|
||||
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect
|
||||
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
|
||||
// into segments via toggleSegmentRect). `index` is the control's position in the stack.
|
||||
struct ControlRow {
|
||||
int id = 0;
|
||||
ControlKind kind = ControlKind::Slider;
|
||||
Rect row; // the full row (label column + control column)
|
||||
Rect label; // the label column at the left
|
||||
Rect control; // the control column to the right of the label (track / toggle area)
|
||||
};
|
||||
|
||||
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each
|
||||
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left
|
||||
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A
|
||||
// row whose top falls past the panel bottom is still returned (the shell clips at paint /
|
||||
// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty
|
||||
// control list or a degenerate panel yields an empty vector. Pure.
|
||||
std::vector<ControlRow> layoutControls(const Rect& panel,
|
||||
const std::vector<ControlDesc>& controls);
|
||||
|
||||
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect,
|
||||
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width
|
||||
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
|
||||
// control rect yields an empty rect. Pure.
|
||||
Rect toggleSegmentRect(const Rect& control, int seg);
|
||||
|
||||
// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a
|
||||
// miss (outside the control area). Pure.
|
||||
int toggleSegmentHitTest(const Rect& control, int x, int y);
|
||||
|
||||
// The slider track sub-rect inside a slider control's `control` rect: the control inset so the
|
||||
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle
|
||||
// margin at each end). The handle CENTER ranges across [track.left, track.right] as the value
|
||||
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
|
||||
// empty rect. Pure.
|
||||
Rect sliderTrackRect(const Rect& control);
|
||||
|
||||
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a
|
||||
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
|
||||
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
|
||||
Rect sliderHandleRect(const Rect& control, double value);
|
||||
|
||||
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range).
|
||||
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate
|
||||
// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the
|
||||
// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones).
|
||||
double valueAtPoint(const Rect& control, int x);
|
||||
|
||||
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc
|
||||
// id) whose interactive area (a Slider's track, a Toggle's whole control area) contains the
|
||||
// point, or -1 for a miss (a gap, the label column, or outside every row). The FIRST matching
|
||||
// row wins (rows never overlap, so at most one matches). Pure — the shell's routing entry
|
||||
// point: on a hit it reads the value (valueAtPoint / toggleSegmentHitTest) and commits.
|
||||
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,126 @@
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract and the S16-F2
|
||||
// route-(b) rationale (WDL drags <windows.h>, so the Preserve DSP is house-native here).
|
||||
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
|
||||
//
|
||||
// Algorithm: a single delay ring of `window_` frames. The write head advances one frame per
|
||||
// input sample (source rate → duration preserved). TWO read taps chase the write head, offset
|
||||
// by half a window; each advances by the shift `ratio_` per frame. A tap that would cross the
|
||||
// write head wraps by a full window (so it stays a bounded delay behind the writer). The two
|
||||
// taps are crossfaded by an equal-power window keyed to each tap's distance from the write
|
||||
// head, so the wrap discontinuity of one tap is masked by the other mid-window — the classic
|
||||
// two-grain time-domain pitch shifter, no FFT.
|
||||
|
||||
#include "pitch_shift.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// A Hann OLA window over a grain phase in [0,1): 0.5(1 - cos(2*pi*phase)). Zero at the grain
|
||||
// ends (where a tap wraps — the discontinuity), unity mid-grain. Two grains offset by half a
|
||||
// window PARTITION UNITY (w(p) + w(p+0.5) == 1 for all p), so the two crossfaded taps sum to a
|
||||
// gain of exactly 1 everywhere — no amplitude ripple across the window, and each tap's wrap
|
||||
// seam is masked because its window is 0 exactly there.
|
||||
double hannWeight(double phase) {
|
||||
while (phase < 0.0) phase += 1.0;
|
||||
while (phase >= 1.0) phase -= 1.0;
|
||||
return 0.5 * (1.0 - std::cos(2.0 * 3.14159265358979323846 * phase));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
window_ = windowFrames;
|
||||
if (window_ <= 1) {
|
||||
// Pass-through: no ring, process() returns input unchanged.
|
||||
ring_.clear();
|
||||
writePos_ = 0;
|
||||
readPos_ = 0.0;
|
||||
ratio_ = 1.0;
|
||||
return;
|
||||
}
|
||||
ring_.assign(static_cast<std::size_t>(window_), 0.0f);
|
||||
reset();
|
||||
}
|
||||
|
||||
void PitchShifter::reset() {
|
||||
if (window_ > 1) {
|
||||
// Zero the ring and seed the read head a half-window behind the writer so the two taps
|
||||
// (readPos_ and readPos_ + window/2) straddle the writer from the first frame.
|
||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||
writePos_ = 0;
|
||||
readPos_ = static_cast<double>(window_) / 2.0;
|
||||
} else {
|
||||
writePos_ = 0;
|
||||
readPos_ = 0.0;
|
||||
}
|
||||
ratio_ = 1.0;
|
||||
}
|
||||
|
||||
void PitchShifter::warm() {
|
||||
if (window_ <= 1) return; // pass-through needs no warm-up
|
||||
// Push one full window of silence so the taps reach steady state before real audio.
|
||||
for (std::int64_t i = 0; i < window_; ++i) process(0.0f);
|
||||
}
|
||||
|
||||
void PitchShifter::setShiftRatio(double ratio) {
|
||||
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run taps backward/stall)
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::process(AudioSample in) {
|
||||
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
|
||||
|
||||
// 1. Write the incoming sample at the write head (source rate).
|
||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||
|
||||
const double w = static_cast<double>(window_);
|
||||
const double half = w / 2.0;
|
||||
|
||||
// 2. Read the two taps, each a bounded delay behind the writer. tap0 is `readPos_`; tap1 is
|
||||
// a half-window ahead of it (mod window). Distance-from-writer drives the crossfade so a
|
||||
// tap near the writer (about to wrap) is faded out while its partner (mid-window) is up.
|
||||
auto readTap = [&](double pos) -> double {
|
||||
// Fractional linear interpolation with ring wrap.
|
||||
double p = pos;
|
||||
while (p < 0.0) p += w;
|
||||
while (p >= w) p -= w;
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(p);
|
||||
const double frac = p - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (i1 >= window_) i1 = 0;
|
||||
const double s0 = static_cast<double>(ring_[static_cast<std::size_t>(i0)]);
|
||||
const double s1 = static_cast<double>(ring_[static_cast<std::size_t>(i1)]);
|
||||
return s0 + (s1 - s0) * frac;
|
||||
};
|
||||
|
||||
const double tap0 = readTap(readPos_);
|
||||
const double tap1 = readTap(readPos_ + half);
|
||||
|
||||
// Distance of tap0 behind the write head, in [0, window). Its crossfade phase is that
|
||||
// distance over the window; tap1 (half a window offset) gets the complementary phase.
|
||||
double dist0 = static_cast<double>(writePos_) - readPos_;
|
||||
while (dist0 < 0.0) dist0 += w;
|
||||
while (dist0 >= w) dist0 -= w;
|
||||
const double phase0 = dist0 / w;
|
||||
|
||||
// Hann windows offset by half a grain partition unity, so the two taps sum to gain 1 with
|
||||
// each tap's wrap seam masked by its window zero. phase0 drives tap0; tap1 (half-window
|
||||
// offset) is at phase0 + 0.5.
|
||||
const double g0 = hannWeight(phase0);
|
||||
const double g1 = hannWeight(phase0 + 0.5);
|
||||
const double out = tap0 * g0 + tap1 * g1;
|
||||
|
||||
// 3. Advance heads: write head one frame (source rate), read head by the shift ratio.
|
||||
++writePos_;
|
||||
if (writePos_ >= window_) writePos_ = 0;
|
||||
readPos_ += ratio_;
|
||||
while (readPos_ >= w) readPos_ -= w;
|
||||
while (readPos_ < 0.0) readPos_ += w;
|
||||
|
||||
return static_cast<AudioSample>(out);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
|
||||
// engine's DSP core. Time-domain overlap-add (OLA) with two half-window-offset read taps
|
||||
// crossfaded to hide the ring-wrap seam. Source is consumed 1:1 and output produced 1:1
|
||||
// (duration held); only the PITCH changes — an octave up plays the same wall-clock length
|
||||
// as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
|
||||
//
|
||||
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
|
||||
// route (a) `WDL_SimplePitchShifter`. But its include chain
|
||||
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
|
||||
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
|
||||
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
|
||||
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
|
||||
// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same
|
||||
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
|
||||
// the SHELL, never in the pure core.
|
||||
//
|
||||
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
|
||||
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
|
||||
// wav_trim do the same).
|
||||
//
|
||||
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
|
||||
// thread, at voice allocation). `warm()` pre-fills the ring with silence so steady-state
|
||||
// latency is reached before the first real sample (no cold-start click). `process()` does
|
||||
// NO allocation and NO locks — it reads/writes the pre-sized ring only. All state is plain
|
||||
// value fields, so a voice owning one by value costs a fixed ring buffer per channel.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "peaks.h" // AudioSample (float)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// A per-channel time-domain OLA pitch shifter. One instance transposes ONE channel; a stereo
|
||||
// voice owns two (or a stereo-aware wrapper) — the algorithm is per-sample and channel-count
|
||||
// agnostic, matching the S7 "one read head, per-channel value" idiom of the core.
|
||||
//
|
||||
// The default-constructed shifter is INERT: with no configure() it passes input through
|
||||
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
|
||||
// byte-identical to the pre-S16 engine.
|
||||
class PitchShifter {
|
||||
public:
|
||||
// Size the delay ring for `windowFrames` (the OLA grain length) and prepare the two
|
||||
// read taps a half-window apart. `windowFrames` <= 1 degrades to pass-through (no ring),
|
||||
// so a degenerate configure never divides by zero or wraps a zero span. Called OFF the
|
||||
// audio thread (allocates). Resets all running state. A larger window = smoother on large
|
||||
// transpositions but more latency; the shell picks it from the Preserve quality setting.
|
||||
void configure(std::int64_t windowFrames);
|
||||
|
||||
// Pre-fill the ring with silence (one full window of zero writes) so the read taps reach
|
||||
// steady state before the first real sample. Removes the cold-start seam (the S16 "onset
|
||||
// click absent" requirement) — call once at voice allocation after configure(). No-op when
|
||||
// unconfigured (pass-through needs no warm-up).
|
||||
void warm();
|
||||
|
||||
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
|
||||
// 1.0 = no shift (pass-through-equivalent output). Set per frame is fine (cheap); the tap
|
||||
// advance simply uses the current value. Values <= 0 are ignored (kept at the last valid
|
||||
// ratio) so a bad input never runs the taps backward or stalls them.
|
||||
void setShiftRatio(double ratio);
|
||||
|
||||
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
|
||||
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
|
||||
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
|
||||
// head, reads the two half-window-offset taps advancing at the shift ratio, crossfades
|
||||
// them by the write-head-relative distance (equal-power), and advances both heads by one.
|
||||
AudioSample process(AudioSample in);
|
||||
|
||||
// Reset running state to a freshly-warmed-equivalent silence (ring zeroed, heads re-seeded)
|
||||
// WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window.
|
||||
void reset();
|
||||
|
||||
// True once configure() sized a real ring (window > 1). A pass-through shifter is false.
|
||||
bool configured() const { return window_ > 1; }
|
||||
|
||||
std::int64_t window() const { return window_; }
|
||||
|
||||
private:
|
||||
std::vector<AudioSample> ring_; // delay line, length `window_` (channel-local)
|
||||
std::int64_t window_ = 0; // OLA grain length in frames; <= 1 = pass-through
|
||||
std::int64_t writePos_ = 0; // integer write head into the ring (source rate)
|
||||
double readPos_ = 0.0; // fractional read head (advances at shift ratio)
|
||||
double ratio_ = 1.0; // current shift ratio (>0)
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,112 @@
|
||||
// reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin.
|
||||
|
||||
#include "reaper_bridge.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "bridge_marshal.h"
|
||||
#include "capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation)
|
||||
#include "ext_keys.h" // kProjExtNamespace (shared wire contract)
|
||||
|
||||
// The VST3 base types must be included before REAPER's VST3 interface header, which
|
||||
// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from
|
||||
// pluginterfaces/base — all in namespace Steinberg.
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
#include "pluginterfaces/base/ftypes.h"
|
||||
|
||||
// REAPER's VST3-side bridge interface (vendored). IReaperHostApplication is what REAPER
|
||||
// passes (as an IHostApplication) to IComponent::initialize; it exposes getReaperApi
|
||||
// (resolve-by-name) and getReaperParent (host context). The header uses UNQUALIFIED
|
||||
// Steinberg types (FUnknown, CStringA, uint32, FUID, DECLARE_CLASS_IID, PLUGIN_API), so
|
||||
// it must be pulled into the Steinberg namespace — the same way REAPER's own VST3
|
||||
// examples include it.
|
||||
namespace Steinberg {
|
||||
#include "reaper_vst3_interfaces.h"
|
||||
} // namespace Steinberg
|
||||
|
||||
// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperHostApplication::iid; some
|
||||
// TU must DEFINE it. We do it here — this is the only place that queries for the
|
||||
// interface (FUnknownPtr uses the iid), so the definition lives with its sole use.
|
||||
DEF_CLASS_IID(Steinberg::IReaperHostApplication)
|
||||
|
||||
// The ext-state namespace is the SHARED wire contract between the extension (writer)
|
||||
// and this instrument (reader); it lives in ext_keys.h (pure, REAPER-free) —
|
||||
// reasampler::kProjExtNamespace() — so the two artifacts read one symbol and cannot
|
||||
// drift. Channel-derived (Phase V, V4): the accessor returns "reasampler" (stable) or
|
||||
// "reasampler_beta" (beta), matching whatever the extension wrote. The S1 spike
|
||||
// duplicated it locally; that duplication is retired.
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
||||
getProjExtState_ = nullptr;
|
||||
enumProjExtState_ = nullptr;
|
||||
enumProjects_ = nullptr;
|
||||
hostApp_ = nullptr;
|
||||
if (!context) return false;
|
||||
|
||||
// Query the host context for REAPER's bridge interface. In a non-REAPER host this
|
||||
// query fails and we stay unconnected — the instrument still loads.
|
||||
Steinberg::FUnknownPtr<Steinberg::IReaperHostApplication> reaper(context);
|
||||
if (!reaper) return false;
|
||||
hostApp_ = reaper.get();
|
||||
|
||||
// Resolve the ext-state functions by name. getReaperApi returns the same function
|
||||
// pointers the extension resolves via rec->GetFunc; a null return means the symbol
|
||||
// is unavailable (very old REAPER) — degrade gracefully.
|
||||
getProjExtState_ = reinterpret_cast<GetProjExtStateFn>(
|
||||
reaper->getReaperApi("GetProjExtState"));
|
||||
enumProjExtState_ = reinterpret_cast<EnumProjExtStateFn>(
|
||||
reaper->getReaperApi("EnumProjExtState"));
|
||||
// EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call
|
||||
// persist.cpp uses, so the instrument derives the project directory identically.
|
||||
enumProjects_ = reinterpret_cast<EnumProjectsFn>(
|
||||
reaper->getReaperApi("EnumProjects"));
|
||||
|
||||
return getProjExtState_ != nullptr;
|
||||
}
|
||||
|
||||
std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::string& key) {
|
||||
if (!getProjExtState_ || !hostApp_) return std::nullopt;
|
||||
|
||||
// Fetch the host project (getReaperParent(3) — project). Reads that live "reasampler"
|
||||
// ext-state against the ACTIVE project the instrument was instantiated in, so it
|
||||
// follows project switches for free (D6).
|
||||
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
|
||||
void* proj = reaper->getReaperParent(3);
|
||||
// A null project is legitimate (e.g. instantiated before a project context exists);
|
||||
// REAPER treats null as the current project for these calls, so we pass it through
|
||||
// rather than bailing — but if the read yields nothing the caller sees nullopt.
|
||||
|
||||
// GetProjExtState writes into a caller buffer; the bank blob can be large (many
|
||||
// samples), so grow the buffer until the value fits rather than risk a silent
|
||||
// truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The
|
||||
// return value is the value length; if it fits strictly inside the buffer it is
|
||||
// complete, else grow and retry up to a 16 MB ceiling.
|
||||
for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) {
|
||||
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
|
||||
const int rv = getProjExtState_(proj, kProjExtNamespace(), key.c_str(),
|
||||
buf.data(), cap);
|
||||
if (rv <= 0) return std::nullopt; // absent / empty key
|
||||
std::string s(buf.data());
|
||||
if (static_cast<int>(s.size()) + 1 < cap) {
|
||||
return decodeGetProjExtState(rv, s);
|
||||
}
|
||||
// else: possibly truncated -> grow and retry.
|
||||
}
|
||||
return std::nullopt; // pathologically large (>16 MB) — give up rather than loop
|
||||
}
|
||||
|
||||
std::string ReaperBridge::activeProjectDir() {
|
||||
if (!enumProjects_) return {};
|
||||
// idx=-1 is the current project tab; the out-buffer receives the full .rpp path,
|
||||
// EMPTY for a never-saved project. Same call + convention as persist.cpp; the pure
|
||||
// projectDirOfRpp turns the .rpp path into the project directory (parent, forward-
|
||||
// slashed) and keeps an unsaved project's empty path empty (no default-location
|
||||
// fallback — the tool's invariant).
|
||||
std::vector<char> buf(4096, '\0');
|
||||
enumProjects_(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,79 @@
|
||||
// reaper_bridge.h — the REAPER VST-host bridge (Phase S1 read spike). THIN shell:
|
||||
// resolves REAPER API functions by name over the host context and reads the live
|
||||
// "reasampler" project ext-state. The fiddly decode lives in bridge_marshal (pure).
|
||||
//
|
||||
// VERIFIED BRIDGE MECHANISM (corrects §1a's estimate). §1a described the VST2-style
|
||||
// hostcb opcode pattern (hostcb(&effect, 0xdeadbeef, 0xdeadf00d, ...)). That is the
|
||||
// VST2 path (video_processor.h documents it for a VST2 aEffect). For a VST3 plugin the
|
||||
// bridge is exposed differently and more cleanly: REAPER passes an IHostApplication as
|
||||
// the `context` to IComponent::initialize(FUnknown* context); querying it for
|
||||
// IReaperHostApplication (vendor/reaper-sdk/sdk/reaper_vst3_interfaces.h) yields:
|
||||
// * getReaperApi(funcname) -> resolve a REAPER API function pointer by name
|
||||
// (the VST3 equivalent of opcode 0xdeadf00d), and
|
||||
// * getReaperParent(3) -> the host ReaProject* (the VST3 equivalent of the
|
||||
// 0xdeadf00e host-context fetch; 1=track, 2=take, 3=project, 4=fxdsp, 5=trackchan).
|
||||
// So a VST3 uses IReaperHostApplication, not the raw hostcb opcodes. Verified against
|
||||
// reaper_vst3_interfaces.h + reaper_plugin_functions.h at the spike.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Wraps the REAPER host bridge for a single plugin instance. Constructed cheaply;
|
||||
// connect() must be called with the initialize() context before any read. All reads
|
||||
// degrade to nullopt (never crash) when the host is not REAPER or a symbol is absent —
|
||||
// the instrument must load in non-REAPER hosts too, just without live state.
|
||||
class ReaperBridge {
|
||||
public:
|
||||
ReaperBridge() = default;
|
||||
|
||||
// Bind to the host. `context` is the FUnknown* REAPER hands IComponent::initialize.
|
||||
// Returns true when the REAPER bridge is available (host is REAPER and the ext-state
|
||||
// API resolved). Safe to call with a null or non-REAPER context — returns false.
|
||||
bool connect(Steinberg::FUnknown* context);
|
||||
|
||||
// True once connect() found the REAPER host application AND resolved the ext-state
|
||||
// functions.
|
||||
bool isConnected() const { return getProjExtState_ != nullptr; }
|
||||
|
||||
// Read a "reasampler" ext-state value by key from the host's active project.
|
||||
// Returns nullopt when unconnected, when the project can't be resolved, or when the
|
||||
// key is absent. This is the S1 read-spike entry point.
|
||||
//
|
||||
// NOT REAL-TIME SAFE (it allocates a read buffer and calls into REAPER): callers on
|
||||
// the audio thread MUST NOT invoke it. The S4 instrument reads on the main/UI thread
|
||||
// and hands a snapshot to the process path (see reasampler_processor.cpp).
|
||||
std::optional<std::string> readReasamplerExtState(const std::string& key);
|
||||
|
||||
// The active project's directory (the folder holding its .rpp), forward-slashed,
|
||||
// no trailing slash — the M4 convention persist uses to place the bank alongside
|
||||
// the .rpp. Empty for an unsaved project or when unconnected. The instrument
|
||||
// resolves relative sample paths against this the SAME way persist does
|
||||
// (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe.
|
||||
std::string activeProjectDir();
|
||||
|
||||
private:
|
||||
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
|
||||
// verified against reaper_plugin_functions.h.
|
||||
using GetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
|
||||
char* valOutNeedBig, int valOutNeedBig_sz);
|
||||
using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx,
|
||||
char* keyOut, int keyOut_sz, char* valOut,
|
||||
int valOut_sz);
|
||||
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path (SDK line
|
||||
// ~1264). The instrument uses idx=-1 (current tab) so it follows the active project,
|
||||
// and reads the .rpp path from the out-buffer exactly as persist.cpp does.
|
||||
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
|
||||
|
||||
void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp)
|
||||
GetProjExtStateFn getProjExtState_ = nullptr;
|
||||
EnumProjExtStateFn enumProjExtState_ = nullptr;
|
||||
EnumProjectsFn enumProjects_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
// reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000
|
||||
// capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the
|
||||
// host's IPlugView seat and routes host paint/mouse into the pure geometry modules
|
||||
// (capture_browser, keyboard_strip) + the pure mapping (sample_map). Windows-only (D5).
|
||||
//
|
||||
// The default face is the CAPTURE BROWSER: a bank-filter tab strip over a grid of
|
||||
// scannable capture cards (peak thumbnail + name + root/key badge). A fresh instance with
|
||||
// no pick shows a "pick a capture" EMPTY STATE and plays silence (the S10 policy reversal
|
||||
// of the S4 first-sample auto-play). Picking a card loads that one capture and reveals a
|
||||
// guided SINGLE-CAPTURE SETUP surface (a keyboard strip with the capture's root marker +
|
||||
// a level readout). Multi-zone keymap editing is a demoted, opt-in ZONES panel (S10-Z),
|
||||
// reached by a toggle and driven by the same keyboard_strip drag machine.
|
||||
//
|
||||
// All layout/hit-test/drag math lives in the pure modules; this shell only draws + routes
|
||||
// (a LICE_SysBitmap blitted in WM_PAINT, a WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP
|
||||
// drag-state machine hit-testing via the pure resolvers). Peak thumbnails are computed
|
||||
// shell-side from the decoded WAV (bank_model's Sample carries no envelope) and cached —
|
||||
// the mirror of bank_panel::thumbnailFor. Every edit commits OFF the audio thread via the
|
||||
// processor's reloadFromBank (RT path untouched).
|
||||
//
|
||||
// Subclasses CPluginView for the IPlugView boilerplate; overrides the attach/remove hooks
|
||||
// to create/destroy the child window and onSize to resize it.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "public.sdk/source/common/pluginview.h"
|
||||
|
||||
#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
|
||||
#include "param_slider.h" // ControlRow (the S12/S15/S16 control-surface geometry)
|
||||
#include "peaks.h" // Envelope (the cached peak thumbnail)
|
||||
#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only in the .cpp
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
class ReaSamplerEditor : public Steinberg::CPluginView {
|
||||
public:
|
||||
// `processor` owns this editor's lifetime domain and outlives it; the editor reads the
|
||||
// live bank through it and drives selection/zone edits + reload on user input. May be
|
||||
// null (defensive — a real host always supplies one).
|
||||
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||
~ReaSamplerEditor() override;
|
||||
|
||||
Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
|
||||
Steinberg::FIDString type) override;
|
||||
Steinberg::tresult PLUGIN_API canResize() override;
|
||||
|
||||
protected:
|
||||
void attachedToParent() override;
|
||||
void removedFromParent() override;
|
||||
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
|
||||
|
||||
private:
|
||||
// Which face the editor shows. The browser is the default; the Zones panel is the
|
||||
// demoted opt-in view reached by the toggle. Both draw over the same snapshotted bank.
|
||||
enum class View { kBrowser, kZones };
|
||||
|
||||
// What a mouse drag is currently editing (the drag-state machine). kNone = no drag in
|
||||
// flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the
|
||||
// single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop
|
||||
// marker on the S11 waveform surface (which marker is in waveMarker_).
|
||||
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
|
||||
kScrollThumb, kParamSlider };
|
||||
|
||||
// The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces).
|
||||
// The int value is the ControlDesc id the pure param_slider hit-test returns; the shell
|
||||
// maps it to the picked zone's play params. Order here is the panel's top-down stack order.
|
||||
enum class ParamControl {
|
||||
kPlayMode = 0, // Gate | Trigger toggle (S15)
|
||||
kPitchEngine, // Varispeed | Preserve toggle (S16)
|
||||
kAttack, // AHDSR attack (Gate) / —
|
||||
kHold, // AHDSR hold (Gate, S15)
|
||||
kDecay, // AHDSR decay (Gate)
|
||||
kSustain, // AHDSR sustain (Gate)
|
||||
kRelease, // AHDSR release (Gate)
|
||||
kTrigLength, // Trigger %-length (Trigger, S15)
|
||||
kTrigFadeIn, // Trigger fade-in (Trigger, S15)
|
||||
kTrigFadeOut, // Trigger fade-out (Trigger, S15)
|
||||
kPitchEnvEnable, // AD pitch envelope on|off (S16)
|
||||
kPitchEnvAttack, // AD pitch attack (S16)
|
||||
kPitchEnvDecay, // AD pitch decay (S16)
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones (S16)
|
||||
kCount
|
||||
};
|
||||
|
||||
// The waveform markers on the single-capture setup surface (S11). Order is the draw + hit
|
||||
// order (start first). Named generically per the spec so S15 can repurpose the surface with
|
||||
// a different marker set; here it is start-point + the sustain loop's two ends.
|
||||
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
|
||||
|
||||
#ifdef _WIN32
|
||||
void paint(HDC hdc);
|
||||
void paintBrowser(LICE_IBitmap* bmp, int w, int h);
|
||||
void paintSetup(LICE_IBitmap* bmp, const Rect& area);
|
||||
void paintZones(LICE_IBitmap* bmp, int w, int h);
|
||||
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
||||
void paintControls(LICE_IBitmap* bmp, const Rect& panel); // S12/S15/S16 param surface
|
||||
|
||||
void onMouseDown(int x, int y);
|
||||
void onMouseMove(int x, int y);
|
||||
void onMouseUp(int x, int y);
|
||||
void onMouseWheel(int delta); // S12 browser scroll (wheel)
|
||||
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
|
||||
|
||||
// S13 (relay degraded): an OS file drop landed on the editor window. We do NOT ingest (the
|
||||
// instrument is a read-only bank consumer and the relay is unshipped) — we flash the "drop
|
||||
// on the ReaSampler panel to add" affordance so the drop is never silently swallowed and the
|
||||
// shipped ingest gesture stays discoverable. `droppedCount` is how many files were dropped
|
||||
// (drawn into the banner). NEVER inserts a timeline item / never touches the bank.
|
||||
void onFilesDropped(int droppedCount);
|
||||
|
||||
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
|
||||
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
|
||||
// a new assignment request -> apply as this instance's selection) and, when anything
|
||||
// changed, re-snapshots the editor's own view (refreshFromBank) + repaints so the browser /
|
||||
// setup surface reflect the new bank. An open editor means THIS instance is the focused
|
||||
// assignment target (the thundering-herd policy — see the handoff), so it passes true.
|
||||
// Suppressed WHILE A DRAG IS IN FLIGHT so a mid-drag reload does not yank the edit surface.
|
||||
void onSyncTimer();
|
||||
|
||||
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
void invalidate();
|
||||
|
||||
HWND childHwnd_ = nullptr;
|
||||
#endif
|
||||
|
||||
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
|
||||
// selection + performance map. Main/UI thread only. Called on attach and after any edit.
|
||||
void refreshFromBank();
|
||||
|
||||
// Publish the edited zones/selection to the processor, then rebuild the instrument OFF
|
||||
// the audio thread. UI thread only. One place so every edit commits identically.
|
||||
void commitAndReload();
|
||||
|
||||
// Recompute the capture cards visible under the current bank filter (samples_ narrowed by
|
||||
// activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change.
|
||||
void rebuildVisible();
|
||||
|
||||
// The peak thumbnail for a bank sample id at `binCount` bins, computed once from the
|
||||
// decoded WAV (mirror of bank_panel::thumbnailFor) and cached by (id, binCount). Returns
|
||||
// an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O).
|
||||
const Envelope& thumbnailFor(const std::string& sampleId, int binCount);
|
||||
|
||||
// The decoded MONO PCM for a bank sample id, decoded once from the WAV and cached by id.
|
||||
// Feeds the S11 waveform surface: the full-res envelope binned at view width AND the
|
||||
// zero-crossing snap (both need the raw frames, not the binned thumbnail). Returns an empty
|
||||
// vector when the WAV can't be resolved/decoded. UI thread only (file I/O). Reuses the same
|
||||
// decode path as thumbnailFor (no new WAV reader), keyed by id (not width — snap is width-
|
||||
// independent). Cleared with the thumbnail cache on refresh.
|
||||
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
|
||||
|
||||
// The effective loop + start markers for the picked single capture (S11): the per-zone
|
||||
// OVERRIDE for the picked id when one exists in map_, else the bank's S2 loop intrinsic
|
||||
// (loop) / frame 0 (start). Absent loop -> loopStart==loopEnd==0 (the "no loop" state).
|
||||
// frames is the decoded length (for defaulting loopEnd when the bank left the loop empty).
|
||||
struct SetupMarkers {
|
||||
std::int64_t start = 0;
|
||||
std::int64_t loopStart = 0;
|
||||
std::int64_t loopEnd = 0;
|
||||
bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance)
|
||||
};
|
||||
SetupMarkers pickedMarkers(std::int64_t frames) const;
|
||||
|
||||
// Commit an edited marker set for the picked capture as a per-zone loop/start override
|
||||
// (upsert on the picked id — mirror of the root-marker path), then reload off-thread.
|
||||
void commitPickedMarkers(const SetupMarkers& m);
|
||||
|
||||
// Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
|
||||
// Does NOT call commitAndReload — callers decide whether this is a live-drag update or a
|
||||
// final commit. selectedId_ must be non-empty before calling. Returns the zone index
|
||||
// (0-based) that was updated or appended, so callers can set selectedZone_.
|
||||
int upsertPickedOverride(const SetupMarkers& m);
|
||||
|
||||
// --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------
|
||||
//
|
||||
// The control panel edits the SELECTED zone's ZonePlaySeconds (S15 play mode + AHDSR; S16
|
||||
// pitch engine + AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap
|
||||
// build resolves them to frames at the live rate. Instrument-owned (D-B), never a bank fact.
|
||||
|
||||
// The control descriptors the panel shows for `play`'s CURRENT play mode: the two toggles +
|
||||
// the mode-relevant sliders (AHDSR for Gate, %-length/fades for Trigger) + the pitch-envelope
|
||||
// controls. The pure param_slider lays these out; this only picks the set. Static (a free
|
||||
// choice of set from the mode) — kept a member for the ParamControl enum access.
|
||||
std::vector<ControlDesc> controlDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The normalized [0,1] display value for control `id` given `play` (the shell's domain
|
||||
// mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade
|
||||
// frames->0..1, semitone depth centered at 0.5).
|
||||
double controlValue(int id, const ZonePlaySeconds& play) const;
|
||||
|
||||
// Apply a committed control interaction to `play`: a slider's normalized `value` (mapped back
|
||||
// into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
|
||||
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
|
||||
// --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
|
||||
std::vector<SampleChoice> samples_; // every bank sample, bank order
|
||||
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
|
||||
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
|
||||
std::string selectedId_; // the single-capture pick ("" = empty state)
|
||||
PerformanceMap map_; // the opt-in zones (empty = no zones)
|
||||
ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot
|
||||
|
||||
// --- Transient UI state (not persisted; component state carries selection + zones) ---
|
||||
View view_ = View::kBrowser; // default face is the browser
|
||||
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
||||
int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none
|
||||
|
||||
// --- S13 drop-to-load affordance (relay DEGRADED — transient, never persisted) ----
|
||||
// S13's cross-artifact ingest relay (editor drop -> extension ingest) is NOT shipped: the
|
||||
// instrument's REAPER bridge is deliberately READ-ONLY (it never writes the bank / ext
|
||||
// state), so an editor drop cannot relay a bank-ingest request without a new write seam +
|
||||
// an extension-side poller (surfaced as a decision, not crossed here). The DEGRADE path per
|
||||
// the spec: the editor ACCEPTS the drop (WM_DROPFILES) and, rather than silently swallowing
|
||||
// it, flashes a clear affordance pointing at the shipped ingest gesture (drop onto the
|
||||
// docked ReaSampler panel). When > 0, the affordance banner is shown; each sync tick decays
|
||||
// it so it auto-dismisses. No file is ingested, no timeline item is ever inserted.
|
||||
int dropHintTicks_ = 0; // remaining sync ticks to show the drop affordance
|
||||
|
||||
// --- S12 browser scroll + search (transient UI state, never persisted) --------
|
||||
int scrollOffset_ = 0; // vertical px offset into the card grid (clamped)
|
||||
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||
bool searchFocused_ = false; // whether the search box has keyboard focus
|
||||
|
||||
// --- S12 numeric note entry (LICE text-entry idiom, transient) ----------------
|
||||
// When >= 0, a low/high/root field is being typed; entryText_ accumulates the keystrokes
|
||||
// and commits (parseNoteEntry) on Enter. -1 = no field editing. The field id is a
|
||||
// ParamControl-independent small enum encoded inline (see the .cpp: 0=low,1=high,2=root).
|
||||
int entryField_ = -1;
|
||||
std::string entryText_;
|
||||
|
||||
// --- Drag-state machine ------------------------------------------------------
|
||||
DragKind drag_ = DragKind::kNone;
|
||||
int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver
|
||||
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag
|
||||
int dragStartLow_ = 0; // the grabbed field's note at grab time
|
||||
int dragStartHigh_ = 0;
|
||||
int dragStartRoot_ = 60;
|
||||
PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss
|
||||
|
||||
// S11 waveform-marker drag: which marker + the marker set snapshotted at grab time (so the
|
||||
// pixel-delta resolver shifts the grabbed frame from its grab-time value, and inter-marker
|
||||
// clamps use the sibling markers).
|
||||
WaveMarker waveMarker_ = WaveMarker::kStart;
|
||||
SetupMarkers dragStartMarkers_;
|
||||
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
|
||||
|
||||
// S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts
|
||||
// from it). S12/S15/S16 param-slider drag: which control id + the panel it lives in (the
|
||||
// shell re-lays the panel each move to map x->value against the live control rect).
|
||||
int dragStartScrollOffset_ = 0;
|
||||
int dragParamId_ = -1;
|
||||
Rect dragParamPanel_{};
|
||||
|
||||
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
|
||||
// Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so
|
||||
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
|
||||
std::unordered_map<std::string, Envelope> thumbCache_;
|
||||
|
||||
// --- Decoded mono-PCM cache (S11; id -> full-res frames) ------------------------------
|
||||
// Keyed by id (width-independent, unlike thumbCache_). Feeds the waveform envelope binning
|
||||
// + the zero-crossing snap. Cleared alongside thumbCache_ on refresh so a re-captured or
|
||||
// deleted sample does not show/snap against stale PCM.
|
||||
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,264 @@
|
||||
// reasampler_embed.cpp — see reasampler_embed.h. The IReaperUIEmbedInterface shell.
|
||||
// Windows-only (D5); guarded so a non-Windows build degrades to a stub that reports
|
||||
// "not supported" and draws nothing.
|
||||
|
||||
#include "reasampler_embed.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app_version.h" // vstPluginName (channel-derived embed label, S18)
|
||||
#include "bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh)
|
||||
#include "editor_geometry.h" // Rect (shared with embed_strip)
|
||||
#include "embed_strip.h" // the pure strip layout + hit-test
|
||||
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey
|
||||
#include "reaper_bridge.h"
|
||||
#include "reasampler_processor.h"
|
||||
|
||||
// wdltypes.h first: it defines INT_PTR portably (and pulls <windows.h> on Windows), which
|
||||
// reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type.
|
||||
#include "wdltypes.h"
|
||||
|
||||
// REAPER's embed message/bitmap contract (vendored). REAPER_FXEMBED_IBitmap is an alias of
|
||||
// LICE_IBitmap, and the WM_* / DrawInfo / SizeHints definitions live here.
|
||||
#include "reaper_plugin_fx_embed.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
// LICE — the same drawing stack the IPlugView editor and bank_panel use. REAPER hands us a
|
||||
// LICE bitmap; we draw into it with the same calls, then return (REAPER blits it).
|
||||
#include "lice/lice.h"
|
||||
#endif
|
||||
|
||||
using namespace Steinberg;
|
||||
|
||||
// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperUIEmbedInterface::iid; some
|
||||
// TU must DEFINE it. This is the only place that answers queryInterface for it, so the
|
||||
// definition lives with its sole use (mirrors reaper_bridge.cpp doing this for
|
||||
// IReaperHostApplication).
|
||||
DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface)
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
#ifdef _WIN32
|
||||
// Palette — mirrored from reasampler_editor.cpp so the inline strip reads as the same tool.
|
||||
const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255);
|
||||
const LICE_pixel kColZone = LICE_RGBA(44, 44, 48, 255);
|
||||
const LICE_pixel kColZoneSel = LICE_RGBA(58, 96, 84, 255);
|
||||
const LICE_pixel kColZoneBorder = LICE_RGBA(20, 20, 22, 255);
|
||||
const LICE_pixel kColLevelBg = LICE_RGBA(20, 20, 22, 255);
|
||||
const LICE_pixel kColLevelFill = LICE_RGBA(120, 200, 160, 255);
|
||||
const LICE_pixel kColEmpty = LICE_RGBA(70, 70, 74, 255);
|
||||
const COLORREF kRgbText = RGB(210, 230, 220);
|
||||
|
||||
// A short display name for a bank sample id, from the snapshotted list (the editor's helper,
|
||||
// duplicated small rather than shared across the shell/pure boundary).
|
||||
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
|
||||
for (const SampleChoice& c : samples) {
|
||||
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
#endif
|
||||
|
||||
// Project the instrument's performance map into the strip's minimal zone shape (key ranges
|
||||
// only). Pure projection — kept here (shell side) because it reads PerformanceMap, a shell
|
||||
// type; embed_strip stays free of it.
|
||||
std::vector<EmbedZone> toEmbedZones(const PerformanceMap& map) {
|
||||
std::vector<EmbedZone> out;
|
||||
out.reserve(map.zones.size());
|
||||
for (const PerformanceZone& z : map.zones) out.push_back(EmbedZone{z.lowNote, z.highNote});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) {
|
||||
QUERY_INTERFACE(iid, obj, FUnknown::iid, IReaperUIEmbedInterface)
|
||||
QUERY_INTERFACE(iid, obj, IReaperUIEmbedInterface::iid, IReaperUIEmbedInterface)
|
||||
*obj = nullptr;
|
||||
return kNoInterface;
|
||||
}
|
||||
|
||||
void ReaSamplerEmbed::refresh() {
|
||||
if (!processor_) {
|
||||
samples_.clear();
|
||||
map_.zones.clear();
|
||||
selectedZone_ = -1;
|
||||
return;
|
||||
}
|
||||
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
}
|
||||
|
||||
void ReaSamplerEmbed::maybeRefresh() {
|
||||
if (!processor_) { refresh(); return; } // clears state; cheap
|
||||
|
||||
// The performance map is a cheap in-process accessor (mutex + copy), and the editor may
|
||||
// have edited zones with NO bank-content change — always re-snapshot it so a zone edit
|
||||
// reflects immediately.
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
|
||||
// The EXPENSIVE part is the bank-blob bridge read (samples_). Gate it on the S9 bank-
|
||||
// generation stamp (a small ext-state read): only re-read the bank when the generation
|
||||
// changed since the last paint (a recapture / ingest / remove), or on the first paint
|
||||
// (lastSeenBankGeneration_ == -1). A pre-S9 project reads generation 0; the first paint
|
||||
// folds it and subsequent idle paints skip the bank read entirely.
|
||||
std::int64_t currentGen = lastSeenBankGeneration_;
|
||||
if (auto rawGen =
|
||||
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBankGenKey)) {
|
||||
currentGen = parseBankGeneration(*rawGen);
|
||||
} else if (lastSeenBankGeneration_ < 0) {
|
||||
currentGen = 0; // unprimed + no stamp (pre-S9): treat as generation 0 for the first read
|
||||
}
|
||||
// Intentional asymmetry: a TRANSIENT bridge failure (readReasamplerExtState returned
|
||||
// nullopt after we were already primed) leaves currentGen == lastSeenBankGeneration_,
|
||||
// so the bank-blob read is skipped and the editor keeps its last-known sample list.
|
||||
// A stale-but-intact list is better than clearing samples_ on every transient hiccup.
|
||||
|
||||
if (lastSeenBankGeneration_ < 0 || currentGen != lastSeenBankGeneration_) {
|
||||
auto banks =
|
||||
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
|
||||
lastSeenBankGeneration_ = currentGen;
|
||||
}
|
||||
}
|
||||
|
||||
TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
switch (msg) {
|
||||
case REAPER_FXEMBED_WM_IS_SUPPORTED:
|
||||
#ifdef _WIN32
|
||||
return 1; // supported and available
|
||||
#else
|
||||
return 0; // not a build target off Windows
|
||||
#endif
|
||||
case REAPER_FXEMBED_WM_CREATE:
|
||||
refresh(); // prime the first paint's snapshot
|
||||
return 0;
|
||||
case REAPER_FXEMBED_WM_DESTROY:
|
||||
return 0;
|
||||
case REAPER_FXEMBED_WM_GETMINMAXINFO: {
|
||||
auto* hints = reinterpret_cast<REAPER_FXEMBED_SizeHints*>(parm3);
|
||||
if (!hints) return 0;
|
||||
// Minimum usable strip height: the keymap must not collapse below its floor
|
||||
// (kEmbedKeymapMinHeight) plus the level band.
|
||||
hints->min_width = 64;
|
||||
hints->max_width = 0; // 0 = unconstrained
|
||||
hints->min_height = kEmbedKeymapMinHeight + kEmbedLevelBandHeight;
|
||||
hints->max_height = 0; // 0 = unconstrained
|
||||
// Preferred aspect: wide strip, roughly 8:1 (w:h). 16.16 fixed point.
|
||||
hints->preferred_aspect = (8 << 16) / 1;
|
||||
hints->minimum_aspect = (4 << 16) / 1;
|
||||
return 1;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
case REAPER_FXEMBED_WM_PAINT:
|
||||
return paint(parm2, parm3) ? 1 : 0;
|
||||
case REAPER_FXEMBED_WM_LBUTTONDOWN:
|
||||
// Selection at most (S6): map the click to a zone; force a redraw if it changed.
|
||||
return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0;
|
||||
#endif
|
||||
default:
|
||||
return 0; // unhandled messages (cursor, wheel, hittest) fall through
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
auto* bmp = reinterpret_cast<LICE_IBitmap*>(bitmap);
|
||||
auto* di = reinterpret_cast<const REAPER_FXEMBED_DrawInfo*>(drawInfo);
|
||||
if (!bmp || !di) return false;
|
||||
const int w = di->width;
|
||||
const int h = di->height;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
// Re-read live state each paint (UI thread) so the strip reflects keymap edits + bank
|
||||
// changes without its own timer — REAPER repaints the embed surface on its cadence. S9
|
||||
// dirty-guard: maybeRefresh does the EXPENSIVE bank-blob read only when the bank generation
|
||||
// changed (the flagged S6 follow-up), always refreshing the cheap performance map.
|
||||
maybeRefresh();
|
||||
|
||||
// REAPER hands us its own bitmap sized to the embed area; draw directly into it (unlike
|
||||
// the editor, which owns a LICE_SysBitmap and BitBlt's). Origin is the bitmap's (0,0).
|
||||
LICE_FillRect(bmp, 0, 0, w, h, kColBackground, 1.0f, 0);
|
||||
|
||||
const EmbedLayout layout = layoutEmbed(w, h);
|
||||
|
||||
if (map_.zones.empty()) {
|
||||
// No opt-in zones authored: show a single faint band spanning the keymap area so the
|
||||
// strip reads as "present, no zones" — the default single-capture face lives in the
|
||||
// editor (this S6 strip mirrors the zones map only).
|
||||
LICE_FillRect(bmp, layout.keymap.left, layout.keymap.top, layout.keymap.width(),
|
||||
layout.keymap.height(), kColEmpty, 0.5f, 0);
|
||||
HDC dc = bmp->getDC();
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
SetTextColor(dc, kRgbText);
|
||||
RECT gr{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right,
|
||||
layout.keymap.bottom};
|
||||
const std::string label = reasampler::vstPluginName() + // channel-derived (S18)
|
||||
(samples_.empty() ? " (bank empty)" : " (no zones)");
|
||||
DrawTextA(dc, label.c_str(), -1, &gr,
|
||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
|
||||
} else {
|
||||
// Draw each zone as a segment across the keymap span, first-match order (so the
|
||||
// painted order matches selection + playback). The selected zone is highlighted.
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[i];
|
||||
const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote);
|
||||
if (r.width() <= 0) continue;
|
||||
const bool sel = (i == selectedZone_);
|
||||
LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(),
|
||||
sel ? kColZoneSel : kColZone, 1.0f, 0);
|
||||
LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, kColZoneBorder,
|
||||
1.0f, 0);
|
||||
// Label the segment with the sample name when it is wide enough to read.
|
||||
if (r.width() >= 24) {
|
||||
HDC dc = bmp->getDC();
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
SetTextColor(dc, kRgbText);
|
||||
RECT gr{r.left + 3, r.top, r.right - 2, r.bottom};
|
||||
const std::string label = sampleLabel(samples_, z.sampleId);
|
||||
DrawTextA(dc, label.c_str(), -1, &gr,
|
||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The level band: a static background here; a live activity level is a later refinement
|
||||
// (the processor would publish a peak the UI thread reads). Draw the empty band so the
|
||||
// strip's geometry is complete and the DAW-verify sees the band lifecycle now.
|
||||
if (layout.levelBand.height() > 0) {
|
||||
LICE_FillRect(bmp, layout.levelBand.left, layout.levelBand.top,
|
||||
layout.levelBand.width(), layout.levelBand.height(), kColLevelBg, 1.0f,
|
||||
0);
|
||||
const double level = processor_ ? processor_->embedActivityLevel() : 0.0;
|
||||
const Rect fill = levelFillRect(layout, level);
|
||||
if (fill.width() > 0) {
|
||||
LICE_FillRect(bmp, fill.left, fill.top, fill.width(), fill.height(),
|
||||
kColLevelFill, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerEmbed::onMouseDown(TPtrInt drawInfo) {
|
||||
auto* di = reinterpret_cast<const REAPER_FXEMBED_DrawInfo*>(drawInfo);
|
||||
if (!di || di->width <= 0 || di->height <= 0) return false;
|
||||
refresh();
|
||||
const EmbedLayout layout = layoutEmbed(di->width, di->height);
|
||||
const std::vector<EmbedZone> zones = toEmbedZones(map_);
|
||||
const int hit = zoneAtPoint(layout, zones.data(), static_cast<int>(zones.size()),
|
||||
di->mouse_x, di->mouse_y);
|
||||
if (hit == selectedZone_) return false; // no change -> no redraw
|
||||
selectedZone_ = hit;
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,112 @@
|
||||
// reasampler_embed.h — the S6 embedded TCP/MCP UI shell. Implements REAPER's
|
||||
// IReaperUIEmbedInterface (vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h +
|
||||
// reaper_vst3_interfaces.h) so the instrument draws a compact keymap/level strip INLINE in
|
||||
// the track/mixer control panel — the same Cockos surface REAPER's own embedded FX use.
|
||||
//
|
||||
// VERIFIED CONTRACT (against reaper_plugin_fx_embed.h + reaper_vst3_interfaces.h):
|
||||
// * VST3 exposes this by having the IEditController answer queryInterface for
|
||||
// IReaperUIEmbedInterface (iid {0x049bf9e7,0xbc74ead0,0xc4101e86,0x7f725981}). Our
|
||||
// SingleComponentEffect IS the edit controller, so the processor's queryInterface hands
|
||||
// REAPER a reference to this object.
|
||||
// * The single method is embed_message(int msg, TPtrInt parm2, TPtrInt parm3). msg is a
|
||||
// REAPER_FXEMBED_WM_* value (aliased to Win32 WM_*):
|
||||
// - WM_IS_SUPPORTED (0x0000): return 1 (supported+available), -1, or 0.
|
||||
// - WM_CREATE (0x0001) / WM_DESTROY (0x0002): embed begin/end; return ignored.
|
||||
// - WM_PAINT (0x000F): parm2 = REAPER_FXEMBED_IBitmap* (alias LICE_IBitmap) to draw
|
||||
// into; parm3 = REAPER_FXEMBED_DrawInfo* (context TCP=1/MCP=2, width/height, mouse,
|
||||
// flags). Return 1 if drawing occurred, 0 otherwise.
|
||||
// - WM_GETMINMAXINFO (0x0024): parm3 = SizeHints*; return 1 if filled.
|
||||
// - mouse WM_* (0x0200..0x020A): parm3 = DrawInfo*; return RETNOTIFY_INVALIDATE
|
||||
// (0x1000000) to force a redraw. Capture is auto-managed by the host.
|
||||
// * There is NO plugin-owned window/HWND here (unlike the IPlugView editor): REAPER hands
|
||||
// a LICE bitmap per paint; we only draw into it and read mouse coords from DrawInfo.
|
||||
//
|
||||
// RT DISCIPLINE (S6 constraint): all embed messages arrive on REAPER's UI thread; nothing
|
||||
// here runs in process(). It reads the same live state the editor reads (bank over the
|
||||
// bridge + the processor's performance map) with the same off-audio-thread accessors — no
|
||||
// new locks visible to process, read-only over the bank. Windows-only (D5), guarded so a
|
||||
// non-Windows build stays compilable.
|
||||
//
|
||||
// The strip's LAYOUT + HIT-TEST is pure (embed_strip.h, unit-tested); this shell marshals
|
||||
// REAPER's messages to/from it and draws with the same LICE idiom as reasampler_editor.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
|
||||
#include "sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects)
|
||||
|
||||
// REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is
|
||||
// pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host
|
||||
// interface header. Its iid is DEFINEd (DEF_CLASS_IID) in reasampler_embed.cpp.
|
||||
namespace Steinberg {
|
||||
#include "reaper_vst3_interfaces.h"
|
||||
} // namespace Steinberg
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
// Implements IReaperUIEmbedInterface. Lifetime is OWNED by the processor (the processor
|
||||
// holds the sole unique_ptr and hands out AddRef'd references from queryInterface); the
|
||||
// back-pointer to the processor is therefore always valid while this lives.
|
||||
class ReaSamplerEmbed : public Steinberg::IReaperUIEmbedInterface {
|
||||
public:
|
||||
explicit ReaSamplerEmbed(ReaSamplerProcessor* processor) : processor_(processor) {}
|
||||
|
||||
// The one embed entry point. Routes each REAPER_FXEMBED_WM_* message; see the header
|
||||
// note above for the per-message contract. UI thread only.
|
||||
Steinberg::TPtrInt embed_message(int msg, Steinberg::TPtrInt parm2,
|
||||
Steinberg::TPtrInt parm3) override;
|
||||
|
||||
// FUnknown: this object's lifetime is owned by the processor, not the host refcount, so
|
||||
// AddRef/release are no-ops (the processor's unique_ptr governs destruction) and
|
||||
// queryInterface answers only FUnknown + IReaperUIEmbedInterface. This mirrors how the
|
||||
// SDK's OBJ refcount would otherwise churn; here the owning processor guarantees the
|
||||
// object outlives every borrowed reference REAPER holds during embedding.
|
||||
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
|
||||
void** obj) override;
|
||||
Steinberg::uint32 PLUGIN_API addRef() override { return 1000; }
|
||||
Steinberg::uint32 PLUGIN_API release() override { return 1000; }
|
||||
|
||||
private:
|
||||
#ifdef _WIN32
|
||||
// Draw the current strip into REAPER's supplied LICE bitmap. Returns true if it drew.
|
||||
bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo);
|
||||
// Handle a mouse-down inside the strip: map to a zone and select it (S6: selection at
|
||||
// most — no new editing semantics). Returns true if the selection changed (the caller
|
||||
// then asks REAPER to invalidate).
|
||||
bool onMouseDown(Steinberg::TPtrInt drawInfo);
|
||||
#endif
|
||||
|
||||
// Snapshot the live bank + the instrument's performance map for the next paint, exactly
|
||||
// as the editor's refreshSampleList does (bridge read + processor accessors, UI thread).
|
||||
void refresh();
|
||||
|
||||
// The S9 dirty-guard over refresh() (the S6 flagged follow-up): read the cheap bank-
|
||||
// generation stamp; do the EXPENSIVE bank-blob bridge read (refresh()) only when the
|
||||
// generation changed since the last paint (or on the first paint) — the strip re-read
|
||||
// per paint was wasteful now that a generation counter exists. The performance map (a
|
||||
// cheap in-process accessor, edited by the editor independently of bank content) is
|
||||
// ALWAYS refreshed so a zone edit still reflects immediately. UI thread only.
|
||||
void maybeRefresh();
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
// The bank generation last folded into samples_ (S9 dirty-guard). -1 forces the first
|
||||
// maybeRefresh() to do a full read (no generation can be negative — parseBankGeneration
|
||||
// yields >= 0 — so -1 is an "unprimed" sentinel distinct from a real generation 0).
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
// Snapshotted for the current paint (refreshed each paint off the audio thread).
|
||||
std::vector<SampleChoice> samples_;
|
||||
PerformanceMap map_;
|
||||
// The zone the last click selected (local/visual only — S6 selection constraint; the
|
||||
// processor's editor-shared selection is NOT updated from here); -1 = none.
|
||||
// Drives the strip's highlight.
|
||||
int selectedZone_ = -1;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,610 @@
|
||||
// reasampler_processor.cpp — see reasampler_processor.h.
|
||||
|
||||
#include "reasampler_processor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "pluginterfaces/base/ibstream.h"
|
||||
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||
#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate)
|
||||
#include "pluginterfaces/vst/ivstevents.h"
|
||||
#include "pluginterfaces/vst/vstspeaker.h"
|
||||
|
||||
#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr)
|
||||
|
||||
#include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse)
|
||||
#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract)
|
||||
#include "reasampler_editor.h"
|
||||
#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
||||
#include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser
|
||||
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
using namespace Steinberg;
|
||||
using namespace Steinberg::Vst;
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
// Tier-0 fixed instrument shape (Tier 2 makes these editable). A gentle amp envelope so
|
||||
// notes neither click on nor cut off abruptly; sustain at unity (velocity does the
|
||||
// dynamics), a short release for a natural tail. Times are in seconds, converted to
|
||||
// frames against the live sample rate at build time.
|
||||
constexpr std::size_t kMaxVoices = 16;
|
||||
|
||||
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
||||
// materially heavier than a Varispeed voice. Below the Varispeed polyphony bound so a chord of
|
||||
// Preserve notes stays within the RT budget; a Preserve note-on past the cap is dropped rather
|
||||
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
|
||||
// cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling.
|
||||
constexpr std::size_t kPreserveVoiceCap = 8;
|
||||
|
||||
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
|
||||
// any failure — the caller treats an unreadable WAV as "nothing to play".
|
||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return bytes;
|
||||
const std::streamoff size = f.tellg();
|
||||
if (size <= 0) return bytes;
|
||||
f.seekg(0, std::ios::beg);
|
||||
bytes.resize(static_cast<std::size_t>(size));
|
||||
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
|
||||
// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode
|
||||
// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for
|
||||
// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path
|
||||
// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no
|
||||
// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by
|
||||
// the zoned build and the single-capture path so both decode identically for the active mode.
|
||||
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
const std::string& relativePath,
|
||||
ChannelMode mode) {
|
||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||
if (abs.empty()) return std::nullopt;
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid) return std::nullopt;
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
||||
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
|
||||
static_cast<int>(layout.sampleRate));
|
||||
if (out.monoFrames.empty()) return std::nullopt;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
||||
// The host owns the returned reference. Cast up to the combined interface the SDK
|
||||
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
|
||||
return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
|
||||
}
|
||||
|
||||
// Out-of-line so unique_ptr<ReaSamplerEmbed> sees the complete type here.
|
||||
ReaSamplerProcessor::~ReaSamplerProcessor() = default;
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) {
|
||||
// S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for
|
||||
// IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed
|
||||
// shell. We own the shell (unique_ptr); the borrowed reference is valid because the
|
||||
// processor outlives it. All other iids fall through to the SDK's queryInterface.
|
||||
if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) {
|
||||
if (!embed_) embed_ = std::make_unique<ReaSamplerEmbed>(this);
|
||||
embed_->addRef();
|
||||
*obj = static_cast<IReaperUIEmbedInterface*>(embed_.get());
|
||||
return kResultOk;
|
||||
}
|
||||
return SingleComponentEffect::queryInterface(iid, obj);
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
||||
tresult result = SingleComponentEffect::initialize(context);
|
||||
if (result != kResultOk) return result;
|
||||
|
||||
// Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the
|
||||
// instrument still loads, it just has no live bank to play.
|
||||
bridge_.connect(context);
|
||||
|
||||
// Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no
|
||||
// audio input. The output arrangement follows the instance's channel mode (S7) — mono by
|
||||
// default (kMono), stereo (kStereo) when the mode is stereo. addAudioOutput needs an initial
|
||||
// arrangement; seed it at the mode's arrangement so getBusInfo is correct from the first
|
||||
// query. (setState may later flip the mode and re-negotiate via setChannelMode.)
|
||||
addEventInput(STR16("MIDI In"), 16);
|
||||
const ChannelMode mode = channelMode();
|
||||
addAudioOutput(STR16("Audio Out"),
|
||||
mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono);
|
||||
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
||||
// process() is not running at terminate. Free the live instrument and drain the
|
||||
// graveyard. Take the pointer out of the atomic first so nothing else races it.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
delete live_.exchange(nullptr);
|
||||
graveyard_.clear();
|
||||
return SingleComponentEffect::terminate();
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
||||
// Activating: build the instrument from the currently-selected sample so the first
|
||||
// block after activation can play. Deactivating: process is now GUARANTEED stopped by
|
||||
// the host, so this is the safe point to reclaim the graveyard (the displaced engines
|
||||
// no reload could free while active). The build/drain are off the audio thread —
|
||||
// setActive is a main/UI-thread call.
|
||||
if (state) {
|
||||
reloadFromBank();
|
||||
} else {
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
graveyard_.clear();
|
||||
}
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
||||
sampleRate_ = setup.sampleRate;
|
||||
maxBlockSize_ = setup.maxSamplesPerBlock;
|
||||
return SingleComponentEffect::setupProcessing(setup);
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Read the whole component-state blob (the performance map, versioned). The blob is
|
||||
// small; read in one shot into a growable buffer.
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::uint8_t chunk[256];
|
||||
int32 got = 0;
|
||||
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
|
||||
bytes.insert(bytes.end(), chunk, chunk + got);
|
||||
}
|
||||
// Component state (v3, S10) is {single-capture selection id, opt-in zones}. The
|
||||
// selection and the zones are DISTINCT — the default face is one picked capture, zones
|
||||
// are a demoted overlay — so both are restored explicitly (no more inferring a selection
|
||||
// from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only
|
||||
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
|
||||
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
|
||||
// silent empty state (no first-sample fallback in reloadFromBank).
|
||||
// Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at
|
||||
// the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing
|
||||
// before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a
|
||||
// pre-setup call would assert inside readZonesPayload (a programming error, not a field case).
|
||||
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
||||
setSelectedSampleId(cs.selectionId);
|
||||
setPerformanceMap(cs.map);
|
||||
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||
// stale assign_request (the user may have manually changed the selection after the assign).
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration;
|
||||
}
|
||||
// Restore the S7 channel mode and point the output bus at its arrangement so a reopened
|
||||
// project comes back in the saved mode. setState runs before the host queries bus info, so
|
||||
// seeding the arrangement here (rather than re-negotiating) is enough — no restartComponent.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
channelMode_ = cs.channelMode;
|
||||
}
|
||||
applyOutputArrangement(cs.channelMode);
|
||||
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
||||
reloadFromBank();
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Persist the full instance state (v3, S10): the single-capture selection id AND the
|
||||
// opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler"
|
||||
// bank ext-state. An instance with no pick and no zones serializes to {"", no zones}
|
||||
// and restores as the S10 empty state (silence + "pick a capture"), never auto-playing
|
||||
// sample #1.
|
||||
ComponentState state_out;
|
||||
state_out.selectionId = selectedSampleId();
|
||||
state_out.map = performanceMap();
|
||||
state_out.channelMode = channelMode(); // S7: persist the per-instance mono/stereo mode
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
|
||||
}
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
|
||||
if (!bytes.empty()) {
|
||||
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||
static_cast<int32>(bytes.size()), nullptr);
|
||||
if (wr != kResultOk) return wr;
|
||||
}
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
std::string ReaSamplerProcessor::selectedSampleId() {
|
||||
std::lock_guard<std::mutex> lock(selectionMutex_);
|
||||
return selectedSampleId_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
|
||||
std::lock_guard<std::mutex> lock(selectionMutex_);
|
||||
selectedSampleId_ = id;
|
||||
}
|
||||
|
||||
PerformanceMap ReaSamplerProcessor::performanceMap() {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
return performanceMap_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
performanceMap_ = map;
|
||||
}
|
||||
|
||||
ChannelMode ReaSamplerProcessor::channelMode() {
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
return channelMode_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::applyOutputArrangement(ChannelMode mode) {
|
||||
// Set the single output bus's SpeakerArrangement to the mode's arrangement so getBusInfo /
|
||||
// getBusArrangement report the right channel count. The default getBusArrangement (from the
|
||||
// base) reads back exactly what we store here. No re-negotiation — the caller drives that.
|
||||
BusList* outs = getBusList(kAudio, kOutput);
|
||||
if (!outs || outs->empty()) return;
|
||||
if (auto* bus = FCast<AudioBus>(outs->at(0))) {
|
||||
bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo
|
||||
: SpeakerArr::kMono);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
if (channelMode_ == mode) return; // no-op: don't churn the bus / re-negotiate
|
||||
channelMode_ = mode;
|
||||
}
|
||||
// The mode changed: repoint the output bus and ask the host to re-negotiate I/O so REAPER's
|
||||
// routing follows (mono<->stereo). restartComponent is a main/UI-thread call; setChannelMode
|
||||
// is driven from the editor, so this is safe. Then reload so the next block decodes the new
|
||||
// channel count into the LoadedInstrument (off-thread, RT path untouched).
|
||||
applyOutputArrangement(mode);
|
||||
if (componentHandler) componentHandler->restartComponent(kIoChanged);
|
||||
reloadFromBank();
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
|
||||
SpeakerArrangement* inputs, int32 numIns,
|
||||
SpeakerArrangement* outputs, int32 numOuts) {
|
||||
// The instrument has ONE canonical arrangement per its channel mode (S7). We take NO audio
|
||||
// input, so any inputs are rejected. For the single output bus: accept (kResultTrue) only
|
||||
// when the host proposes exactly the mode's arrangement; otherwise reject (kResultFalse) but
|
||||
// KEEP the mode's arrangement (per the VST3 contract, a plug-in that can't honor a proposal
|
||||
// keeps a valid arrangement of its own). getBusArrangement then still reports the mode's
|
||||
// channel count, so the host adapts its routing to us rather than forcing our channel count.
|
||||
if (numIns < 0 || numOuts < 0) return kInvalidArgument;
|
||||
if (numIns > 0) return kResultFalse; // no audio input bus to arrange
|
||||
|
||||
const SpeakerArrangement want =
|
||||
channelMode() == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono;
|
||||
applyOutputArrangement(channelMode()); // keep the bus pinned to the mode's arrangement
|
||||
if (numOuts == 1 && outputs && outputs[0] == want) return kResultTrue;
|
||||
return kResultFalse;
|
||||
}
|
||||
|
||||
std::string ReaSamplerProcessor::reloadFromBank() {
|
||||
// OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so
|
||||
// the retired-slot free is single-writer. This mutex is NEVER taken on the audio
|
||||
// thread — process() only touches the atomic.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
|
||||
// Mint this reload's generation number first so we can stamp the built instrument
|
||||
// with it before publishing. Under reloadMutex_ no other reload races here.
|
||||
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
|
||||
// 1. Read the live bank + resolve the project dir over the bridge (allocates,
|
||||
// calls REAPER — fine here, off-thread).
|
||||
std::optional<std::string> banksJson =
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
||||
const std::string projectDir = bridge_.activeProjectDir();
|
||||
// The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
|
||||
// Read once under its mutex, off the audio thread, before the decode loop.
|
||||
const ChannelMode mode = channelMode();
|
||||
|
||||
std::string resolvedId;
|
||||
std::unique_ptr<LoadedInstrument> built;
|
||||
|
||||
if (banksJson) {
|
||||
// 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
|
||||
// zones against the live bank (STALE ids drop cleanly), decode each zone's WAV
|
||||
// off-thread, and build the ZONED keymap. Each surviving zone plays its bank
|
||||
// sample repitched from its effective root note (override > bank intrinsic > C4).
|
||||
// A zone whose WAV fails to decode is dropped (not the whole map).
|
||||
const PerformanceMap map = performanceMap();
|
||||
Keymap km;
|
||||
bool haveKeymap = false;
|
||||
|
||||
if (!map.empty()) {
|
||||
const ResolvedPerformance resolved = resolvePerformance(*banksJson, map);
|
||||
if (!resolved.zones.empty()) {
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
std::vector<ResolvedZone> kept;
|
||||
decoded.reserve(resolved.zones.size());
|
||||
kept.reserve(resolved.zones.size());
|
||||
for (const ResolvedZone& rz : resolved.zones) {
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, rz.relativePath, mode);
|
||||
if (!pcm) continue; // unreadable WAV -> drop this zone
|
||||
kept.push_back(rz);
|
||||
decoded.push_back(std::move(*pcm));
|
||||
}
|
||||
km = buildZonedKeymap(kept, decoded);
|
||||
haveKeymap = !km.zones.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Single-capture fast path (S10): an empty performance map plays the ONE
|
||||
// deliberately-selected capture chromatically across the whole keyboard. This is
|
||||
// the default face — one picked capture, repitched from its root. NO first-
|
||||
// sample fallback: an EMPTY selection (or a stale id) resolves to nullopt in
|
||||
// selectSample, so an un-picked instrument stays SILENT (the editor shows its
|
||||
// "pick a capture" empty state) rather than auto-playing sample #1 (S10 policy
|
||||
// reversal of the S4 convenience default).
|
||||
if (!haveKeymap) {
|
||||
std::optional<SelectedSample> sel =
|
||||
selectSample(*banksJson, selectedSampleId());
|
||||
if (sel) {
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, sel->relativePath, mode);
|
||||
if (pcm) {
|
||||
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
|
||||
sel->rootNote, sel->loop,
|
||||
std::move(pcm->framesR));
|
||||
haveKeymap = true;
|
||||
resolvedId = selectedSampleId(); // the concrete pick that resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (haveKeymap) {
|
||||
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
|
||||
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
|
||||
// note-on never allocates. Floored at 2 so a valid window is always a real ring
|
||||
// (which also covers a pathological host rate <= 0 — no rate literal needed).
|
||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), kMaxVoices, gen, kPreserveVoiceCap,
|
||||
preserveWindow);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the
|
||||
// graveyard tagged with this generation (process may still be mid-block reading
|
||||
// it). A null `built` (no bank / unreadable WAV) installs silence.
|
||||
// `built` is heap-owned; release() hands ownership to the atomic, and the
|
||||
// exchanged pointer is re-owned by the graveyard.
|
||||
//
|
||||
// Bounded reclaim: prune graveyard entries where displacedAt <= seen, where seen
|
||||
// is the last generation process() published. process() publishes inst->installedAt
|
||||
// (not a re-read of reloadGeneration_), so seen == D means process holds the
|
||||
// instrument installed at gen D. An entry with displacedAt == D was displaced by
|
||||
// reload D, which installed that very successor — process cannot be holding the
|
||||
// displaced entry. The pruning condition is therefore <= (see header for the full
|
||||
// proof). Remaining entries drain at setActive(false) / terminate() when process
|
||||
// is guaranteed stopped.
|
||||
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
||||
graveyard_.erase(
|
||||
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
||||
[seen](const GraveyardEntry& e) { return e.displacedAt <= seen; }),
|
||||
graveyard_.end());
|
||||
LoadedInstrument* prev = live_.exchange(built.release());
|
||||
if (prev) graveyard_.push_back({gen, std::unique_ptr<LoadedInstrument>(prev)});
|
||||
return resolvedId;
|
||||
}
|
||||
|
||||
ReaSamplerProcessor::BankSyncResult
|
||||
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
// OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call
|
||||
// REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER
|
||||
// host, or before connect) yields nullopt for both reads, so this no-ops cleanly.
|
||||
BankSyncResult result;
|
||||
|
||||
// --- S8: assignment-request consume FIRST -------------------------------------
|
||||
// Decode the pending assignment request (nullopt when absent/malformed). Resolve its
|
||||
// (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when
|
||||
// the sampleId names an existing sample (the reader requirement — an unresolvable pair is
|
||||
// dropped). Then run the pure consume decision against this instance's persisted marker.
|
||||
std::optional<AssignmentRequest> request;
|
||||
if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) {
|
||||
request = decodeAssignmentRequest(*raw);
|
||||
}
|
||||
|
||||
bool resolves = false;
|
||||
if (request) {
|
||||
// Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request
|
||||
// whose sample was rolled back by an extension undo resolves to nullopt -> dropped).
|
||||
if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) {
|
||||
resolves = selectSample(*banksJson, request->sampleId).has_value();
|
||||
}
|
||||
}
|
||||
|
||||
// Read lastConsumed and conditionally write it back under a single lock scope so there
|
||||
// is no interleave window between the read and the write (a concurrent getState could
|
||||
// otherwise observe a stale marker between the two separate lock acquisitions).
|
||||
std::int64_t lastConsumed = 0;
|
||||
const AssignConsumeDecision decision = [&] {
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
lastConsumed = lastConsumedAssignGeneration_;
|
||||
const AssignConsumeDecision d =
|
||||
consumeDecision(request, lastConsumed, resolves, isFocusedTarget);
|
||||
// Advance the persisted consumed marker whenever the decision consumed the request
|
||||
// (applied OR dropped-as-seen). getState will persist it on the next project save so
|
||||
// a re-open does not re-apply. A non-target instance leaves the marker (decision
|
||||
// returns it unchanged) so it stays eligible if focus later lands here.
|
||||
if (d.consumedGeneration != lastConsumed) {
|
||||
lastConsumedAssignGeneration_ = d.consumedGeneration;
|
||||
}
|
||||
return d;
|
||||
}();
|
||||
|
||||
if (decision.apply) {
|
||||
// Apply the assignment as this instance's own selection (the same path a user card-pick
|
||||
// takes) — the instrument updates its OWN state, never the bank. reloadFromBank below
|
||||
// rebuilds against the new selection, so skip a redundant reload here.
|
||||
setSelectedSampleId(decision.sampleId);
|
||||
result.applied = true;
|
||||
}
|
||||
|
||||
// --- S9: bank-generation change-detection -------------------------------------
|
||||
// Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll
|
||||
// (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — setState
|
||||
// already loaded the current bank, so a redundant reload on open would only churn. A later
|
||||
// generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the
|
||||
// reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced).
|
||||
std::int64_t currentGen = kBankGenerationAbsent;
|
||||
if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) {
|
||||
currentGen = parseBankGeneration(*rawGen);
|
||||
}
|
||||
const bool firstPoll = (lastSeenBankGeneration_ < 0);
|
||||
const bool genChanged =
|
||||
!firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen);
|
||||
lastSeenBankGeneration_ = currentGen;
|
||||
|
||||
if (genChanged || result.applied) {
|
||||
reloadFromBank(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
|
||||
result.reloaded = genChanged; // report S9 vs S8 distinctly for the editor's reaction
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
// REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the
|
||||
// whole block (a single atomic acquire), then publish inst->installedAt so the off-
|
||||
// thread graveyard pruner knows exactly which generation this block is holding.
|
||||
//
|
||||
// We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an
|
||||
// ordering race: reading reloadGeneration_ after live_ could observe a generation
|
||||
// newer than the pointer we actually hold, causing the pruner to free an instrument
|
||||
// process is still reading. installedAt was set on the reload path before the atomic
|
||||
// exchange that made the instrument visible, so it is always <= the generation of any
|
||||
// instrument that could have been loaded after our acquire above.
|
||||
LoadedInstrument* inst = live_.load(std::memory_order_acquire);
|
||||
const std::uint64_t heldGen = inst ? inst->installedAt : 0;
|
||||
processGeneration_.store(heldGen, std::memory_order_release);
|
||||
|
||||
// Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps
|
||||
// events at block granularity (no per-event sample-offset split) — audible timing is
|
||||
// within one block, adequate for Tier 0; sample-accurate scheduling is a later tier.
|
||||
if (inst && data.inputEvents) {
|
||||
const int32 count = data.inputEvents->getEventCount();
|
||||
for (int32 i = 0; i < count; ++i) {
|
||||
Event e;
|
||||
if (data.inputEvents->getEvent(i, e) != kResultOk) continue;
|
||||
if (e.type == Event::kNoteOnEvent) {
|
||||
// A note-on with velocity 0 is a note-off by MIDI convention.
|
||||
const int vel = static_cast<int>(e.noteOn.velocity * 127.0f + 0.5f);
|
||||
if (vel <= 0) {
|
||||
inst->engine.noteOff(e.noteOn.pitch);
|
||||
} else {
|
||||
inst->engine.noteOn(e.noteOn.pitch, vel);
|
||||
}
|
||||
} else if (e.type == Event::kNoteOffEvent) {
|
||||
inst->engine.noteOff(e.noteOff.pitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) {
|
||||
embedPeak_.store(0.f, std::memory_order_relaxed);
|
||||
return kResultOk;
|
||||
}
|
||||
AudioBusBuffers& out = data.outputs[0];
|
||||
const int32 frames = data.numSamples;
|
||||
|
||||
// 64-bit host processing is not supported by the mono float core; emit silence
|
||||
// rather than mis-render. REAPER runs 32-bit float by default.
|
||||
if (data.symbolicSampleSize != kSample32) {
|
||||
embedPeak_.store(0.f, std::memory_order_relaxed);
|
||||
for (int32 ch = 0; ch < out.numChannels; ++ch) {
|
||||
if (double* buf = out.channelBuffers64[ch]) {
|
||||
for (int32 i = 0; i < frames; ++i) buf[i] = 0.0;
|
||||
}
|
||||
}
|
||||
out.silenceFlags = (out.numChannels >= 64)
|
||||
? ~0ULL
|
||||
: ((1ULL << out.numChannels) - 1);
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
// Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked
|
||||
// into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here
|
||||
// we simply match the buffers the host handed us: >=2 channels -> true stereo render into
|
||||
// ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the
|
||||
// render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here.
|
||||
float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
|
||||
float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr;
|
||||
if (ch0 && ch1) {
|
||||
// Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo
|
||||
// path (both channels equal), so a mono capture in stereo mode is centered, not silent.
|
||||
for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; }
|
||||
if (inst) {
|
||||
inst->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
|
||||
}
|
||||
// Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
|
||||
for (int32 ch = 2; ch < out.numChannels; ++ch) {
|
||||
if (float* buf = out.channelBuffers32[ch]) {
|
||||
for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
|
||||
}
|
||||
}
|
||||
// Block peak (max across L/R) for the embed strip's level indicator; RT-safe.
|
||||
float peak = 0.f;
|
||||
for (int32 i = 0; i < frames; ++i) {
|
||||
const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i];
|
||||
const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i];
|
||||
if (a0 > peak) peak = a0;
|
||||
if (a1 > peak) peak = a1;
|
||||
}
|
||||
embedPeak_.store(peak, std::memory_order_relaxed);
|
||||
} else if (ch0) {
|
||||
// Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel;
|
||||
// the replicate is defensive for a host that still hands >1 channel on a mono bus).
|
||||
for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
|
||||
if (inst) {
|
||||
inst->engine.render(ch0, static_cast<std::size_t>(frames));
|
||||
}
|
||||
float peak = 0.f;
|
||||
for (int32 i = 0; i < frames; ++i) {
|
||||
const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i];
|
||||
if (a > peak) peak = a;
|
||||
}
|
||||
embedPeak_.store(peak, std::memory_order_relaxed);
|
||||
for (int32 ch = 1; ch < out.numChannels; ++ch) {
|
||||
if (float* buf = out.channelBuffers32[ch]) {
|
||||
for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report silence only when nothing is loaded (lets the host optimize when idle).
|
||||
// With an instrument loaded we clear the flag so a ringing voice is not skipped.
|
||||
out.silenceFlags = inst ? 0 : ((out.numChannels >= 64)
|
||||
? ~0ULL
|
||||
: ((1ULL << out.numChannels) - 1));
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) {
|
||||
if (name && FIDStringsEqual(name, ViewType::kEditor)) {
|
||||
return new ReaSamplerEditor(this);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,272 @@
|
||||
// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the
|
||||
// pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio
|
||||
// output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the
|
||||
// engine's audio into the output bus — so a chosen bank sample plays chromatically from
|
||||
// its root note in REAPER's routing/record/render path.
|
||||
//
|
||||
// SingleComponentEffect is the SDK's combined processor+controller base — sanctioned
|
||||
// for a non-distributable, REAPER-only plugin under D5/D6. It gives us
|
||||
// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own
|
||||
// state (the selected sample), and the IEditController seat so createView() can hand the
|
||||
// host our IPlugView LICE editor.
|
||||
//
|
||||
// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO
|
||||
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — bridge ext-state
|
||||
// read, WAV decode, path resolve, keymap build, VoiceEngine construction — all happens
|
||||
// OFF the audio thread (reloadFromBank, driven from the main/UI thread) and is handed to
|
||||
// process via a single atomic pointer swap. See the LoadedInstrument handoff below.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
|
||||
|
||||
#include "reaper_bridge.h"
|
||||
#include "sample_map.h" // PerformanceMap (the instrument's owned zoned keymap)
|
||||
#include "sampler_core.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
|
||||
// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice
|
||||
// engine that plays it. The engine holds a reference into the keymap, so the two MUST
|
||||
// live and die together at a STABLE address — hence this is heap-allocated and neither
|
||||
// copyable nor movable. The audio thread only ever reads it through an atomic pointer;
|
||||
// it is built and destroyed off the audio thread.
|
||||
//
|
||||
// installedAt: the reloadGeneration_ value at which this instrument was atomically
|
||||
// installed into live_. Set on the reload path before the exchange. process() publishes
|
||||
// this field (not a fresh re-read of reloadGeneration_) so the published generation is
|
||||
// exactly the generation of the instrument actually in hand for the block.
|
||||
struct LoadedInstrument {
|
||||
Keymap keymap;
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reload generation at which this was installed
|
||||
|
||||
LoadedInstrument(Keymap km, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0)
|
||||
: keymap(std::move(km)),
|
||||
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames),
|
||||
installedAt(gen) {}
|
||||
|
||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
|
||||
};
|
||||
|
||||
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
|
||||
public:
|
||||
ReaSamplerProcessor() = default;
|
||||
// Out-of-line so the owned ReaSamplerEmbed (held by unique_ptr, forward-declared here)
|
||||
// is a complete type at the destruction point (defined in the .cpp).
|
||||
~ReaSamplerProcessor() override;
|
||||
|
||||
// The factory create function (registered in vst_entry.cpp).
|
||||
static Steinberg::FUnknown* createInstance(void* /*context*/);
|
||||
|
||||
//--- from IComponent / IPluginBase -------------------------------------
|
||||
// Connects the REAPER bridge (context is REAPER's IHostApplication) and declares
|
||||
// the instrument bus topology.
|
||||
Steinberg::tresult PLUGIN_API initialize(Steinberg::FUnknown* context) override;
|
||||
Steinberg::tresult PLUGIN_API terminate() override;
|
||||
Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override;
|
||||
|
||||
// Instance state = the selected bank sample id (D-B: a performance choice the
|
||||
// instrument owns; NEVER written back to the bank). Component-state, so a saved
|
||||
// REAPER project restores which sample each instance plays.
|
||||
Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override;
|
||||
Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override;
|
||||
|
||||
//--- from IAudioProcessor ----------------------------------------------
|
||||
Steinberg::tresult PLUGIN_API setupProcessing(
|
||||
Steinberg::Vst::ProcessSetup& setup) override;
|
||||
// Marshals MIDI -> VoiceEngine -> audio output. Real-time safe (no alloc/IO/lock).
|
||||
Steinberg::tresult PLUGIN_API process(
|
||||
Steinberg::Vst::ProcessData& data) override;
|
||||
|
||||
// S7 channel-mode bus negotiation. The instrument has ONE canonical output arrangement
|
||||
// determined by its per-instance channel mode (mono -> kMono, stereo -> kStereo). We
|
||||
// accept the host's proposal only when it matches that arrangement; otherwise we reject
|
||||
// (kResultFalse) but keep the mode's arrangement, so getBusArrangement / getBusInfo always
|
||||
// report the mode's channel count and REAPER routes accordingly. A runtime mode change
|
||||
// updates the output bus + calls restartComponent(kIoChanged) to trigger re-negotiation.
|
||||
Steinberg::tresult PLUGIN_API setBusArrangements(
|
||||
Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns,
|
||||
Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override;
|
||||
|
||||
//--- from IEditController -----------------------------------------------
|
||||
// Hands the host our LICE IPlugView editor.
|
||||
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
||||
|
||||
// Override queryInterface to additionally expose REAPER's IReaperUIEmbedInterface (S6):
|
||||
// REAPER queries the IEditController for it to drive the inline TCP/MCP embed surface.
|
||||
// All other iids delegate to SingleComponentEffect's implementation unchanged.
|
||||
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
|
||||
void** obj) override;
|
||||
|
||||
// The embedded-strip activity level (0..1), read by the S6 embed shell on the UI thread.
|
||||
// Backed by embedPeak_, the per-block mono peak the audio thread stores relaxed — a
|
||||
// lock-free advisory readout, never touched with a lock the audio thread could contend.
|
||||
double embedActivityLevel() const {
|
||||
return static_cast<double>(embedPeak_.load(std::memory_order_relaxed));
|
||||
}
|
||||
|
||||
// Called by the editor (main/UI thread) when the user picks a sample, and internally
|
||||
// on load. Reads the live bank over the bridge, resolves+decodes the selected WAV
|
||||
// OFF the audio thread, and publishes the built instrument to process() via an
|
||||
// atomic swap. Safe to call with no bridge / no bank (leaves silence). Returns the
|
||||
// resolved selection id ("" if nothing was loaded) for the editor to reflect.
|
||||
std::string reloadFromBank();
|
||||
|
||||
// The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor
|
||||
// can react (repaint / re-snapshot its own view) only when something actually changed.
|
||||
struct BankSyncResult {
|
||||
bool reloaded = false; // the bank generation changed -> reloadFromBank ran
|
||||
bool applied = false; // a new assignment request was applied -> selection changed
|
||||
};
|
||||
|
||||
// Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF
|
||||
// THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). Semantics:
|
||||
// * S9: if the bank generation differs from what we last saw, call reloadFromBank() so a
|
||||
// recapture/ingest refreshes playback hands-free (atomic swap, glitch-free).
|
||||
// * S8: if a NEW (generation > last consumed) assignment request names a resolvable
|
||||
// sample AND this instance is the target (isFocusedTarget), apply it as the selection
|
||||
// and reload; an unresolvable request is DROPPED silently (marker advanced, no change);
|
||||
// a non-target instance neither applies nor advances its marker.
|
||||
// The consumed marker advances in component state (marked dirty via the host handler) so a
|
||||
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
|
||||
// (the editor passes true only for the instance whose editor is open — see the handoff).
|
||||
// Idempotent on an idle tick (generation unchanged + no new request -> no work).
|
||||
BankSyncResult pollBankSync(bool isFocusedTarget);
|
||||
|
||||
// The bridge, for the editor's live-state readout + sample list. Owned here; the
|
||||
// editor borrows it (outlives the editor).
|
||||
ReaperBridge& bridge() { return bridge_; }
|
||||
// The current single-capture selection id (main/UI thread reads for the editor). Guarded
|
||||
// by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked
|
||||
// capture the default face plays chromatically when the performance map is empty; an EMPTY
|
||||
// id resolves to SILENCE (no first-sample fallback). A non-empty zoned map supersedes it.
|
||||
std::string selectedSampleId();
|
||||
void setSelectedSampleId(const std::string& id);
|
||||
|
||||
// The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written
|
||||
// by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the
|
||||
// audio thread — reloadFromBank bakes it into the LoadedInstrument's Keymap off-thread.
|
||||
PerformanceMap performanceMap();
|
||||
void setPerformanceMap(const PerformanceMap& map);
|
||||
|
||||
// The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread
|
||||
// (the editor toggle) and read off-thread by getState/reloadFromBank; guarded by
|
||||
// channelModeMutex_. NEVER read on the audio thread — process() renders against the host's
|
||||
// negotiated output channel count, and reloadFromBank bakes the mode into the decode.
|
||||
ChannelMode channelMode();
|
||||
// Sets the mode. When it CHANGES, updates the output bus arrangement (mono->kMono /
|
||||
// stereo->kStereo) and asks the host to re-negotiate I/O via restartComponent(kIoChanged),
|
||||
// then reloads the instrument so the next block decodes the new channel count. A no-op set
|
||||
// (same mode) does neither. UI thread only.
|
||||
void setChannelMode(ChannelMode mode);
|
||||
|
||||
private:
|
||||
// Apply `mode` to the output audio bus's SpeakerArrangement (kMono / kStereo). Called from
|
||||
// initialize (topology) and setChannelMode (runtime change). Does NOT re-negotiate — the
|
||||
// caller drives restartComponent when appropriate.
|
||||
void applyOutputArrangement(ChannelMode mode);
|
||||
|
||||
ReaperBridge bridge_;
|
||||
|
||||
// --- The audio-thread handoff (S4 real-time discipline) -----------------
|
||||
// process() atomically loads `live_` at block start and marshals/renders against it —
|
||||
// a single atomic acquire, no lock, no free on the audio thread.
|
||||
//
|
||||
// reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new
|
||||
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
|
||||
// NOT freed on the reload path: process() may still be mid-block reading it, and two
|
||||
// rapid reloads could otherwise free a pointer process is using. Instead it is parked
|
||||
// in `graveyard_` tagged with the reload generation at which it was displaced.
|
||||
//
|
||||
// Bounded reclaim: process() publishes inst->installedAt (the generation at which the
|
||||
// held instrument was installed) via processGeneration_ — a single atomic store, RT-
|
||||
// safe. The reload path prunes graveyard entries where displacedAt <= seen (where seen
|
||||
// is the last published processGeneration_).
|
||||
//
|
||||
// Safety argument: an entry with displacedAt == D was displaced by reload D, which
|
||||
// simultaneously installed its successor with installedAt == D. process() publishing
|
||||
// seen == D means it holds that successor (or a later one). In either case, the
|
||||
// displaced entry is not the pointer process is using, so freeing it is safe. The
|
||||
// pruning condition is therefore <= (not strict <): an entry displaced at exactly the
|
||||
// published generation is also provably unreachable.
|
||||
//
|
||||
// The graveyard's upper bound is the number of reloads since process last ran
|
||||
// (typically 0–1 in normal use). Remaining entries drain at setActive(false) /
|
||||
// terminate(), when the host guarantees process is stopped.
|
||||
std::atomic<LoadedInstrument*> live_{nullptr};
|
||||
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
|
||||
std::atomic<std::uint64_t> processGeneration_{0}; // generation last seen by process (written on audio thread, read off-thread)
|
||||
struct GraveyardEntry {
|
||||
std::uint64_t displacedAt = 0; // reloadGeneration_ value when this was displaced
|
||||
std::unique_ptr<LoadedInstrument> instrument;
|
||||
};
|
||||
std::vector<GraveyardEntry> graveyard_; // drained on reclaim + setActive(false) + terminate
|
||||
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||
|
||||
// The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence).
|
||||
// Off-thread only; a small mutex guards the string against a getState/editor race. NOT
|
||||
// read on the audio thread.
|
||||
std::mutex selectionMutex_;
|
||||
std::string selectedSampleId_;
|
||||
|
||||
// The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only;
|
||||
// guarded against a getState/editor race. NOT read on the audio thread — reloadFromBank
|
||||
// bakes it into the LoadedInstrument's Keymap under the reload lock.
|
||||
std::mutex performanceMutex_;
|
||||
PerformanceMap performanceMap_;
|
||||
|
||||
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadFromBank);
|
||||
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
|
||||
// on the audio thread — process renders against the host's negotiated output channel count.
|
||||
std::mutex channelModeMutex_;
|
||||
ChannelMode channelMode_ = ChannelMode::Mono;
|
||||
|
||||
// The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in
|
||||
// component state (v5) so a re-open does not re-apply a request the user already got and
|
||||
// then changed away from. Written by pollBankSync (UI/timer thread) and getState; read by
|
||||
// pollBankSync + getState; seeded by setState. Guarded against a getState/poll race. NEVER
|
||||
// read on the audio thread. Default 0 -> a genuinely new first assign (gen >= 1) applies.
|
||||
std::mutex assignMarkerMutex_;
|
||||
std::int64_t lastConsumedAssignGeneration_ = 0;
|
||||
|
||||
// The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync
|
||||
// is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a
|
||||
// -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the
|
||||
// FIRST poll after an editor open BASELINES the seen value without a redundant reload (setState
|
||||
// already loaded the current bank); a subsequent generation CHANGE then drives the reload.
|
||||
// NOT read on the audio thread.
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
|
||||
// Latched from setupProcessing so setActive/reload can size against it. Read
|
||||
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
|
||||
// before any audio, and reloadFromBank guards on it before use.
|
||||
double sampleRate_ = 0.0;
|
||||
Steinberg::int32 maxBlockSize_ = 4096;
|
||||
|
||||
// --- S6 embedded TCP/MCP UI ---------------------------------------------
|
||||
// The embed shell (IReaperUIEmbedInterface), created lazily on the first queryInterface
|
||||
// and owned here for the processor's lifetime. REAPER borrows AddRef'd references from
|
||||
// queryInterface; the shell's refcount is a no-op because THIS unique_ptr governs its
|
||||
// destruction (the processor always outlives the borrowed references).
|
||||
std::unique_ptr<ReaSamplerEmbed> embed_;
|
||||
|
||||
// The per-block mono peak (0..1+) the audio thread stores relaxed; the embed strip's
|
||||
// level indicator reads it via embedActivityLevel(). Advisory only — a plain atomic,
|
||||
// no ordering coupling, never guarded by a lock the audio thread touches.
|
||||
std::atomic<float> embedPeak_{0.f};
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -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
|
||||
@@ -0,0 +1,640 @@
|
||||
// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL /
|
||||
// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core.
|
||||
|
||||
#include "sample_map.h"
|
||||
|
||||
#include <algorithm> // std::min
|
||||
#include <cassert> // assert
|
||||
#include <cstring> // std::memcpy
|
||||
#include <utility> // std::move
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
|
||||
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
|
||||
// SampleLoop with an explicit hasLoop. Absent -> no loop.
|
||||
SampleLoop loopFromSample(const Sample& s) {
|
||||
SampleLoop out;
|
||||
if (s.loop) {
|
||||
out.hasLoop = true;
|
||||
out.start = s.loop->start;
|
||||
out.end = s.loop->end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
|
||||
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
|
||||
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
|
||||
SelectedSample distill(const Sample& s) {
|
||||
SelectedSample out;
|
||||
out.relativePath = s.relativePath;
|
||||
out.rootNote = s.rootNote ? *s.rootNote : 60;
|
||||
out.loop = loopFromSample(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
const std::string& sampleId) {
|
||||
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
|
||||
// circuit before parsing — no stored id resolves to nothing to play by design.
|
||||
if (sampleId.empty()) return std::nullopt;
|
||||
if (banksJson.empty()) return std::nullopt;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
|
||||
|
||||
// Search every bank (pool first, then named — banks() is ordinal order) for the
|
||||
// stored id. A sample lives in exactly one bank, so first hit wins.
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(sampleId)) {
|
||||
return distill(*s);
|
||||
}
|
||||
}
|
||||
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
|
||||
// the editor reflects the missing pick with its empty state rather than masking it.
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
|
||||
std::vector<SampleChoice> out;
|
||||
if (banksJson.empty()) return out;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out;
|
||||
for (const Bank& b : book->banks()) {
|
||||
for (const Sample& s : b.index.all()) {
|
||||
out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<BankChoice> listBanks(const std::string& banksJson) {
|
||||
std::vector<BankChoice> out;
|
||||
if (banksJson.empty()) return out;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out;
|
||||
for (const Bank& b : book->banks()) {
|
||||
out.push_back(BankChoice{b.id, b.displayName});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount) {
|
||||
std::vector<AudioSample> out;
|
||||
if (channelCount <= 0 || interleaved.empty()) return out;
|
||||
const std::size_t stride = static_cast<std::size_t>(channelCount);
|
||||
const std::size_t frames = interleaved.size() / stride;
|
||||
out.resize(frames);
|
||||
const double inv = 1.0 / static_cast<double>(channelCount);
|
||||
for (std::size_t f = 0; f < frames; ++f) {
|
||||
double acc = 0.0;
|
||||
const std::size_t base = f * stride;
|
||||
for (std::size_t c = 0; c < stride; ++c) {
|
||||
acc += static_cast<double>(interleaved[base + c]);
|
||||
}
|
||||
out[f] = static_cast<AudioSample>(acc * inv);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount, int which) {
|
||||
std::vector<AudioSample> out;
|
||||
if (channelCount <= 0 || interleaved.empty()) return out;
|
||||
const std::size_t stride = static_cast<std::size_t>(channelCount);
|
||||
// Clamp the requested channel into the source's range: a channel past the last one reads
|
||||
// the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono).
|
||||
std::size_t ch = which < 0 ? 0 : static_cast<std::size_t>(which);
|
||||
if (ch >= stride) ch = stride - 1;
|
||||
const std::size_t frames = interleaved.size() / stride;
|
||||
out.resize(frames);
|
||||
for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch];
|
||||
return out;
|
||||
}
|
||||
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
|
||||
DecodedZonePcm out;
|
||||
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
||||
out.sampleRate = sampleRate;
|
||||
if (mode == ChannelMode::Mono) {
|
||||
// MONO mode: the existing downmix policy (average all source channels), one channel out.
|
||||
out.monoFrames = downmixToMono(interleaved, sourceChannels);
|
||||
return out; // framesR stays empty
|
||||
}
|
||||
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
|
||||
// duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
|
||||
// out-of-range channel request to the last channel, so a mono source yields L == R.
|
||||
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
|
||||
out.framesR = extractChannel(interleaved, sourceChannels, 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
|
||||
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
|
||||
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
|
||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
const auto secToFrames = [sr](double sec) {
|
||||
double f = sec * sr;
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
ZonePlayParams out;
|
||||
out.playMode = stored.playMode;
|
||||
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
||||
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
|
||||
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
|
||||
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
|
||||
out.trigger = stored.trigger; // source-frame / fraction, unchanged
|
||||
out.pitchEngine = stored.pitchEngine;
|
||||
out.pitchEnv.enabled = stored.pitchEnv.enabled;
|
||||
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
||||
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
||||
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
||||
return out;
|
||||
}
|
||||
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
|
||||
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
|
||||
SampleData data;
|
||||
data.frames = std::move(frames);
|
||||
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
||||
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
|
||||
if (!framesR.empty() && framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(framesR);
|
||||
}
|
||||
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
|
||||
data.sampleRate = sampleRate;
|
||||
data.rootNote = rootNote;
|
||||
data.loop = loop;
|
||||
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
|
||||
data.play = resolvePlay(play, data.sampleRate);
|
||||
|
||||
return Keymap::singleSampleChromatic(std::move(data));
|
||||
}
|
||||
|
||||
// --- Performance map ---------------------------------------------------------
|
||||
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
|
||||
if (banksJson.empty()) return out; // no bank -> nothing resolves
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out; // malformed -> nothing (never throw)
|
||||
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// Look the id up across every bank (pool + named) — a sample lives in exactly
|
||||
// one bank, so first hit wins.
|
||||
const Sample* found = nullptr;
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(z.sampleId)) {
|
||||
found = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune).
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
continue;
|
||||
}
|
||||
ResolvedZone rz;
|
||||
rz.relativePath = found->relativePath;
|
||||
rz.lowNote = z.lowNote;
|
||||
rz.highNote = z.highNote;
|
||||
// Effective root: override beats bank intrinsic beats middle-C default.
|
||||
rz.rootNote = z.rootOverride ? *z.rootOverride
|
||||
: (found->rootNote ? *found->rootNote : 60);
|
||||
// Effective loop / start (S11): the instrument's per-zone override wins over the
|
||||
// bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is
|
||||
// never mutated — this only shapes what the core plays for THIS instance (D-B).
|
||||
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
|
||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||
// S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument
|
||||
// state, not resolved against the bank); buildZonedKeymap resolves them to frames.
|
||||
rz.play = z.play;
|
||||
out.zones.push_back(std::move(rz));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded) {
|
||||
Keymap km;
|
||||
const std::size_t n = std::min(zones.size(), decoded.size());
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
// An unreadable/empty WAV drops just this zone (not the whole map).
|
||||
if (decoded[i].monoFrames.empty()) continue;
|
||||
SampleData data;
|
||||
data.frames = decoded[i].monoFrames;
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded[i].framesR.empty() &&
|
||||
decoded[i].framesR.size() == data.frames.size()) {
|
||||
data.framesR = decoded[i].framesR;
|
||||
}
|
||||
assert(decoded[i].sampleRate > 0 &&
|
||||
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
|
||||
data.sampleRate = decoded[i].sampleRate;
|
||||
data.rootNote = zones[i].rootNote;
|
||||
data.loop = zones[i].loop;
|
||||
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(zones[i].play, data.sampleRate);
|
||||
const std::size_t sampleIndex = km.samples.size();
|
||||
km.samples.push_back(std::move(data));
|
||||
KeyZone zone;
|
||||
zone.lowNote = zones[i].lowNote;
|
||||
zone.highNote = zones[i].highNote;
|
||||
zone.rootNote = zones[i].rootNote;
|
||||
zone.sampleIndex = sampleIndex;
|
||||
km.zones.push_back(zone);
|
||||
}
|
||||
return km; // empty zones in -> empty Keymap (silence)
|
||||
}
|
||||
|
||||
// --- Performance-map instance state (setState/getState) -----------------------
|
||||
|
||||
namespace {
|
||||
|
||||
void putU32le(std::vector<std::uint8_t>& out, std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||
}
|
||||
|
||||
// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as
|
||||
// two's-complement u64, mirroring the u32 signed-int idiom above).
|
||||
void putU64le(std::vector<std::uint8_t>& out, std::uint64_t v) {
|
||||
for (int b = 0; b < 8; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFF));
|
||||
}
|
||||
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++).
|
||||
// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields.
|
||||
std::uint64_t doubleToBits(double d) {
|
||||
std::uint64_t bits;
|
||||
std::memcpy(&bits, &d, sizeof(bits));
|
||||
return bits;
|
||||
}
|
||||
double bitsToDouble(std::uint64_t bits) {
|
||||
double d;
|
||||
std::memcpy(&d, &bits, sizeof(d));
|
||||
return d;
|
||||
}
|
||||
|
||||
// A bounded little-endian reader over a byte blob. Every read is length-checked; once a
|
||||
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
|
||||
// blob degrades to a partial/empty parse rather than reading out of bounds.
|
||||
struct ByteReader {
|
||||
const std::vector<std::uint8_t>& bytes;
|
||||
std::size_t pos = 0;
|
||||
bool ok = true;
|
||||
|
||||
explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}
|
||||
|
||||
std::uint32_t u32() {
|
||||
if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; }
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[pos]) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
|
||||
pos += 4;
|
||||
return v;
|
||||
}
|
||||
std::uint8_t u8() {
|
||||
if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; }
|
||||
return bytes[pos++];
|
||||
}
|
||||
std::string str(std::uint32_t len) {
|
||||
if (!ok || pos + len > bytes.size()) { ok = false; return {}; }
|
||||
std::string s(reinterpret_cast<const char*>(bytes.data() + pos), len);
|
||||
pos += len;
|
||||
return s;
|
||||
}
|
||||
// Signed ints go on the wire as u32 two's-complement (fixed 32-bit width).
|
||||
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
|
||||
|
||||
std::uint64_t u64() {
|
||||
if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; }
|
||||
std::uint64_t v = 0;
|
||||
for (int b = 0; b < 8; ++b)
|
||||
v |= static_cast<std::uint64_t>(bytes[pos + static_cast<std::size_t>(b)]) << (b * 8);
|
||||
pos += 8;
|
||||
return v;
|
||||
}
|
||||
// Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width).
|
||||
std::int64_t i64() { return static_cast<std::int64_t>(u64()); }
|
||||
|
||||
// Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields
|
||||
// 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob
|
||||
// as "no marker" and falls through to the (also-guarded) v1 count read.
|
||||
std::uint32_t peekU32() const {
|
||||
if (!ok || pos + 4 > bytes.size()) return 0;
|
||||
return static_cast<std::uint32_t>(bytes[pos]) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
|
||||
}
|
||||
};
|
||||
|
||||
// Append the zones payload — the shared body of the performance blob and the component blob,
|
||||
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
|
||||
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
|
||||
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes
|
||||
// the zone count so any reader can detect the record shape independently of the envelope version
|
||||
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
|
||||
// through EITHER envelope with no envelope bump.
|
||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putU32le(out, kZonesFormatMarker);
|
||||
putU32le(out, kZonesPayloadVersion);
|
||||
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putU32le(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
// S11 extension: loop override (hasLoop flag + start/end), then start point.
|
||||
out.push_back(z.loopOverride ? 1 : 0);
|
||||
if (z.loopOverride) {
|
||||
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
|
||||
putU64le(out, asU64(z.loopOverride->start));
|
||||
putU64le(out, asU64(z.loopOverride->end));
|
||||
}
|
||||
out.push_back(z.startPoint ? 1 : 0);
|
||||
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
|
||||
|
||||
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
|
||||
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
|
||||
// fraction. Order matches the header's v5 record spec.
|
||||
const ZonePlaySeconds& pp = z.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putU64le(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the component
|
||||
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
|
||||
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
|
||||
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
|
||||
// keeps the zones that parsed cleanly and drops the rest.
|
||||
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame
|
||||
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames /
|
||||
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed.
|
||||
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
bool extended = false; // v2+: the S11 loop/start tail is present
|
||||
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
||||
if (r.peekU32() == kZonesFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // current: full play params, wall-clock in seconds
|
||||
const std::uint32_t count = r.u32();
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
|
||||
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
|
||||
PerformanceZone z;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
z.sampleId = r.str(idLen);
|
||||
z.lowNote = r.i32();
|
||||
z.highNote = r.i32();
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) z.rootOverride = r.i32();
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
z.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) z.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
|
||||
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`)
|
||||
// to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is.
|
||||
// A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr.
|
||||
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
|
||||
// frames; read in the emit order.
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putU32le(out, kPerformanceStateVersion);
|
||||
putZonesPayload(out, map);
|
||||
return out;
|
||||
}
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
|
||||
// For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3
|
||||
// blob is encountered with an invalid rate — the calller guarantees a real rate before use.
|
||||
PerformanceMap map;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return map; // no version tag -> empty
|
||||
|
||||
// BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes,
|
||||
// no length prefix). Lift it to one full-keyboard zone playing that id.
|
||||
if (version == kSelectionStateVersion) {
|
||||
const std::string id = deserializeSelection(bytes);
|
||||
if (!id.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = id;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
||||
|
||||
readZonesPayload(r, map, projectRate);
|
||||
return map;
|
||||
}
|
||||
|
||||
// --- Combined component state (v3, S10) --------------------------------------
|
||||
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putU32le(out, kComponentStateVersion);
|
||||
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
|
||||
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
|
||||
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
|
||||
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
|
||||
// stops at the mode byte is a strict prefix (see the v4 lift below).
|
||||
putU64le(out, asU64(state.lastConsumedAssignGeneration));
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
||||
putZonesPayload(out, state.map);
|
||||
return out;
|
||||
}
|
||||
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
|
||||
// For v5 and later blobs it is unused. See readZonesPayload for the guard.
|
||||
ComponentState out;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state)
|
||||
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
|
||||
// * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard
|
||||
// zone} so the old pick survives as BOTH the selection and a one-zone map.
|
||||
// * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate
|
||||
// single-capture selection.
|
||||
if (version == kSelectionStateVersion) {
|
||||
out.selectionId = deserializeSelection(bytes);
|
||||
if (!out.selectionId.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = out.selectionId;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
out.map.zones.push_back(std::move(z));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (version == kPerformanceStateVersion) {
|
||||
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
|
||||
return out; // channelMode stays Mono (pre-S7)
|
||||
}
|
||||
// BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
|
||||
// the id length + id + zones body starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionZonesV3Version) {
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
|
||||
}
|
||||
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
|
||||
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration
|
||||
// defaults to 0, so a first assign still applies for a pre-marker instance.
|
||||
if (version == kSelectionZonesModeV4Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out; // marker stays 0 (pre-S8/S9 reader)
|
||||
}
|
||||
if (version != kComponentStateVersion) return out; // unknown -> empty
|
||||
|
||||
// v5: the channel-mode byte, then the 8-byte consumed-assignment marker, precede the v3
|
||||
// body. A non-{0,1} mode byte is treated as mono (conservative default) rather than
|
||||
// rejected — a corrupt mode never silences the instance.
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
out.lastConsumedAssignGeneration = r.i64();
|
||||
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
|
||||
std::vector<std::uint8_t> out;
|
||||
out.resize(4 + sampleId.size());
|
||||
const std::uint32_t v = kSelectionStateVersion;
|
||||
out[0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||
out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||
out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||
out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||
std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
|
||||
if (bytes.size() < 4) return {}; // no version tag -> no selection
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
|
||||
(static_cast<std::uint32_t>(bytes[1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[3]) << 24);
|
||||
if (v != kSelectionStateVersion) return {}; // unknown version -> ignore
|
||||
return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
|
||||
bytes.size() - 4);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,468 @@
|
||||
#pragma once
|
||||
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live
|
||||
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core
|
||||
// plays, and (de)serialize the instance's selected-sample choice for VST3 component
|
||||
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the
|
||||
// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable
|
||||
// arithmetic out of a host-facing shell.
|
||||
//
|
||||
// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam
|
||||
// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV).
|
||||
// Both of those raw inputs cross the bridge/file boundary in the shell; everything
|
||||
// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second
|
||||
// parser; the S1 spike's string-scan reader is retired), pick the selected sample,
|
||||
// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic
|
||||
// Keymap — is pure and unit-tested here.
|
||||
//
|
||||
// It links bank_book (the shared BankBook::deserialize) and wav_trim (the shared
|
||||
// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap /
|
||||
// SampleData it produces). All three are pure; this stays pure.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
||||
#include "sampler_core.h" // Keymap, SampleData, SampleLoop
|
||||
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The bank sample this instance is bound to, distilled from the live "banks" blob:
|
||||
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank
|
||||
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet.
|
||||
struct SelectedSample {
|
||||
std::string relativePath; // project-relative; the shell resolves it (M4 convention)
|
||||
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
|
||||
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
|
||||
};
|
||||
|
||||
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
|
||||
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank
|
||||
// project). `sampleId` is this instance's stored selection.
|
||||
//
|
||||
// Precedence, all pure:
|
||||
// * empty / malformed banksJson -> nullopt (nothing to play)
|
||||
// * sampleId empty -> nullopt (NO selection -> silence)
|
||||
// * sampleId names a sample in ANY bank -> that sample (searched pool + named)
|
||||
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved;
|
||||
// the editor returns to the empty state)
|
||||
//
|
||||
// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh
|
||||
// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first
|
||||
// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play
|
||||
// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns
|
||||
// nullopt rather than silently substituting a different sample — the editor reflects the
|
||||
// missing selection with its "pick a capture" empty state instead of masking it.
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
const std::string& sampleId);
|
||||
|
||||
// One entry in the capture browser's card list: the stable id + display name plus the S2
|
||||
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
|
||||
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
|
||||
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache,
|
||||
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already
|
||||
// holds: the metadata the card badge + bank filter need. Pure projection over the shared
|
||||
// parse — the UI never parses JSON itself.
|
||||
//
|
||||
// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the
|
||||
// badge shows "root: —" / no root, never a guessed value).
|
||||
// - key: the optional human musical key label ("F#m"), when the bank set it.
|
||||
// - bankId: the id of the bank this sample lives in (the bank filter matches on it).
|
||||
struct SampleChoice {
|
||||
std::string id;
|
||||
std::string displayName;
|
||||
std::optional<int> rootNote;
|
||||
std::optional<std::string> key;
|
||||
std::string bankId;
|
||||
};
|
||||
std::vector<SampleChoice> listSamples(const std::string& banksJson);
|
||||
|
||||
// One bank the filter tab strip offers: its stable id + display name, in ordinal order
|
||||
// (pool first). The browser prepends an "All" tab (no id) shell-side. Empty for an empty /
|
||||
// malformed blob. Pure projection over the shared parse.
|
||||
struct BankChoice {
|
||||
std::string id;
|
||||
std::string displayName;
|
||||
};
|
||||
std::vector<BankChoice> listBanks(const std::string& banksJson);
|
||||
|
||||
// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields:
|
||||
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
|
||||
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
|
||||
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve
|
||||
// their source channel count, so a stereo (or N-channel) capture is folded to a single
|
||||
// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is
|
||||
// the least-surprising, no-clip default — a centered mono source stays unity, and a
|
||||
// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride
|
||||
// in -> empty out. Pure.
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount);
|
||||
|
||||
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is
|
||||
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the
|
||||
// source's last channel reads the last channel, so a mono source asked for channel 1 yields
|
||||
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
|
||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount, int which);
|
||||
|
||||
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
|
||||
//
|
||||
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
|
||||
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
|
||||
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
|
||||
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
|
||||
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
|
||||
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
|
||||
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
|
||||
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
|
||||
//
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||
struct AdsrSeconds {
|
||||
double attackSeconds = 0.003; // tier-0 default
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060; // tier-0 default
|
||||
};
|
||||
|
||||
// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless.
|
||||
struct PitchEnvSeconds {
|
||||
bool enabled = false;
|
||||
double attackSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing
|
||||
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap
|
||||
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
|
||||
struct ZonePlaySeconds {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1)
|
||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||
};
|
||||
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
|
||||
// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode,
|
||||
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
|
||||
|
||||
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
||||
// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case
|
||||
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0
|
||||
// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default),
|
||||
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
|
||||
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
|
||||
// pair never half-plays. `sampleRate` is the WAV's rate.
|
||||
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it
|
||||
// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a
|
||||
// picked single capture plays under the same default engine as a zone would. This function
|
||||
// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR = {},
|
||||
const ZonePlaySeconds& play = ZonePlaySeconds{});
|
||||
|
||||
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
|
||||
//
|
||||
// The performance map is the keymap the user authors IN the instrument: several bank
|
||||
// samples zoned across the keyboard, each with a key range and a root note. It is a
|
||||
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never
|
||||
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but
|
||||
// OVERRIDABLE here — the override lives on the zone, never on `Sample`.
|
||||
//
|
||||
// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM.
|
||||
// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build
|
||||
// stitches the decoded frames + this map into a sampler_core Keymap.
|
||||
|
||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range,
|
||||
// with an optional root-note override. rootOverride absent -> repitch from the bank
|
||||
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty).
|
||||
//
|
||||
// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain
|
||||
// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the
|
||||
// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins
|
||||
// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame
|
||||
// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here;
|
||||
// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone.
|
||||
struct PerformanceZone {
|
||||
std::string sampleId; // bank sample id this zone plays
|
||||
int lowNote = 0; // inclusive
|
||||
int highNote = 127; // inclusive
|
||||
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
||||
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
|
||||
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
|
||||
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
|
||||
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
|
||||
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
|
||||
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
|
||||
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
|
||||
ZonePlaySeconds play;
|
||||
};
|
||||
|
||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||
// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's
|
||||
// first-match Keymap::resolve — overlaps are neither rejected nor clamped, the earlier
|
||||
// zone simply takes the contested keys; documented, deterministic).
|
||||
struct PerformanceMap {
|
||||
std::vector<PerformanceZone> zones;
|
||||
|
||||
bool empty() const { return zones.empty(); }
|
||||
};
|
||||
|
||||
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank
|
||||
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats
|
||||
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct
|
||||
// from PerformanceZone (which names an id) — this is the id resolved against the live bank.
|
||||
struct ResolvedZone {
|
||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
|
||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||
};
|
||||
|
||||
// The result of resolving a performance map against the live bank blob. `zones` are the
|
||||
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is
|
||||
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a
|
||||
// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence
|
||||
// for the whole map — and its id is reported here so the editor can flag/prune it).
|
||||
struct ResolvedPerformance {
|
||||
std::vector<ResolvedZone> zones;
|
||||
std::vector<std::string> droppedSampleIds;
|
||||
};
|
||||
|
||||
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared
|
||||
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
|
||||
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
|
||||
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
|
||||
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result (the shell
|
||||
// then falls back to Tier-0 — see reloadFromBank).
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
|
||||
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
|
||||
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is
|
||||
// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order
|
||||
// is preserved so first-match overlap resolution matches the map's authored order. A zone
|
||||
// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the
|
||||
// map). Empty zones in -> empty Keymap (silence).
|
||||
struct DecodedZonePcm {
|
||||
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
||||
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
|
||||
// receive the WAV's real rate before use.
|
||||
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
||||
};
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded);
|
||||
|
||||
// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding
|
||||
// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's
|
||||
// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// * MONO mode -> downmix to one channel (the existing policy: average all source
|
||||
// channels). framesR EMPTY. A mono or stereo source both collapse.
|
||||
// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered).
|
||||
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels
|
||||
// takes channels 0 and 1 (documented; the sampler's stereo image is
|
||||
// the first two channels — no surround fold).
|
||||
// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone
|
||||
// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here.
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
|
||||
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
|
||||
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
|
||||
// truncation/wrong-version by design (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
|
||||
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
|
||||
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
|
||||
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
|
||||
// key composition property: the zone-record extension is versioned inside the map blob, not on
|
||||
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
|
||||
// collide on a single version number.
|
||||
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
|
||||
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
|
||||
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
|
||||
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
|
||||
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
|
||||
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
|
||||
// to each zone record after rootOverride:
|
||||
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
|
||||
// 8-byte LE loop.end (both two's-complement int64);
|
||||
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
|
||||
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
|
||||
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
|
||||
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
|
||||
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
|
||||
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
|
||||
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
|
||||
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
|
||||
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
|
||||
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
|
||||
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
|
||||
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
|
||||
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
|
||||
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
|
||||
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
|
||||
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
|
||||
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
|
||||
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
|
||||
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
|
||||
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
|
||||
// 8-byte LE pitchEnv.peakSemitones (double);
|
||||
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
|
||||
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
|
||||
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
|
||||
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
|
||||
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
|
||||
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
|
||||
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
|
||||
// to an EMPTY map.
|
||||
//
|
||||
// These two functions serialize the ZONES only. Since S10 the instrument's full component
|
||||
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
|
||||
// below, the v3 format the processor actually reads/writes. serializePerformance/
|
||||
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12). serializePerformance
|
||||
// and serializeComponentState both emit the CURRENT payload version (v5 — marker + version +
|
||||
// records with the S11 loop/start tail AND the full play-params tail with wall-clock times in
|
||||
// SECONDS) so the overrides round-trip through EITHER envelope. Readers accept a v1 payload (no
|
||||
// marker), a v2 payload (marker + version 2, no play tail), and a v3 payload (legacy S15/S16
|
||||
// play tail with wall-clock frame counts) for back-compat, lifting missing fields to defaults.
|
||||
// v4 was never shipped and is not read. The marker is a high sentinel that a legitimate zone
|
||||
// count (bounded by 128 MIDI zones in practice, always tiny) can never collide with.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
|
||||
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
|
||||
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
|
||||
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
|
||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
|
||||
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
|
||||
//
|
||||
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
|
||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||
// state), never auto-playing sample #1.
|
||||
//
|
||||
// Format (envelope v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono,
|
||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker),
|
||||
// then a 4-byte LE selection-id length + id bytes, then the CURRENT zones payload (identical to
|
||||
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||
// The 8-byte marker is the ONLY envelope-v5 addition over envelope-v4 — the envelope grew a field,
|
||||
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
|
||||
// versioning; the two version numbers are independent axes). BACK-COMPAT on
|
||||
// read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0,
|
||||
// preserving current behavior for already-saved instances):
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct.
|
||||
// * v4 blob -> {channelMode, 0, selectionId, zones}: pre-S8/S9 reader (no marker).
|
||||
// * v3 blob -> {mono, 0, selectionId, zones}: pre-S7 had no channel mode.
|
||||
// * v2 blob -> {mono, 0, "", zones}: an S5 instance had zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, id, one full-keyboard zone}: the S4 single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, "", no zones}: EMPTY (the S10 silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
|
||||
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
|
||||
// already got and then manually changed away from: on re-open the instance re-reads the pending
|
||||
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
|
||||
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
|
||||
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
ChannelMode channelMode = ChannelMode::Mono; // S7 output mode; default mono (D-E)
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kComponentStateVersion = 5;
|
||||
|
||||
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
|
||||
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
|
||||
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
|
||||
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
||||
|
||||
// The full instance state serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
|
||||
|
||||
// The full instance state parsed back from IBStream bytes (setState). Tolerant of
|
||||
// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table
|
||||
// above so already-saved instances restore cleanly.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
|
||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
||||
// performance choice, held by the instrument, never written back to the bank). It is a
|
||||
// single string id. serialize/deserialize keep the on-the-wire form explicit and
|
||||
// versioned so a future Tier can extend it without breaking already-saved instances.
|
||||
//
|
||||
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
|
||||
// length prefix is needed — the id runs to the end of the stream (the host tells us the
|
||||
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
|
||||
// by returning "" (no selection — under the S10 policy reversal an empty selection is
|
||||
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
|
||||
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
|
||||
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
// The selected-sample id serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
||||
|
||||
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
|
||||
// too-short, or empty -> "" (graceful no-selection).
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,633 @@
|
||||
// sampler_core — pure sampler engine implementation. See sampler_core.h for the
|
||||
// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape,
|
||||
// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes.
|
||||
|
||||
#include "sampler_core.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pitchRatio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double pitchRatio(int note, int rootNote) {
|
||||
// Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0.
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keymap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ZoneResolution Keymap::resolve(int note, int velocity) const {
|
||||
(void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1.
|
||||
for (std::size_t i = 0; i < zones.size(); ++i) {
|
||||
const KeyZone& z = zones[i];
|
||||
if (note >= z.lowNote && note <= z.highNote) {
|
||||
return ZoneResolution{true, i};
|
||||
}
|
||||
}
|
||||
return ZoneResolution{false, 0};
|
||||
}
|
||||
|
||||
Keymap Keymap::singleSampleChromatic(SampleData sample) {
|
||||
const int root = sample.rootNote;
|
||||
Keymap km;
|
||||
km.samples.push_back(std::move(sample));
|
||||
KeyZone zone;
|
||||
zone.lowNote = 0;
|
||||
zone.highNote = 127;
|
||||
zone.rootNote = root;
|
||||
zone.sampleIndex = 0;
|
||||
km.zones.push_back(zone);
|
||||
return km;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdsrEnvelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AdsrEnvelope::noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
void AdsrEnvelope::noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished ||
|
||||
stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
// Release from the CURRENT level — release-before-sustain releases from the
|
||||
// partial attack/decay level, not from sustainLevel.
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
double AdsrEnvelope::tick() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight
|
||||
// through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the
|
||||
// stage on this same tick (no frame consumed at 1.0 beyond what Attack already
|
||||
// emitted), so hold=0 is byte-identical to the pre-S15 envelope.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
// Fall through to Decay this frame so no extra unity sample is emitted for a
|
||||
// zero-length hold (preserving the exact pre-S15 sample-for-sample shape).
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold→Decay only; not a general recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A
|
||||
// negative fade is treated as 0. When both fades together exceed the play length, shrink
|
||||
// the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp),
|
||||
// then the fade-in — never letting either go negative or the sum exceed the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
}
|
||||
|
||||
double TriggerEnvelope::amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_).
|
||||
// Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length).
|
||||
// The offset is fractional (the read head is fractional under repitch), so the ramps are
|
||||
// smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): 0->1 constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double PitchEnvelope::tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs
|
||||
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote) {
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively.
|
||||
int v = velocity;
|
||||
if (v < 0) v = 0;
|
||||
if (v > 127) v = 127;
|
||||
velocityGain_ = static_cast<double>(v) / 127.0;
|
||||
baseRatio_ = pitchRatio(note, rootNote);
|
||||
sample_ = &sample;
|
||||
|
||||
const ZonePlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp
|
||||
// into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than
|
||||
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's
|
||||
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length.
|
||||
//
|
||||
// All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
|
||||
// buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
|
||||
// the live sample rate.
|
||||
//
|
||||
// Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
|
||||
// (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
|
||||
// every DAW rate — now trivially true, since the times are wall-clock seconds. ---
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
// --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). ---
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// --- Preserve engine (S16): reset + pre-warm the ALREADY-SIZED per-channel shifters. The
|
||||
// rings were allocated off-thread by presizePreserveShifters (the engine calls it at
|
||||
// construction), so this RT-safe path only zeroes state (reset) and runs a silence pass
|
||||
// (warm) to settle the OLA taps before the first output frame — NO allocation here.
|
||||
// Varispeed voices never touch the shifters (advanceFrame checks configured()), so a
|
||||
// Varispeed instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
shiftL_.reset();
|
||||
shiftL_.warm();
|
||||
if (sample.channelCount() == 2 && shiftR_.configured()) {
|
||||
shiftR_.reset();
|
||||
shiftR_.warm();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
|
||||
if (playMode_ == PlayMode::Trigger) return;
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
double Voice::tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
// AHDSR is wall-clock (one tick per output frame), independent of the read rate.
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades
|
||||
// land on the same source frames under either engine's read rate. The voice ALSO frees on
|
||||
// readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
|
||||
// bracketing indices, interpolation partner) is computed ONCE and applied identically to
|
||||
// every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE
|
||||
// per frame and scale all channels equally (a voice is one envelope). The head advances by
|
||||
// exactly one source-frame step per call, so mono and stereo consume the sample at one rate.
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a
|
||||
// valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap
|
||||
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
|
||||
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = playMode_ == PlayMode::Gate && loop.hasLoop &&
|
||||
loop.end > loop.start && loop.start >= 0 && loop.end <= frameCount;
|
||||
if (loopUsable) {
|
||||
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
}
|
||||
|
||||
// TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The
|
||||
// trigger envelope also finishes at the same frame count; either latches the voice idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Linear interpolation between the two bracketing SOURCE frames. For the loop case, the
|
||||
// second point wraps to loopStart so the seam is continuous.
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||
const double frac = readPos_ - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (loopUsable && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
// Raw interpolated source values (pre-shift). These are the SOURCE stream both engines read;
|
||||
// Varispeed applies pitch by the read RATE, Preserve applies it by the shifter.
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
double srcR = 0.0;
|
||||
if (stereo) {
|
||||
srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
}
|
||||
|
||||
// The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0)
|
||||
// this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read
|
||||
// (no per-frame transcendental), byte-identical to pre-S16.
|
||||
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// PRESERVE: read the source at unity rate (duration held) and TRANSPOSE the output by
|
||||
// 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to the shift amount, not the
|
||||
// read rate — pitch bends, duration unchanged (S16 contract).
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(static_cast<AudioSample>(srcL)));
|
||||
outL = shiftedL * gain;
|
||||
if (stereo) {
|
||||
if (shiftR_.configured()) {
|
||||
// Genuine stereo: an independent shifter transposes channel 1. Each shifter is
|
||||
// process()'d EXACTLY ONCE per output frame (never twice — that would advance its
|
||||
// heads twice and corrupt the OLA state).
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.process(static_cast<AudioSample>(srcR))) * gain;
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
|
||||
// value from srcL (== srcR since pcmR aliases pcm); mirror it to R. Do NOT call
|
||||
// shiftL_.process again this frame.
|
||||
outRlocal = shiftedL * gain;
|
||||
}
|
||||
}
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch
|
||||
// envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the
|
||||
// envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
outL = srcL * gain;
|
||||
if (stereo) outRlocal = srcR * gain;
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
if (amplitudeDone_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
AudioSample Voice::renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames)
|
||||
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap),
|
||||
preserveVoiceCap_(preserveVoiceCap) {
|
||||
// maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on;
|
||||
// clamp to a single voice so the engine is always usable (documented degenerate).
|
||||
//
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (Voice& v : voices_) v.presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play.
|
||||
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) {
|
||||
return kNoVoice; // zone points at a missing sample — refuse rather than UB.
|
||||
}
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated).
|
||||
// Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only
|
||||
// NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch").
|
||||
if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample, zone.rootNote);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap (S4 real-time discipline).
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,531 @@
|
||||
#pragma once
|
||||
// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately
|
||||
// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW
|
||||
// and outside any plugin host. It owns the pure sampler engine: polyphonic voice
|
||||
// allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap
|
||||
// with (note, velocity) -> zone resolution, and repitch/interpolation from a root note
|
||||
// with loop-point-aware sustain.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL,
|
||||
// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell
|
||||
// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from
|
||||
// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced
|
||||
// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i).
|
||||
//
|
||||
// It shares the `AudioSample` float alias from peaks — the one house precedent for a
|
||||
// pure module leaning on peaks for the audio-domain type (wav_trim does the same). The
|
||||
// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the
|
||||
// core does no file I/O — it is handed decoded sample frames and produces audio frames.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "peaks.h" // AudioSample (float)
|
||||
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7
|
||||
// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders
|
||||
// per-channel. A PERFORMANCE choice the instrument owns (component state), never written
|
||||
// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain
|
||||
// value so the shell (bus negotiation, state) and the engine share one spelling; the core
|
||||
// itself never branches on it — the mode only picks which render overload the shell drives.
|
||||
enum class ChannelMode { Mono, Stereo };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
|
||||
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
|
||||
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
|
||||
// with the rest of the engine machinery; only the value structs need to precede SampleData.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
|
||||
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
|
||||
struct AdsrParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR
|
||||
std::int64_t decayFrames = 0;
|
||||
double sustainLevel = 1.0; // 0..1
|
||||
std::int64_t releaseFrames = 0;
|
||||
};
|
||||
|
||||
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's
|
||||
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop,
|
||||
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone
|
||||
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span
|
||||
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)),
|
||||
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over
|
||||
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play
|
||||
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
|
||||
struct TriggerParams {
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
||||
std::int64_t fadeInFrames = 0; // 0->1 ramp at the head
|
||||
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd
|
||||
};
|
||||
|
||||
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default
|
||||
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool)
|
||||
// so a third curve can join without a signature change.
|
||||
enum class FadeCurve { EqualPower, Linear };
|
||||
|
||||
// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted.
|
||||
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||
|
||||
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration
|
||||
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances
|
||||
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length).
|
||||
enum class PitchEngine { Varispeed, Preserve };
|
||||
|
||||
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching"
|
||||
// directive). ONE constant to flip if Varispeed should be the default instead. This is the
|
||||
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's
|
||||
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core
|
||||
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
|
||||
// engine" holds for the core's own regression tests (an octave up still halves duration in the
|
||||
// bare engine); the Preserve product default is layered on above at (de)serialization.
|
||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
|
||||
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
|
||||
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
|
||||
// smoother on big transpositions, more onset latency. One knob, resolved at voice allocation.
|
||||
inline constexpr double kPreserveWindowMs = 50.0;
|
||||
|
||||
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always
|
||||
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to
|
||||
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack
|
||||
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
|
||||
struct PitchEnvParams {
|
||||
bool enabled = false;
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData
|
||||
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16
|
||||
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope
|
||||
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
|
||||
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
|
||||
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
|
||||
struct ZonePlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr; // Gate: the AHDSR envelope
|
||||
TriggerParams trigger; // Trigger: %-length + fades
|
||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||
PitchEnvParams pitchEnv; // AD pitch modulation, off by default
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that
|
||||
// govern playback. The shell decodes the on-disk WAV and fills this; the core
|
||||
// never touches a file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A loop over [start, end) frames, half-open. A zero-length loop (start == end)
|
||||
// is the "no sustain loop" marker — a held note past the sample end goes silent
|
||||
// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false.
|
||||
struct SampleLoop {
|
||||
bool hasLoop = false;
|
||||
std::int64_t start = 0; // first looped frame (inclusive)
|
||||
std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end
|
||||
};
|
||||
|
||||
// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is
|
||||
// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample).
|
||||
// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise
|
||||
// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both
|
||||
// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical
|
||||
// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was
|
||||
// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio.
|
||||
struct SampleData {
|
||||
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
|
||||
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample
|
||||
int sampleRate = 0; // frames per second (for reference; ratio is
|
||||
// note-relative, so rate cancels for repitch).
|
||||
// 0 is explicitly invalid — every consumer must
|
||||
// receive a real rate before use.
|
||||
int rootNote = 60; // MIDI note recorded at (plays at unity here)
|
||||
SampleLoop loop; // sustain loop, if any
|
||||
// Initial read position (frame offset) a voice starts playback at — frame 0 by
|
||||
// default, so an unset start point is exactly the pre-S11 behavior. S11 makes this
|
||||
// an instrument-side per-zone override (the "start point" marker); S15 builds on it
|
||||
// (both play modes carry a modifiable start). Clamped into [0, frames) at note-on:
|
||||
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
|
||||
std::int64_t startFrame = 0;
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch
|
||||
// envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is
|
||||
// Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData.
|
||||
ZonePlayParams play;
|
||||
|
||||
// 2 iff a matching-length second channel exists; else 1. A framesR of a different
|
||||
// length than frames is treated as absent (mono) — a malformed pair never half-plays.
|
||||
int channelCount() const {
|
||||
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves
|
||||
// to at most one zone; a zone names which SampleData to play and the root note to
|
||||
// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the
|
||||
// sample's own root. Tier-1: several zones, each a key range with its own root.
|
||||
//
|
||||
// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built:
|
||||
// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone
|
||||
// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and
|
||||
// resolve() gains the velocity dimension it already receives but currently ignores
|
||||
// for selection. The (note, velocity) signature and the "resolve to a zone, then a
|
||||
// sample within it" shape are already in place — Tier 2 fills in the second step
|
||||
// without changing callers or the voice engine. See the report note.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with
|
||||
// the root note to repitch from (defaults to the sample's own root, overridable in
|
||||
// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a
|
||||
// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention).
|
||||
struct KeyZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // repitch reference for this zone
|
||||
std::size_t sampleIndex = 0; // index into Keymap::samples
|
||||
};
|
||||
|
||||
// Result of resolving a (note, velocity). `matched == false` means the note falls in
|
||||
// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0.
|
||||
struct ZoneResolution {
|
||||
bool matched = false;
|
||||
std::size_t zoneIndex = 0; // valid only when matched
|
||||
};
|
||||
|
||||
// The keymap: the decoded samples plus the zones that map keys onto them. Owns
|
||||
// resolution. Pure: no host types. Zones are tested first-match in order, so an
|
||||
// earlier zone wins an overlap (deterministic, documented).
|
||||
struct Keymap {
|
||||
std::vector<SampleData> samples;
|
||||
std::vector<KeyZone> zones;
|
||||
|
||||
// Resolves (note, velocity) to a zone. First zone (in order) whose [low,high]
|
||||
// contains `note` wins. velocity is accepted now (Tier-2 seam) but does not
|
||||
// affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains
|
||||
// the note.
|
||||
ZoneResolution resolve(int note, int velocity) const;
|
||||
|
||||
// Convenience: build the Tier-0 degenerate keymap — one sample mapped
|
||||
// chromatically across the whole keyboard from its own root note.
|
||||
static Keymap singleSampleChromatic(SampleData sample);
|
||||
};
|
||||
|
||||
// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`:
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0,
|
||||
// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed.
|
||||
double pitchRatio(int note, int rootNote);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
|
||||
// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
|
||||
// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
|
||||
//
|
||||
// Segment math (all linear ramps):
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
|
||||
// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
|
||||
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain)
|
||||
// releases from the current partial level, not from sustainLevel. AdsrParams is defined above
|
||||
// (with the other per-zone value structs); this section holds only the per-frame evaluator.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn();
|
||||
// Gate off: enter Release from the current level.
|
||||
void noteOff();
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever
|
||||
// (until the next noteOn). A single, monotonic per-frame step — the caller pulls
|
||||
// one value per output frame.
|
||||
double tick();
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams /
|
||||
// FadeCurve value structs are defined above with the other per-zone params.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated
|
||||
// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output
|
||||
// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so
|
||||
// output and source frames coincide, but under Varispeed a transposed voice consumes source
|
||||
// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME
|
||||
// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The
|
||||
// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by
|
||||
// the play length and note-off-immune. Reports finished() once the offset reaches the play length.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the
|
||||
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength
|
||||
// (fadeOut anchored to the end). A zero/negative play length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play
|
||||
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over
|
||||
// the offset (no internal advance) so it composes with either pitch engine's read rate.
|
||||
double amplitudeAt(double sourceOffset);
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs
|
||||
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones
|
||||
// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone
|
||||
// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested
|
||||
// for offset at t=0, peak at t=attack, and 0 at t=attack+decay.
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
// Advance one frame, return this frame's pitch offset in semitones.
|
||||
double tick();
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A single voice: one active note playing one repitched, enveloped sample. Reads
|
||||
// the sample by fractional frame position with linear interpolation, advancing by
|
||||
// the pitch ratio; loops the sustain region for held notes past the loop end.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Voice {
|
||||
public:
|
||||
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
|
||||
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
|
||||
// from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from
|
||||
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
|
||||
// buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE +
|
||||
// Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`.
|
||||
// The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) —
|
||||
// start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio
|
||||
// thread inside process(). The warm silence pass settles the OLA taps before the first
|
||||
// output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play
|
||||
// is default (Gate + Varispeed + no pitch env).
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote);
|
||||
|
||||
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
||||
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// True while this voice is producing (or about to produce) sound.
|
||||
bool active() const { return active_; }
|
||||
// The note this voice was started on (for note-off routing). Meaningless if idle.
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter — higher = started earlier relative to others. The voice
|
||||
// engine uses this for its stealing policy (oldest first). Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
|
||||
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
|
||||
// audio thread (this allocates). The engine calls it once at construction so start() — which
|
||||
// runs on the audio thread inside process() — never allocates: start() only reset()s + warm()s
|
||||
// the already-sized rings. `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
|
||||
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap no-op
|
||||
// in the underlying vector.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one
|
||||
// output frame. Returns 0.0 (and goes idle) once the envelope finishes or the
|
||||
// sample runs out with no loop. The value is already velocity- and
|
||||
// envelope-scaled — the engine sums voices directly. This is the MONO path (channel
|
||||
// 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged.
|
||||
AudioSample renderFrame();
|
||||
|
||||
// STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances
|
||||
// the read head + envelope by exactly one frame (the same single advance the mono path
|
||||
// performs — the envelope ticks ONCE per frame, shared across both channels). For a mono
|
||||
// sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered).
|
||||
// Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions
|
||||
// as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r);
|
||||
|
||||
private:
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and
|
||||
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and
|
||||
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR);
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per
|
||||
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the
|
||||
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to
|
||||
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope
|
||||
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
|
||||
double tickAmplitude();
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only
|
||||
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame
|
||||
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
|
||||
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
|
||||
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with
|
||||
// bounded voice stealing, note-off routing, and block rendering (sum of voices).
|
||||
//
|
||||
// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a
|
||||
// new note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the
|
||||
// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing
|
||||
// that, the note that has already had the most time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
|
||||
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
|
||||
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
|
||||
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
|
||||
// from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
|
||||
// `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by
|
||||
// maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's
|
||||
// Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so
|
||||
// note-on (which runs in process()) never allocates; 0 leaves them pass-through (a
|
||||
// Varispeed-only instrument pays no ring cost). The processor derives it from the host
|
||||
// sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
|
||||
// are unaffected.
|
||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0);
|
||||
|
||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
||||
// voice, or steals one per the policy above. Returns the index of the voice used,
|
||||
// or kNoVoice for an out-of-zone (unplayed) note.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
|
||||
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
|
||||
// this never touches memory it does not own and NEVER allocates). This is the
|
||||
// audio-thread entry point: the VST3 process callback passes the host's own output
|
||||
// channel buffer, so no allocation, resize, or heap traffic happens under process.
|
||||
// Voices that finish mid-block go idle and stop contributing. `out` must point at
|
||||
// at least `frameCount` writable samples; a null `out` or zero count is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two
|
||||
// buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there
|
||||
// (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no
|
||||
// resize, no lock. A mono sample plays dual-mono (same value to both channels, centered);
|
||||
// a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono
|
||||
// and stereo render paths are independent output shapes over the SAME voice pool; the active
|
||||
// channel mode (mono vs stereo bus) picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// TEST / off-thread convenience: appends `frameCount` summed frames to `out`
|
||||
// (grows it — DO NOT call on the audio thread; it allocates). Delegates to the
|
||||
// real-time overload after sizing the buffer, so both paths share one mix loop.
|
||||
// Does not clear existing contents — appends, matching the pre-S4 contract the
|
||||
// unit tests rely on.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const Keymap& keymap_;
|
||||
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -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
|
||||
@@ -0,0 +1,99 @@
|
||||
// waveform_view.cpp — see waveform_view.h. Pure math; no host types.
|
||||
|
||||
#include "waveform_view.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib> // std::abs (int overload)
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
|
||||
if (f < 0) return 0;
|
||||
if (f > frameCount) return frameCount;
|
||||
return f;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
|
||||
const int w = std::max(0, area.width());
|
||||
if (frameCount <= 0 || w <= 0) return area.left;
|
||||
const std::int64_t f = clampFrame(frame, frameCount);
|
||||
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line
|
||||
// visually centered on its frame; the divide is exact rational (multiply first).
|
||||
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
|
||||
return area.left + static_cast<int>(num / frameCount);
|
||||
}
|
||||
|
||||
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
|
||||
const int w = std::max(0, area.width());
|
||||
if (frameCount <= 0 || w <= 0) return 0;
|
||||
if (x <= area.left) return 0;
|
||||
if (x >= area.right) return frameCount;
|
||||
const std::int64_t dx = static_cast<std::int64_t>(x - area.left);
|
||||
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw
|
||||
// agree at bin granularity.
|
||||
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
|
||||
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
|
||||
}
|
||||
|
||||
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || frames == nullptr) return -1;
|
||||
if (!contains(area, x, y)) return -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const int mx = frameToX(area, frameCount, frames[i]);
|
||||
if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
|
||||
int dxPixels) {
|
||||
const std::int64_t start = clampFrame(startFrame, frameCount);
|
||||
if (dxPixels == 0) return start;
|
||||
const int w = std::max(0, area.width());
|
||||
if (frameCount <= 0 || w <= 0) return start; // no room to move
|
||||
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
|
||||
const std::int64_t magnitude =
|
||||
(static_cast<std::int64_t>(std::abs(dxPixels)) * frameCount +
|
||||
static_cast<std::int64_t>(w) / 2) /
|
||||
static_cast<std::int64_t>(w);
|
||||
const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude;
|
||||
return clampFrame(start + shift, frameCount);
|
||||
}
|
||||
|
||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||
std::int64_t target) {
|
||||
if (pcm == nullptr || frames < 2) return clampFrame(target, frames > 0 ? frames - 1 : 0);
|
||||
// Clamp target into a valid sample index [0, frames).
|
||||
std::int64_t t = target;
|
||||
if (t < 0) t = 0;
|
||||
if (t > frames - 1) t = frames - 1;
|
||||
|
||||
// A crossing lives at frame i (1 <= i < frames) when sign(pcm[i-1]) != sign(pcm[i]) OR
|
||||
// pcm[i] == 0. isCrossing(i) tests exactly that. We fan out from t: at each distance d we
|
||||
// probe t-d before t+d, so an equidistant tie resolves to the LOWER frame (deterministic).
|
||||
auto isCrossing = [&](std::int64_t i) -> bool {
|
||||
if (i < 1 || i >= frames) return false;
|
||||
const AudioSample a = pcm[i - 1];
|
||||
const AudioSample b = pcm[i];
|
||||
if (b == 0.0f) return true; // a sample on zero is its own crossing
|
||||
return (a < 0.0f) != (b < 0.0f); // sign change between i-1 and i
|
||||
};
|
||||
|
||||
if (isCrossing(t)) return t;
|
||||
for (std::int64_t d = 1; d < frames; ++d) {
|
||||
const std::int64_t lo = t - d;
|
||||
if (lo >= 1 && isCrossing(lo)) return lo; // lower side wins the tie
|
||||
const std::int64_t hi = t + d;
|
||||
if (hi < frames && isCrossing(hi)) return hi;
|
||||
// Stop once both probes have run off both ends — no crossing anywhere.
|
||||
if (lo < 1 && hi >= frames) break;
|
||||
}
|
||||
return t; // no sign change in the whole buffer -> keep the raw (clamped) target
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,83 @@
|
||||
// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11
|
||||
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
||||
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap
|
||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell
|
||||
// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it.
|
||||
//
|
||||
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal
|
||||
// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start,
|
||||
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately
|
||||
// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a
|
||||
// different marker set (start + %-length end + fades) without reworking the machinery.
|
||||
//
|
||||
// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab
|
||||
// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's
|
||||
// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps),
|
||||
// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so
|
||||
// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio
|
||||
// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do
|
||||
// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
#include "peaks.h" // AudioSample (float), the mono PCM the snap scans
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The width (px) of a marker's grab region either side of its x line: a grab within this many
|
||||
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab
|
||||
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
|
||||
// distinguishable.
|
||||
inline constexpr int kMarkerGrabWidth = 5;
|
||||
|
||||
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.left,
|
||||
// frame frameCount -> area.right. A frame is clamped to [0, frameCount] before mapping, so an
|
||||
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a
|
||||
// zero-width area pins every frame to area.left (a degenerate, non-inverting result). Pure.
|
||||
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame);
|
||||
|
||||
// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to
|
||||
// [0, frameCount]. A point left of area.left yields 0; right of area.right yields frameCount.
|
||||
// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips
|
||||
// to the same frame at bin granularity).
|
||||
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
|
||||
|
||||
// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at
|
||||
// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is
|
||||
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker
|
||||
// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring
|
||||
// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or
|
||||
// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like
|
||||
// keyboard_strip::zoneBarAtPoint.
|
||||
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time
|
||||
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the
|
||||
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth),
|
||||
// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to
|
||||
// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag;
|
||||
// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this
|
||||
// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame
|
||||
// for dxPixels == 0.
|
||||
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
|
||||
int dxPixels);
|
||||
|
||||
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the
|
||||
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames)
|
||||
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing
|
||||
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and
|
||||
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to
|
||||
// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or
|
||||
// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller
|
||||
// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the
|
||||
// decoded PCM the shell already holds; no host types, no file I/O.
|
||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||
std::int64_t target);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Standalone tests for reasampler::AssignmentRequest — no REAPER, no framework.
|
||||
// The S8 ingest assignment-request seam: the (bankId, sampleId, generation) value the
|
||||
// extension writes to ext-state after an ingest-with-assign, decoded by the instrument
|
||||
// in a later dispatch. Only the wire format lives in this module; test it hard because
|
||||
// the reader (a different artifact) must decode exactly what this writer produces.
|
||||
//
|
||||
// Covers: encode/decode round-trip, ids carrying arbitrary bytes (GUIDs, separators),
|
||||
// the generation field including zero and negative-guard, and malformed/truncated/
|
||||
// trailing-garbage input -> nullopt (the reader's "no pending request" fallback hinges
|
||||
// on it).
|
||||
|
||||
#include "../src/assignment_request.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- round-trip --------------------------------------------------------------
|
||||
|
||||
static void testRoundTrip() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "{12345678-1234-1234-1234-1234567890AB}";
|
||||
req.sampleId = "cap-1700000000-kick.wav";
|
||||
req.generation = 1700000123;
|
||||
|
||||
const std::string wire = encodeAssignmentRequest(req);
|
||||
auto back = decodeAssignmentRequest(wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == req);
|
||||
// Re-encoding the decoded value is byte-stable (deterministic encoder).
|
||||
CHECK(encodeAssignmentRequest(*back) == wire);
|
||||
}
|
||||
|
||||
// The pool bank id and an empty-ish generation must round-trip too (generation 0 is the
|
||||
// documented pre-S9 default; an assign still carries a real stamp, but 0 must be legal).
|
||||
static void testRoundTripPoolAndZeroGeneration() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "pool";
|
||||
req.sampleId = "s1";
|
||||
req.generation = 0;
|
||||
|
||||
auto back = decodeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->bankId == "pool");
|
||||
CHECK(back->sampleId == "s1");
|
||||
CHECK(back->generation == 0);
|
||||
}
|
||||
|
||||
// Ids carrying the wire's own metacharacters (':' the length delimiter, digits that
|
||||
// could be misread as a length, the magic-tag bytes) must survive whole — the whole
|
||||
// reason for length-prefixing over a delimiter-split format.
|
||||
static void testRoundTripAdversarialIds() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "12:34:has-colons"; // ':' is the length delimiter
|
||||
req.sampleId = "rsassign1-lookalike-99"; // embeds the magic tag
|
||||
req.generation = -42; // negative is representable
|
||||
|
||||
auto back = decodeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == req);
|
||||
CHECK(back->bankId == "12:34:has-colons");
|
||||
CHECK(back->sampleId == "rsassign1-lookalike-99");
|
||||
CHECK(back->generation == -42);
|
||||
}
|
||||
|
||||
// Empty ids are structurally valid on the wire (length 0) and must round-trip — the
|
||||
// decoder must not conflate an empty field with a parse failure.
|
||||
static void testRoundTripEmptyFields() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "";
|
||||
req.sampleId = "";
|
||||
req.generation = 7;
|
||||
|
||||
auto back = decodeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == req);
|
||||
}
|
||||
|
||||
// A large generation (past 32-bit) must not truncate — the field is int64.
|
||||
static void testLargeGeneration() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "b";
|
||||
req.sampleId = "s";
|
||||
req.generation = 9007199254740993LL; // > 2^53, > INT32_MAX
|
||||
|
||||
auto back = decodeAssignmentRequest(encodeAssignmentRequest(req));
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->generation == 9007199254740993LL);
|
||||
}
|
||||
|
||||
// --- malformed / tolerant parse ----------------------------------------------
|
||||
|
||||
static void testMalformedParse() {
|
||||
// Absence / total garbage — the reader maps these to "no pending request".
|
||||
CHECK(!decodeAssignmentRequest("").has_value());
|
||||
CHECK(!decodeAssignmentRequest("not a request").has_value());
|
||||
// Wrong magic tag.
|
||||
CHECK(!decodeAssignmentRequest("rsprov1" "1:b1:s1:7").has_value());
|
||||
// Magic only, no fields.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1").has_value());
|
||||
// Truncated mid-record (missing the generation field).
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s1").has_value());
|
||||
// A length that runs past the end.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "99:short").has_value());
|
||||
// Non-numeric length token.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "x:pool2:s11:7").has_value());
|
||||
// A non-numeric generation field.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s13:abc").has_value());
|
||||
// A bare "-" generation.
|
||||
CHECK(!decodeAssignmentRequest("rsassign1" "4:pool2:s11:-").has_value());
|
||||
}
|
||||
|
||||
// Trailing garbage after a well-formed record must be rejected — a partial/padded blob
|
||||
// is not a valid request, and the reader must never accept the prefix and ignore the rest.
|
||||
static void testTrailingGarbageRejected() {
|
||||
AssignmentRequest req;
|
||||
req.bankId = "pool";
|
||||
req.sampleId = "s1";
|
||||
req.generation = 7;
|
||||
const std::string wire = encodeAssignmentRequest(req);
|
||||
|
||||
// The clean value parses.
|
||||
CHECK(decodeAssignmentRequest(wire).has_value());
|
||||
// The same value with any trailing byte does not.
|
||||
CHECK(!decodeAssignmentRequest(wire + "X").has_value());
|
||||
CHECK(!decodeAssignmentRequest(wire + "0:").has_value());
|
||||
}
|
||||
|
||||
// --- overflow / adversarial integer inputs ------------------------------------
|
||||
|
||||
// A 21-digit length field overflows SIZE_MAX and must be rejected safely (no UB,
|
||||
// no wrap-around that could make a huge length appear small and pass the bounds check).
|
||||
static void testOverflowFieldLength() {
|
||||
// Craft a wire where the bankId length token is 21 digits that exceed SIZE_MAX.
|
||||
// The decoder must fail cleanly, not access memory out of bounds.
|
||||
// "rsassign1" + "999999999999999999999:" (21 nines) + junk: rejects before OOB.
|
||||
const std::string wire = std::string("rsassign1") + "999999999999999999999:junk";
|
||||
CHECK(!decodeAssignmentRequest(wire).has_value());
|
||||
}
|
||||
|
||||
// A 20-digit generation (exceeds the 19-digit cap) must be rejected safely.
|
||||
static void testOverflowFieldInt64() {
|
||||
// Encode a valid record then manually substitute the generation with a 20-digit value.
|
||||
// We cannot use encode (it would produce a correct 19-digit generation), so we
|
||||
// build the wire manually. Generation "99999999999999999999" (20 nines) exceeds cap.
|
||||
// bankId = "pool" (4 bytes), sampleId = "s1" (2 bytes).
|
||||
const std::string wire = std::string("rsassign1")
|
||||
+ "4:pool"
|
||||
+ "2:s1"
|
||||
+ "20:99999999999999999999";
|
||||
CHECK(!decodeAssignmentRequest(wire).has_value());
|
||||
}
|
||||
|
||||
// SIZE_MAX as a length field (20 digits, within the digit-count cap) must not UB or
|
||||
// wrap. The overflow-guard in field() caps the multiplication; even if the value itself
|
||||
// does not trigger the multiply guard (SIZE_MAX accumulates cleanly digit by digit),
|
||||
// the subsequent "len > s_.size() - start" bounds check catches it because the actual
|
||||
// string is tiny — no OOB access, no wraparound, clean rejection.
|
||||
static void testOverflowExactSizeMax() {
|
||||
// 18446744073709551615 = SIZE_MAX on 64-bit. 20 digits: within the digit cap, but the
|
||||
// trailing bounds check rejects it because the wire string is far smaller than SIZE_MAX.
|
||||
const std::string wire = std::string("rsassign1") + "18446744073709551615:X";
|
||||
CHECK(!decodeAssignmentRequest(wire).has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testRoundTrip();
|
||||
testRoundTripPoolAndZeroGeneration();
|
||||
testRoundTripAdversarialIds();
|
||||
testRoundTripEmptyFields();
|
||||
testLargeGeneration();
|
||||
testMalformedParse();
|
||||
testTrailingGarbageRejected();
|
||||
testOverflowFieldLength();
|
||||
testOverflowFieldInt64();
|
||||
testOverflowExactSizeMax();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -36,6 +36,8 @@ static Sample fullSample(const std::string& seed) {
|
||||
s.captureTimeSigNum = 6; // L7 F1 meter stamp (non-4/4 to prove it round-trips)
|
||||
s.captureTimeSigDenom = 8;
|
||||
s.key = "F#m";
|
||||
s.rootNote = 60; // Phase S seam field (present)
|
||||
s.loop = LoopPoints{4096, 65536}; // Phase S seam field (present)
|
||||
s.levels = {-0.3, -12.7, -14.2};
|
||||
s.clipped = true;
|
||||
s.tier = Tier::Archive;
|
||||
@@ -86,11 +88,18 @@ static void testFullFieldRoundTrip() {
|
||||
CHECK(minMeter && minMeter->captureTimeSigNum == 0 && minMeter->captureTimeSigDenom == 0);
|
||||
CHECK(full && full->provenance.has_value());
|
||||
CHECK(full && full->provenance->fxChainSnapshot == "<FXCHAIN\n BYPASS 0 0 0\n>");
|
||||
// Phase S seam fields survive round-trip exactly.
|
||||
CHECK(full && full->rootNote.has_value() && *full->rootNote == 60);
|
||||
CHECK(full && full->loop.has_value());
|
||||
CHECK(full && full->loop && full->loop->start == 4096 && full->loop->end == 65536);
|
||||
|
||||
const Sample* min = back->query("min-b");
|
||||
CHECK(min && !min->key.has_value());
|
||||
CHECK(min && !min->provenance.has_value());
|
||||
CHECK(min && min->trackGuids.empty());
|
||||
// Seam fields absent on the minimal sample and stay absent.
|
||||
CHECK(min && !min->rootNote.has_value());
|
||||
CHECK(min && !min->loop.has_value());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,6 +426,128 @@ static void testEnumRangeValidation() {
|
||||
CHECK(!r2.has_value());
|
||||
}
|
||||
|
||||
// S2 test case 2: a legacy Sample JSON — written before the Phase S seam fields
|
||||
// existed, so it has NO "rootNote" or "loop" keys at all — parses to clean empty
|
||||
// optionals (no loss, no migration) and re-serializes without inventing values.
|
||||
// (The parser's forward-compat unknown-key skipping is what makes the reverse case
|
||||
// — new keys ignored by an old parser — safe too; here we test old-JSON→new-parser.)
|
||||
static void testLegacyJsonDefaults() {
|
||||
const char* legacy =
|
||||
"{\"samples\":[{\"id\":\"leg1\",\"relativePath\":\"bank/leg.wav\","
|
||||
"\"displayName\":\"legacy\","
|
||||
"\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":1.5,\"endSeconds\":2.5,"
|
||||
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
|
||||
"\"wetDry\":1.0,\"channelCount\":2,\"sampleRate\":48000,"
|
||||
"\"lengthSeconds\":1.0,\"lengthBeats\":0.0,\"captureTempo\":120.0,"
|
||||
"\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
|
||||
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-leg1\","
|
||||
"\"provenance\":null,\"createdTimestamp\":0}]}";
|
||||
auto r = BankIndex::deserialize(legacy);
|
||||
CHECK(r.has_value());
|
||||
if (r) {
|
||||
const Sample* s = r->query("leg1");
|
||||
CHECK(s != nullptr);
|
||||
CHECK(s && !s->rootNote.has_value()); // clean default, not a guessed value
|
||||
CHECK(s && !s->loop.has_value());
|
||||
|
||||
// Re-serialize is lossless: parsing it again yields an equal index. This
|
||||
// proves the absent fields did not silently gain values on the way out.
|
||||
std::string out = r->serialize();
|
||||
auto again = BankIndex::deserialize(out);
|
||||
CHECK(again.has_value());
|
||||
CHECK(again && *again == *r);
|
||||
if (again) {
|
||||
const Sample* s2 = again->query("leg1");
|
||||
CHECK(s2 && !s2->rootNote.has_value());
|
||||
CHECK(s2 && !s2->loop.has_value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// S2 test case 4: boundary values for the seam fields are representable and
|
||||
// round-trip. rootNote 0 and 127 (the MIDI edges); loopStart == loopEnd (a valid
|
||||
// zero-length marker); a loop whose end sits at the file's last frame. Also asserts
|
||||
// the deserialize-boundary validation rules reject out-of-range input rather than
|
||||
// storing a bogus value.
|
||||
static void testSeamFieldBoundaries() {
|
||||
// rootNote at both MIDI edges + equal-and-end-anchored loop points round-trip.
|
||||
BankIndex idx;
|
||||
Sample lo = minimalSample("lo"); lo.contentHash = "h-lo";
|
||||
lo.rootNote = 0;
|
||||
lo.loop = LoopPoints{0, 0}; // zero-length marker at frame 0
|
||||
Sample hi = minimalSample("hi"); hi.contentHash = "h-hi";
|
||||
hi.rootNote = 127;
|
||||
hi.loop = LoopPoints{100, 100}; // start == end elsewhere
|
||||
Sample end = minimalSample("end"); end.contentHash = "h-end";
|
||||
end.loop = LoopPoints{0, 9223372036854775807LL}; // end at max int64 frame
|
||||
CHECK(idx.add(lo) == AddResult::Added);
|
||||
CHECK(idx.add(hi) == AddResult::Added);
|
||||
CHECK(idx.add(end) == AddResult::Added);
|
||||
|
||||
auto back = BankIndex::deserialize(idx.serialize());
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == idx);
|
||||
if (back) {
|
||||
CHECK(back->query("min-lo")->rootNote == 0);
|
||||
CHECK(back->query("min-hi")->rootNote == 127);
|
||||
// Named local: a brace-init with a comma inside CHECK(...) would be parsed
|
||||
// as two macro arguments by the preprocessor.
|
||||
const LoopPoints zeroLen{0, 0};
|
||||
CHECK(back->query("min-lo")->loop == zeroLen);
|
||||
CHECK(back->query("min-end")->loop->end == 9223372036854775807LL);
|
||||
}
|
||||
|
||||
// Validation rule (chosen for this design, surfaced in the handoff):
|
||||
// rootNote must be 0..127; loop must satisfy 0 <= start <= end.
|
||||
// Out-of-range input is rejected at the deserialize boundary (nullopt), mirroring
|
||||
// the existing enum-range and integer-overflow rejections — never clamped.
|
||||
const char* head =
|
||||
"{\"samples\":[{\"id\":\"bad\",\"relativePath\":\"bank/b.wav\","
|
||||
"\"displayName\":\"\",\"sourceMode\":0,"
|
||||
"\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
|
||||
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
|
||||
"\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
|
||||
"\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
|
||||
"\"key\":null,";
|
||||
const char* tail =
|
||||
"\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
|
||||
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-bad\","
|
||||
"\"provenance\":null,\"createdTimestamp\":0}]}";
|
||||
|
||||
CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value());
|
||||
CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value());
|
||||
CHECK(!BankIndex::deserialize(
|
||||
std::string(head) + "\"loop\":{\"start\":10,\"end\":5}," + tail).has_value()); // start > end
|
||||
CHECK(!BankIndex::deserialize(
|
||||
std::string(head) + "\"loop\":{\"start\":-1,\"end\":5}," + tail).has_value()); // negative start
|
||||
}
|
||||
|
||||
// S2 test case 3: the seam-field addition is purely additive — dedup-by-hash, tier
|
||||
// moves/filtering, and BankIndex ordering are byte-for-byte unchanged by the
|
||||
// presence (or absence) of rootNote/loop. Two samples differing ONLY in seam fields
|
||||
// but sharing a content hash still collapse; a seam-populated sample tiers exactly
|
||||
// like any other.
|
||||
static void testSeamFieldsAdditiveInvariant() {
|
||||
BankIndex idx;
|
||||
Sample a = fullSample("z"); // has rootNote + loop populated
|
||||
CHECK(idx.add(a) == AddResult::Added);
|
||||
|
||||
// Same hash, seam fields cleared — dedup keys off contentHash only, so this
|
||||
// still collapses. Seam fields do NOT enter the dedup identity.
|
||||
Sample dup = fullSample("z2");
|
||||
dup.contentHash = a.contentHash;
|
||||
dup.rootNote.reset();
|
||||
dup.loop.reset();
|
||||
CHECK(idx.add(dup) == AddResult::Collapsed);
|
||||
CHECK(idx.size() == 1);
|
||||
|
||||
// Tier move on a seam-populated sample behaves exactly as before.
|
||||
CHECK(idx.query("id-z")->tier == Tier::Archive);
|
||||
CHECK(idx.moveTier("id-z", Tier::Scratch));
|
||||
CHECK(idx.query("id-z")->tier == Tier::Scratch);
|
||||
CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFullFieldRoundTrip();
|
||||
testDedupByHash();
|
||||
@@ -430,6 +561,9 @@ int main() {
|
||||
testUnicodeEscapeDecoding();
|
||||
testIntegerOverflow();
|
||||
testEnumRangeValidation();
|
||||
testLegacyJsonDefaults();
|
||||
testSeamFieldBoundaries();
|
||||
testSeamFieldsAdditiveInvariant();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Standalone tests for reasampler::vst::bank_sync — no REAPER, no VST3, no framework.
|
||||
// The S9 bank-generation change-detection + the S8 assignment-request consume DECISION
|
||||
// (the yes/no maths the instrument's off-audio-thread poll runs). The shell owns the
|
||||
// cadence + side effects; this proves the decision rules without a host.
|
||||
//
|
||||
// Covers: parseBankGeneration (absent/malformed/overflow/negative/valid whole-string),
|
||||
// formatBankGeneration round-trip, bankGenerationChanged, and every consumeDecision rule
|
||||
// (no request / not-newer / non-target / unresolvable-drop / apply), asserting both the
|
||||
// apply flag AND the advanced-marker value so a stale request is never re-evaluated.
|
||||
|
||||
#include "../src/vst/bank_sync.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- parseBankGeneration -----------------------------------------------------
|
||||
|
||||
static void testParseAbsentAndMalformed() {
|
||||
// Absent / empty -> generation 0 (the pre-S9 default; a project with no stamp).
|
||||
CHECK(parseBankGeneration("") == 0);
|
||||
CHECK(parseBankGeneration("") == kBankGenerationAbsent);
|
||||
// Malformed -> 0, never a crash, never a partial value.
|
||||
CHECK(parseBankGeneration("abc") == 0);
|
||||
CHECK(parseBankGeneration("12x") == 0); // trailing garbage rejects whole
|
||||
CHECK(parseBankGeneration("x12") == 0); // leading garbage
|
||||
CHECK(parseBankGeneration("1 2") == 0); // embedded space
|
||||
CHECK(parseBankGeneration("+5") == 0); // sign rejected
|
||||
CHECK(parseBankGeneration("-5") == 0); // negative rejected
|
||||
CHECK(parseBankGeneration(" 5") == 0); // leading space
|
||||
CHECK(parseBankGeneration("5.0") == 0); // decimal point
|
||||
}
|
||||
|
||||
static void testParseValid() {
|
||||
CHECK(parseBankGeneration("0") == 0);
|
||||
CHECK(parseBankGeneration("1") == 1);
|
||||
CHECK(parseBankGeneration("42") == 42);
|
||||
CHECK(parseBankGeneration("00042") == 42); // leading zeros are still digits -> 42
|
||||
CHECK(parseBankGeneration("9007199254740993") == 9007199254740993LL); // > 2^53
|
||||
}
|
||||
|
||||
static void testParseOverflow() {
|
||||
// A 19-digit int64-max is fine; anything past it rejects to 0 (never wraps).
|
||||
CHECK(parseBankGeneration("9223372036854775807") == 9223372036854775807LL); // INT64_MAX
|
||||
CHECK(parseBankGeneration("9223372036854775808") == 0); // INT64_MAX + 1 -> reject
|
||||
CHECK(parseBankGeneration("99999999999999999999") == 0); // 20 nines -> reject
|
||||
}
|
||||
|
||||
static void testFormatRoundTrip() {
|
||||
CHECK(formatBankGeneration(0) == "0");
|
||||
CHECK(formatBankGeneration(1) == "1");
|
||||
CHECK(formatBankGeneration(123456789) == "123456789");
|
||||
// Round-trips: format then parse yields the original for the valid domain.
|
||||
for (std::int64_t g : {std::int64_t{0}, std::int64_t{1}, std::int64_t{7},
|
||||
std::int64_t{9007199254740993LL}}) {
|
||||
CHECK(parseBankGeneration(formatBankGeneration(g)) == g);
|
||||
}
|
||||
}
|
||||
|
||||
// --- bankGenerationChanged ---------------------------------------------------
|
||||
|
||||
static void testGenerationChanged() {
|
||||
CHECK(!bankGenerationChanged(0, 0)); // pre-S9 idle: no stamp seen, no stamp now
|
||||
CHECK(bankGenerationChanged(0, 1)); // first bump after a pre-S9 baseline -> change
|
||||
CHECK(bankGenerationChanged(5, 6)); // normal increment
|
||||
CHECK(!bankGenerationChanged(6, 6)); // idle poll (coalesced): no change
|
||||
CHECK(bankGenerationChanged(6, 3)); // a project switch/reload can lower it -> change
|
||||
}
|
||||
|
||||
// --- consumeDecision ---------------------------------------------------------
|
||||
|
||||
static AssignmentRequest makeReq(const std::string& bank, const std::string& sample,
|
||||
std::int64_t gen) {
|
||||
AssignmentRequest r;
|
||||
r.bankId = bank;
|
||||
r.sampleId = sample;
|
||||
r.generation = gen;
|
||||
return r;
|
||||
}
|
||||
|
||||
// Rule 1a: no pending request -> nothing to do, marker unchanged.
|
||||
static void testNoRequest() {
|
||||
const auto d = consumeDecision(std::nullopt, /*lastConsumed*/ 5,
|
||||
/*resolves*/ true, /*isFocusedTarget*/ true);
|
||||
CHECK(!d.apply);
|
||||
CHECK(d.consumedGeneration == 5); // marker held
|
||||
}
|
||||
|
||||
// Rule 1b: a request no newer than what we already consumed (re-open case) -> no re-apply.
|
||||
static void testNotNewerNotReapplied() {
|
||||
// The persisted marker equals the request generation: the user already got this assign,
|
||||
// possibly changed away from it. It MUST NOT re-apply on re-open.
|
||||
const auto same = consumeDecision(makeReq("b", "s", 100), 100, true, true);
|
||||
CHECK(!same.apply);
|
||||
CHECK(same.consumedGeneration == 100); // unchanged
|
||||
// An older request (a stale value lingering) is likewise ignored.
|
||||
const auto older = consumeDecision(makeReq("b", "s", 90), 100, true, true);
|
||||
CHECK(!older.apply);
|
||||
CHECK(older.consumedGeneration == 100);
|
||||
}
|
||||
|
||||
// Rule 2: a NEW request but this instance is not the target -> do not apply AND do not
|
||||
// advance the marker (must stay eligible if focus later lands here — no thundering herd).
|
||||
static void testNonTargetStaysEligible() {
|
||||
const auto d = consumeDecision(makeReq("b", "s", 200), /*lastConsumed*/ 100,
|
||||
/*resolves*/ true, /*isFocusedTarget*/ false);
|
||||
CHECK(!d.apply);
|
||||
CHECK(d.consumedGeneration == 100); // marker NOT advanced -> still eligible later
|
||||
}
|
||||
|
||||
// Rule 3: a NEW request, target, but unresolvable -> DROP silently. Marker advances so it
|
||||
// is never re-evaluated, but no selection change (assignment_request.h reader requirement).
|
||||
static void testUnresolvableDroppedSilently() {
|
||||
const auto d = consumeDecision(makeReq("b", "deleted-sample", 200),
|
||||
/*lastConsumed*/ 100, /*resolves*/ false,
|
||||
/*isFocusedTarget*/ true);
|
||||
CHECK(!d.apply); // no selection change
|
||||
CHECK(d.consumedGeneration == 200); // consumed-as-seen: never re-evaluated
|
||||
CHECK(d.sampleId.empty()); // nothing to apply
|
||||
}
|
||||
|
||||
// Rule 4: a NEW request, target, resolvable -> APPLY selection + advance the marker.
|
||||
static void testAppliedWhenNewTargetResolvable() {
|
||||
const auto d = consumeDecision(makeReq("bank-7", "cap-42", 200),
|
||||
/*lastConsumed*/ 100, /*resolves*/ true,
|
||||
/*isFocusedTarget*/ true);
|
||||
CHECK(d.apply);
|
||||
CHECK(d.bankId == "bank-7");
|
||||
CHECK(d.sampleId == "cap-42");
|
||||
CHECK(d.consumedGeneration == 200);
|
||||
}
|
||||
|
||||
// Re-assigning the SAME sample id under a NEW generation must re-apply (the generation is
|
||||
// the disambiguator; a recapture/re-drop of the same id is a fresh assign, not a no-op).
|
||||
static void testSameIdNewGenerationReapplies() {
|
||||
// First consume at gen 100.
|
||||
const auto first = consumeDecision(makeReq("b", "s", 100), 50, true, true);
|
||||
CHECK(first.apply);
|
||||
CHECK(first.consumedGeneration == 100);
|
||||
// Same id, higher generation, marker now at 100 -> applies again.
|
||||
const auto second = consumeDecision(makeReq("b", "s", 150), 100, true, true);
|
||||
CHECK(second.apply);
|
||||
CHECK(second.sampleId == "s");
|
||||
CHECK(second.consumedGeneration == 150);
|
||||
}
|
||||
|
||||
// A fresh instance (lastConsumed == 0) applies a first assign — the default marker must not
|
||||
// swallow the first request.
|
||||
static void testFreshInstanceAppliesFirst() {
|
||||
const auto d = consumeDecision(makeReq("b", "s", 1), 0, true, true);
|
||||
CHECK(d.apply);
|
||||
CHECK(d.consumedGeneration == 1);
|
||||
}
|
||||
|
||||
// Re-import / dedup-collapse path: the extension ingests a sample that already exists in
|
||||
// the bank (dedup collapse: the bank_generation counter does NOT advance because no new
|
||||
// sample was added), but a new assign_request is still written with a HIGHER assign
|
||||
// generation (the ingest disambiguator, independent of bank_generation).
|
||||
//
|
||||
// The consumeDecision must apply the request — its own generation is the "is this new?"
|
||||
// discriminator, and it is strictly greater than lastConsumed. bank_generation does not
|
||||
// enter consumeDecision at all; this test proves the two counters are fully independent.
|
||||
static void testDedupCollapseAssignAppliesWhenBankGenerationUnchanged() {
|
||||
// Simulate: bank_generation is 5 both before and after the dedup ingest (unchanged).
|
||||
// The assign_request generation is 300 (new; lastConsumed was 200 from the prior assign).
|
||||
// The existing sample resolves (it is in the bank — dedup kept it there).
|
||||
const auto d = consumeDecision(makeReq("pool", "existing-sample-id", 300),
|
||||
/*lastConsumed*/ 200, /*resolves*/ true,
|
||||
/*isFocusedTarget*/ true);
|
||||
CHECK(d.apply);
|
||||
CHECK(d.bankId == "pool");
|
||||
CHECK(d.sampleId == "existing-sample-id");
|
||||
CHECK(d.consumedGeneration == 300); // marker advanced to the new assign generation
|
||||
// bank_generation (5) is not a parameter here — this test documents its absence from
|
||||
// consumeDecision: only the assign_request's own generation drives the consume decision.
|
||||
}
|
||||
|
||||
int main() {
|
||||
testParseAbsentAndMalformed();
|
||||
testParseValid();
|
||||
testParseOverflow();
|
||||
testFormatRoundTrip();
|
||||
testGenerationChanged();
|
||||
testNoRequest();
|
||||
testNotNewerNotReapplied();
|
||||
testNonTargetStaysEligible();
|
||||
testUnresolvableDroppedSilently();
|
||||
testAppliedWhenNewTargetResolvable();
|
||||
testSameIdNewGenerationReapplies();
|
||||
testFreshInstanceAppliesFirst();
|
||||
testDedupCollapseAssignAppliesWhenBankGenerationUnchanged();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Standalone tests for reasampler::vst::bridge_marshal — no VST3, no REAPER, no test
|
||||
// framework. Same fast assert loop as the sibling pure tests: assert the REAPER
|
||||
// bridge-read marshalling (GetProjExtState result decode) directly, so the DAW-facing
|
||||
// shell only has to invoke the API.
|
||||
//
|
||||
// Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer
|
||||
// guard). The S1 spike's extractJsonStringField string-scan reader was retired in S4
|
||||
// (the instrument now parses the bank through the shared bank_book JSON path), so its
|
||||
// cases are gone with it.
|
||||
|
||||
#include "../src/vst/bridge_marshal.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- decodeGetProjExtState ----------------------------------------------------
|
||||
|
||||
static void testDecodeHit() {
|
||||
// REAPER reports a non-zero length and filled the buffer: that IS the value.
|
||||
auto v = decodeGetProjExtState(5, "hello");
|
||||
CHECK(v.has_value());
|
||||
CHECK(v && *v == "hello");
|
||||
}
|
||||
|
||||
static void testDecodeAbsentKey() {
|
||||
// REAPER returns 0 for an absent key. Even if a caller passed a dirty buffer, the
|
||||
// decoder must NOT surface it — the zero return means "no value".
|
||||
auto v = decodeGetProjExtState(0, "stale-bytes-from-a-prior-read");
|
||||
CHECK(!v.has_value());
|
||||
}
|
||||
|
||||
static void testDecodeNegativeReturn() {
|
||||
auto v = decodeGetProjExtState(-1, "whatever");
|
||||
CHECK(!v.has_value());
|
||||
}
|
||||
|
||||
static void testDecodeEmptyBuffer() {
|
||||
// Positive return but empty buffer — treat as no value (defensive).
|
||||
auto v = decodeGetProjExtState(3, "");
|
||||
CHECK(!v.has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDecodeHit();
|
||||
testDecodeAbsentKey();
|
||||
testDecodeNegativeReturn();
|
||||
testDecodeEmptyBuffer();
|
||||
|
||||
if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Standalone tests for reasampler::vst::browser_scroll — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure editor tests: assert the S12 scroll-window +
|
||||
// scrollbar-thumb + type-to-filter-search geometry LAYERED over the S10 capture_browser.
|
||||
//
|
||||
// Covers: scrollContentHeight (ceil rows * card height, 0 for no cards); scrollMaxOffset (0
|
||||
// when content fits, else content-visible); clampScrollOffset pinning to [0,max]; visibleCardRange
|
||||
// windowing (top rows only, scrolled window, empty when scrolled past the end); scrolledCardCellRect
|
||||
// shifting a cell up by the offset; scrollThumbRect (empty when it fits, proportional height +
|
||||
// position, minimum height, at-max pins to the track bottom); thumbDragToOffset as the position
|
||||
// inverse (a full-track drag reaches max, round-trips); searchBoxRect; nameMatchesQuery
|
||||
// (case-insensitive substring, empty-query identity, no-match); filterNameIndices preserving order
|
||||
// and returning every index for an empty query.
|
||||
|
||||
#include "../src/vst/browser_scroll.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// A layout wide enough for a few columns and tall enough to show a few rows.
|
||||
static BrowserLayout wideLayout() { return layoutBrowser(560, 300); }
|
||||
|
||||
// --- content / max / clamp ----------------------------------------------------
|
||||
|
||||
static void testContentHeight() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
CHECK(scrollContentHeight(L, 0) == 0);
|
||||
// One card -> one row -> one card height.
|
||||
CHECK(scrollContentHeight(L, 1) == kBrowserCardHeight);
|
||||
// columns+1 cards -> two rows.
|
||||
CHECK(scrollContentHeight(L, L.columns + 1) == 2 * kBrowserCardHeight);
|
||||
// Exactly `columns` cards -> one row.
|
||||
CHECK(scrollContentHeight(L, L.columns) == kBrowserCardHeight);
|
||||
}
|
||||
|
||||
static void testMaxOffsetFitsAndOverflows() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
// A single row fits within the 300px area -> no scroll.
|
||||
CHECK(scrollMaxOffset(L, L.columns) == 0);
|
||||
// Many rows overflow -> max = content - gridHeight.
|
||||
const int many = L.columns * 20;
|
||||
const int expect = scrollContentHeight(L, many) - L.grid.height();
|
||||
CHECK(scrollMaxOffset(L, many) == expect);
|
||||
CHECK(expect > 0);
|
||||
}
|
||||
|
||||
static void testClamp() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
const int many = L.columns * 20;
|
||||
const int maxOff = scrollMaxOffset(L, many);
|
||||
CHECK(clampScrollOffset(L, many, -50) == 0);
|
||||
CHECK(clampScrollOffset(L, many, maxOff + 500) == maxOff);
|
||||
CHECK(clampScrollOffset(L, many, maxOff / 2) == maxOff / 2);
|
||||
}
|
||||
|
||||
// --- visible window -----------------------------------------------------------
|
||||
|
||||
static void testVisibleRangeTop() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
const int many = L.columns * 20;
|
||||
const VisibleRange vr = visibleCardRange(L, many, 0);
|
||||
CHECK(vr.first == 0);
|
||||
// At offset 0, the last visible row is the one containing (gridH-1).
|
||||
const int expectedLastRow = (L.grid.height() - 1) / kBrowserCardHeight + 1;
|
||||
CHECK(vr.last == expectedLastRow * L.columns);
|
||||
}
|
||||
|
||||
static void testVisibleRangeScrolled() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
const int many = L.columns * 20;
|
||||
// Scroll one full card row down.
|
||||
const VisibleRange vr = visibleCardRange(L, many, kBrowserCardHeight);
|
||||
CHECK(vr.first == L.columns); // the first row scrolled off the top
|
||||
}
|
||||
|
||||
static void testVisibleRangeEmptyWhenNoCards() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
const VisibleRange vr = visibleCardRange(L, 0, 0);
|
||||
CHECK(vr.first == 0 && vr.last == 0);
|
||||
}
|
||||
|
||||
static void testScrolledCellShiftsUp() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
const Rect base = cardCellRect(L, 3);
|
||||
const Rect shifted = scrolledCardCellRect(L, 3, 40);
|
||||
CHECK(shifted.top == base.top - 40);
|
||||
CHECK(shifted.bottom == base.bottom - 40);
|
||||
CHECK(shifted.left == base.left);
|
||||
}
|
||||
|
||||
// --- scrollbar thumb ----------------------------------------------------------
|
||||
|
||||
static void testThumbEmptyWhenFits() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
CHECK(scrollThumbRect(L, L.columns, 0).height() == 0); // one row fits -> no thumb
|
||||
}
|
||||
|
||||
static void testThumbProportionalAndClamped() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
const int many = L.columns * 20;
|
||||
const Rect atTop = scrollThumbRect(L, many, 0);
|
||||
CHECK(atTop.height() > 0);
|
||||
CHECK(atTop.top == L.grid.top); // at offset 0 the thumb starts at the track top
|
||||
CHECK(atTop.width() == kScrollbarWidth);
|
||||
CHECK(atTop.right == L.grid.right);
|
||||
// At max offset, the thumb bottom reaches the grid bottom (pinned to the end).
|
||||
const int maxOff = scrollMaxOffset(L, many);
|
||||
const Rect atMax = scrollThumbRect(L, many, maxOff);
|
||||
CHECK(atMax.bottom == L.grid.top + L.grid.height());
|
||||
}
|
||||
|
||||
static void testThumbDragIsInverse() {
|
||||
const BrowserLayout L = wideLayout();
|
||||
const int many = L.columns * 20;
|
||||
const int maxOff = scrollMaxOffset(L, many);
|
||||
// A zero drag holds the start offset.
|
||||
CHECK(thumbDragToOffset(L, many, 0, 0) == 0);
|
||||
// A large positive drag pins to max; a large negative drag pins to 0.
|
||||
CHECK(thumbDragToOffset(L, many, 0, 100000) == maxOff);
|
||||
CHECK(thumbDragToOffset(L, many, maxOff, -100000) == 0);
|
||||
// Dragging the thumb by the whole track span from top reaches (near) max.
|
||||
const Rect thumb = scrollThumbRect(L, many, 0);
|
||||
const int trackSpan = L.grid.height() - thumb.height();
|
||||
const int off = thumbDragToOffset(L, many, 0, trackSpan);
|
||||
CHECK(off >= maxOff - 2 && off <= maxOff);
|
||||
}
|
||||
|
||||
// --- search -------------------------------------------------------------------
|
||||
|
||||
static void testSearchBoxRect() {
|
||||
const Rect r = searchBoxRect(200);
|
||||
CHECK(r.left == 0 && r.top == 0 && r.right == 200 && r.height() == kSearchBoxHeight);
|
||||
CHECK(searchBoxRect(0).width() == 0);
|
||||
}
|
||||
|
||||
static void testNameMatch() {
|
||||
CHECK(nameMatchesQuery("Kick Drum 01", "")); // empty query matches all
|
||||
CHECK(nameMatchesQuery("Kick Drum 01", "drum")); // case-insensitive substring
|
||||
CHECK(nameMatchesQuery("Kick Drum 01", "KICK"));
|
||||
CHECK(!nameMatchesQuery("Kick Drum 01", "snare"));
|
||||
CHECK(!nameMatchesQuery("ab", "abc")); // query longer than name
|
||||
}
|
||||
|
||||
static void testFilterIndices() {
|
||||
std::vector<std::string> names{"Kick", "Snare", "Kick Sub", "Hat"};
|
||||
// Empty query -> every index, in order.
|
||||
const std::vector<int> all = filterNameIndices(names, "");
|
||||
CHECK(all.size() == 4 && all[0] == 0 && all[3] == 3);
|
||||
// "kick" -> indices 0 and 2, order preserved.
|
||||
const std::vector<int> kicks = filterNameIndices(names, "kick");
|
||||
CHECK(kicks.size() == 2 && kicks[0] == 0 && kicks[1] == 2);
|
||||
// No match -> empty.
|
||||
CHECK(filterNameIndices(names, "zzz").empty());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testContentHeight();
|
||||
testMaxOffsetFitsAndOverflows();
|
||||
testClamp();
|
||||
testVisibleRangeTop();
|
||||
testVisibleRangeScrolled();
|
||||
testVisibleRangeEmptyWhenNoCards();
|
||||
testScrolledCellShiftsUp();
|
||||
testThumbEmptyWhenFits();
|
||||
testThumbProportionalAndClamped();
|
||||
testThumbDragIsInverse();
|
||||
testSearchBoxRect();
|
||||
testNameMatch();
|
||||
testFilterIndices();
|
||||
|
||||
if (g_fail == 0) std::printf("browser_scroll: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Standalone tests for reasampler::vst::capture_browser — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure tests (embed_strip / editor_geometry): assert
|
||||
// the capture-first browser's card-grid + bank-filter-tab layout and hit-testing directly.
|
||||
//
|
||||
// Covers: layoutBrowser splitting an area into the tab strip + card grid and deriving the
|
||||
// column count; a tiny/zero area (no inversion, columns >= 1); cardCellRect / cardContentRect
|
||||
// / cardThumbnailRect / cardLabelRect tiling row-major across columns with the gutter inset
|
||||
// and the thumbnail band above the label; cardHitTest landing on the card content (and MISSING
|
||||
// in the inter-card gutter, past the last card, and on the tab strip); filterTabRect dividing
|
||||
// the strip into equal segments with the last tab absorbing the remainder; filterTabHitTest
|
||||
// hitting each tab and missing off-strip.
|
||||
|
||||
#include "../src/vst/capture_browser.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- layoutBrowser ------------------------------------------------------------
|
||||
|
||||
static void testLayoutNormalArea() {
|
||||
// Wide enough for several columns of the fixed-width card.
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
CHECK(L.tabStrip.left == 0 && L.tabStrip.top == 0 && L.tabStrip.right == 560);
|
||||
CHECK(L.tabStrip.height() == kBrowserTabHeight);
|
||||
// The grid starts right below the tab strip and fills the rest, contiguous.
|
||||
CHECK(L.grid.top == L.tabStrip.bottom);
|
||||
CHECK(L.grid.bottom == 300 && L.grid.right == 560);
|
||||
// columns = grid.width() / cardWidth (>= 1).
|
||||
CHECK(L.columns == 560 / kBrowserCardWidth);
|
||||
CHECK(L.columns >= 1);
|
||||
}
|
||||
|
||||
static void testLayoutNarrowAreaSingleColumn() {
|
||||
// Narrower than one card: still a single column, no inversion.
|
||||
const BrowserLayout L = layoutBrowser(kBrowserCardWidth - 10, 200);
|
||||
CHECK(L.columns == 1);
|
||||
CHECK(L.grid.width() >= 0);
|
||||
CHECK(L.tabStrip.height() == kBrowserTabHeight);
|
||||
}
|
||||
|
||||
static void testLayoutZeroArea() {
|
||||
const BrowserLayout L = layoutBrowser(0, 0);
|
||||
CHECK(L.tabStrip.width() == 0 && L.tabStrip.height() == 0);
|
||||
CHECK(L.grid.width() == 0);
|
||||
CHECK(L.columns == 1); // never zero (avoids a divide-by-zero in card layout)
|
||||
}
|
||||
|
||||
static void testLayoutTinyHeightClampsTabStrip() {
|
||||
// A height below the tab band: the tab strip clamps to the area, the grid is empty.
|
||||
const BrowserLayout L = layoutBrowser(560, kBrowserTabHeight - 6);
|
||||
CHECK(L.tabStrip.height() == kBrowserTabHeight - 6);
|
||||
CHECK(L.grid.height() <= 0); // no room left for cards
|
||||
}
|
||||
|
||||
// --- card rects ---------------------------------------------------------------
|
||||
|
||||
static void testCardCellsTileRowMajor() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
const int cols = L.columns;
|
||||
// Card 0 is top-left of the grid.
|
||||
const Rect c0 = cardCellRect(L, 0);
|
||||
CHECK(c0.left == L.grid.left && c0.top == L.grid.top);
|
||||
CHECK(c0.width() == kBrowserCardWidth && c0.height() == kBrowserCardHeight);
|
||||
// Card 1 is one card-width to the right, same row.
|
||||
const Rect c1 = cardCellRect(L, 1);
|
||||
CHECK(c1.left == L.grid.left + kBrowserCardWidth);
|
||||
CHECK(c1.top == c0.top);
|
||||
// The first card of the SECOND row wraps back to the left, one card-height down.
|
||||
const Rect wrap = cardCellRect(L, cols);
|
||||
CHECK(wrap.left == L.grid.left);
|
||||
CHECK(wrap.top == L.grid.top + kBrowserCardHeight);
|
||||
}
|
||||
|
||||
static void testCardCellNegativeIndex() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
const Rect r = cardCellRect(L, -1);
|
||||
CHECK(r.left == 0 && r.top == 0 && r.right == 0 && r.bottom == 0);
|
||||
}
|
||||
|
||||
static void testCardContentInsetByGutter() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
const Rect cell = cardCellRect(L, 0);
|
||||
const Rect content = cardContentRect(L, 0);
|
||||
CHECK(content.left == cell.left + kBrowserCardGutter);
|
||||
CHECK(content.top == cell.top + kBrowserCardGutter);
|
||||
CHECK(content.right == cell.right - kBrowserCardGutter);
|
||||
CHECK(content.bottom == cell.bottom - kBrowserCardGutter);
|
||||
}
|
||||
|
||||
static void testThumbnailAboveLabel() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
const Rect content = cardContentRect(L, 0);
|
||||
const Rect thumb = cardThumbnailRect(L, 0);
|
||||
const Rect label = cardLabelRect(L, 0);
|
||||
// Thumbnail is the top band of the content; the label is the remainder below it, contiguous.
|
||||
CHECK(thumb.left == content.left && thumb.right == content.right);
|
||||
CHECK(thumb.top == content.top);
|
||||
CHECK(thumb.height() == kBrowserThumbHeight);
|
||||
CHECK(label.top == thumb.bottom);
|
||||
CHECK(label.bottom == content.bottom);
|
||||
CHECK(label.left == content.left && label.right == content.right);
|
||||
}
|
||||
|
||||
// --- cardHitTest --------------------------------------------------------------
|
||||
|
||||
static void testCardHitCenterOfCard() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
const Rect content = cardContentRect(L, 3);
|
||||
const int cx = content.left + content.width() / 2;
|
||||
const int cy = content.top + content.height() / 2;
|
||||
CHECK(cardHitTest(L, 12, cx, cy) == 3);
|
||||
}
|
||||
|
||||
static void testCardHitMissesGutter() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
// A point in the gutter between the content and the cell edge (top-left corner of cell 0)
|
||||
// is a miss — only the card CONTENT counts.
|
||||
const Rect cell = cardCellRect(L, 0);
|
||||
CHECK(cardHitTest(L, 12, cell.left, cell.top) == -1);
|
||||
}
|
||||
|
||||
static void testCardHitMissesPastLastCard() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
// Only 2 cards exist; a point on where card 5 WOULD be is a miss.
|
||||
const Rect content = cardContentRect(L, 5);
|
||||
const int cx = content.left + content.width() / 2;
|
||||
const int cy = content.top + content.height() / 2;
|
||||
CHECK(cardHitTest(L, 2, cx, cy) == -1);
|
||||
}
|
||||
|
||||
static void testCardHitMissesTabStrip() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
CHECK(cardHitTest(L, 12, 10, L.tabStrip.top + 2) == -1);
|
||||
}
|
||||
|
||||
static void testCardHitZeroCards() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
CHECK(cardHitTest(L, 0, 20, 40) == -1);
|
||||
}
|
||||
|
||||
// --- filter tabs --------------------------------------------------------------
|
||||
|
||||
static void testFilterTabsTileStrip() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
const int n = 4; // "All" + 3 banks
|
||||
const Rect t0 = filterTabRect(L, n, 0);
|
||||
const Rect tLast = filterTabRect(L, n, n - 1);
|
||||
CHECK(t0.left == L.tabStrip.left);
|
||||
// Adjacent tabs share an exact edge (no gap).
|
||||
CHECK(filterTabRect(L, n, 0).right == filterTabRect(L, n, 1).left);
|
||||
CHECK(filterTabRect(L, n, 1).right == filterTabRect(L, n, 2).left);
|
||||
// The last tab reaches the strip's right edge exactly (absorbs the remainder).
|
||||
CHECK(tLast.right == L.tabStrip.right);
|
||||
// All tabs share the strip's height.
|
||||
CHECK(t0.top == L.tabStrip.top && t0.bottom == L.tabStrip.bottom);
|
||||
}
|
||||
|
||||
static void testFilterTabOutOfRange() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
CHECK(filterTabRect(L, 3, -1).width() == 0);
|
||||
CHECK(filterTabRect(L, 3, 3).width() == 0);
|
||||
CHECK(filterTabRect(L, 0, 0).width() == 0);
|
||||
}
|
||||
|
||||
static void testFilterTabHit() {
|
||||
const BrowserLayout L = layoutBrowser(560, 300);
|
||||
const int n = 3;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const Rect t = filterTabRect(L, n, i);
|
||||
const int cx = t.left + t.width() / 2;
|
||||
const int cy = t.top + t.height() / 2;
|
||||
CHECK(filterTabHitTest(L, n, cx, cy) == i);
|
||||
}
|
||||
// Below the strip (in the grid) -> no tab.
|
||||
CHECK(filterTabHitTest(L, n, 20, L.grid.top + 4) == -1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLayoutNormalArea();
|
||||
testLayoutNarrowAreaSingleColumn();
|
||||
testLayoutZeroArea();
|
||||
testLayoutTinyHeightClampsTabStrip();
|
||||
testCardCellsTileRowMajor();
|
||||
testCardCellNegativeIndex();
|
||||
testCardContentInsetByGutter();
|
||||
testThumbnailAboveLabel();
|
||||
testCardHitCenterOfCard();
|
||||
testCardHitMissesGutter();
|
||||
testCardHitMissesPastLastCard();
|
||||
testCardHitMissesTabStrip();
|
||||
testCardHitZeroCards();
|
||||
testFilterTabsTileStrip();
|
||||
testFilterTabOutOfRange();
|
||||
testFilterTabHit();
|
||||
|
||||
if (g_fail == 0) std::printf("capture_browser: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// Standalone tests for reasampler::vst::editor_geometry — no VST3, no REAPER, no test
|
||||
// framework. Same fast assert loop as the sibling pure tests (mode_switch et al.):
|
||||
// assert the IPlugView LICE editor's layout math + hit-testing directly.
|
||||
//
|
||||
// Covers: contains() half-open convention + degenerate rects; layoutEditor regions on a
|
||||
// normal view (title band + button + canvas), a tiny view (button clamped to canvas,
|
||||
// never overhanging), and a zero view (all rects empty, no inversion); hitTest hitting
|
||||
// the button, missing on the title/canvas, missing outside the surface, and boundary
|
||||
// pixels; layout<->hit-test agreement (a click on the drawn button rect hits it).
|
||||
|
||||
#include "../src/vst/editor_geometry.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- contains() ---------------------------------------------------------------
|
||||
|
||||
static void testContainsHalfOpen() {
|
||||
Rect r{10, 20, 50, 40}; // [10,50) x [20,40)
|
||||
CHECK(contains(r, 10, 20)); // top-left inclusive
|
||||
CHECK(contains(r, 49, 39)); // bottom-right exclusive edge, inside
|
||||
CHECK(!contains(r, 50, 30)); // right edge excluded
|
||||
CHECK(!contains(r, 30, 40)); // bottom edge excluded
|
||||
CHECK(!contains(r, 9, 30)); // left of rect
|
||||
CHECK(!contains(r, 30, 19)); // above rect
|
||||
}
|
||||
|
||||
static void testContainsDegenerate() {
|
||||
CHECK(!contains(Rect{10, 10, 10, 20}, 10, 15)); // zero width
|
||||
CHECK(!contains(Rect{10, 10, 20, 10}, 15, 10)); // zero height
|
||||
CHECK(!contains(Rect{20, 10, 10, 20}, 15, 15)); // inverted (right < left)
|
||||
}
|
||||
|
||||
// --- layoutEditor: normal view ------------------------------------------------
|
||||
|
||||
static void testLayoutNormalView() {
|
||||
// A comfortable 400x260 view: title band spans the top full width; canvas is the
|
||||
// rest; button sits inside the canvas, inset by the margin.
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
|
||||
CHECK(L.titleBar.left == 0 && L.titleBar.top == 0);
|
||||
CHECK(L.titleBar.right == 400);
|
||||
CHECK(L.titleBar.height() > 0 && L.titleBar.height() <= 260);
|
||||
|
||||
// Canvas begins right below the title bar and reaches the bottom-right.
|
||||
CHECK(L.canvas.top == L.titleBar.bottom);
|
||||
CHECK(L.canvas.right == 400 && L.canvas.bottom == 260);
|
||||
|
||||
// Button is inside the canvas (does not overhang any edge).
|
||||
CHECK(L.button.left >= L.canvas.left);
|
||||
CHECK(L.button.top >= L.canvas.top);
|
||||
CHECK(L.button.right <= L.canvas.right);
|
||||
CHECK(L.button.bottom <= L.canvas.bottom);
|
||||
CHECK(L.button.width() > 0 && L.button.height() > 0);
|
||||
}
|
||||
|
||||
// --- layoutEditor: tiny view (clamping) ---------------------------------------
|
||||
|
||||
static void testLayoutTinyViewClampsButton() {
|
||||
// A view narrower/shorter than the button's natural size: the button must clamp to
|
||||
// the canvas and never produce an inverted or overhanging rect.
|
||||
const EditorLayout L = layoutEditor(40, 40);
|
||||
CHECK(L.button.right <= L.canvas.right);
|
||||
CHECK(L.button.bottom <= L.canvas.bottom);
|
||||
CHECK(L.button.right >= L.button.left); // never inverted
|
||||
CHECK(L.button.bottom >= L.button.top);
|
||||
// Title bar clamps to the client height when the view is shorter than its height.
|
||||
CHECK(L.titleBar.bottom <= 40);
|
||||
}
|
||||
|
||||
// --- layoutEditor: zero view (all empty, no inversion) ------------------------
|
||||
|
||||
static void testLayoutZeroView() {
|
||||
const EditorLayout L = layoutEditor(0, 0);
|
||||
CHECK(L.titleBar.width() <= 0 || L.titleBar.height() <= 0);
|
||||
CHECK(L.canvas.width() <= 0 || L.canvas.height() <= 0);
|
||||
// No rect is inverted.
|
||||
CHECK(L.button.right >= L.button.left);
|
||||
CHECK(L.button.bottom >= L.button.top);
|
||||
CHECK(L.canvas.right >= L.canvas.left);
|
||||
CHECK(L.canvas.bottom >= L.canvas.top);
|
||||
// A click anywhere on an empty layout hits nothing.
|
||||
CHECK(hitTest(L, 0, 0) == HitTarget::kNone);
|
||||
CHECK(hitTest(L, 5, 5) == HitTarget::kNone);
|
||||
}
|
||||
|
||||
// --- hitTest ------------------------------------------------------------------
|
||||
|
||||
static void testHitTestButton() {
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
// Center of the button hits it.
|
||||
const int cx = (L.button.left + L.button.right) / 2;
|
||||
const int cy = (L.button.top + L.button.bottom) / 2;
|
||||
CHECK(hitTest(L, cx, cy) == HitTarget::kButton);
|
||||
}
|
||||
|
||||
static void testHitTestMissesNonButton() {
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
// Title bar is inert in the spike.
|
||||
CHECK(hitTest(L, 200, L.titleBar.top + 1) == HitTarget::kNone);
|
||||
// Empty canvas away from the button.
|
||||
CHECK(hitTest(L, 380, 240) == HitTarget::kNone);
|
||||
// Outside the surface entirely.
|
||||
CHECK(hitTest(L, -5, -5) == HitTarget::kNone);
|
||||
CHECK(hitTest(L, 500, 500) == HitTarget::kNone);
|
||||
}
|
||||
|
||||
static void testHitTestButtonBoundary() {
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
// Top-left corner of the button is inclusive; the right/bottom edges are excluded.
|
||||
CHECK(hitTest(L, L.button.left, L.button.top) == HitTarget::kButton);
|
||||
CHECK(hitTest(L, L.button.right, L.button.top) == HitTarget::kNone);
|
||||
CHECK(hitTest(L, L.button.left, L.button.bottom) == HitTarget::kNone);
|
||||
}
|
||||
|
||||
// --- layout<->hit-test agreement ----------------------------------------------
|
||||
|
||||
// Every pixel inside the drawn button rect must hit the button; this is the
|
||||
// load-bearing consistency invariant between what the shell draws and what it routes.
|
||||
static void testHitTestMatchesDrawnButton() {
|
||||
const EditorLayout L = layoutEditor(320, 200);
|
||||
for (int y = L.button.top; y < L.button.bottom; ++y) {
|
||||
for (int x = L.button.left; x < L.button.right; ++x) {
|
||||
CHECK(hitTest(L, x, y) == HitTarget::kButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- sample list (S4) ---------------------------------------------------------
|
||||
|
||||
static void testSampleRowRectStacks() {
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
const Rect r0 = sampleRowRect(L, 0);
|
||||
const Rect r1 = sampleRowRect(L, 1);
|
||||
// Row 0 starts at the canvas top and spans its full width.
|
||||
CHECK(r0.top == L.canvas.top);
|
||||
CHECK(r0.left == L.canvas.left && r0.right == L.canvas.right);
|
||||
CHECK(r0.height() == kSampleRowHeight);
|
||||
// Row 1 sits directly below row 0 (no gap, no overlap).
|
||||
CHECK(r1.top == r0.bottom);
|
||||
CHECK(r1.height() == kSampleRowHeight);
|
||||
// A negative index is an empty rect.
|
||||
CHECK(sampleRowRect(L, -1).width() == 0 && sampleRowRect(L, -1).height() == 0);
|
||||
}
|
||||
|
||||
static void testSampleRowHitTestMapsClickToRow() {
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
const int rows = 5;
|
||||
// A click in the vertical middle of row 2 resolves to index 2.
|
||||
const Rect r2 = sampleRowRect(L, 2);
|
||||
const int midY = (r2.top + r2.bottom) / 2;
|
||||
CHECK(sampleRowHitTest(L, rows, 200, midY) == 2);
|
||||
// Row 0's top-left corner hits row 0.
|
||||
const Rect r0 = sampleRowRect(L, 0);
|
||||
CHECK(sampleRowHitTest(L, rows, r0.left, r0.top) == 0);
|
||||
}
|
||||
|
||||
static void testSampleRowHitTestMisses() {
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
const int rows = 3;
|
||||
// Above the first row (in the title bar) -> no row.
|
||||
CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.top) == -1);
|
||||
// Below the last row -> no row.
|
||||
const Rect last = sampleRowRect(L, rows - 1);
|
||||
CHECK(sampleRowHitTest(L, rows, 200, last.bottom + 1) == -1);
|
||||
// Left of the canvas -> no row.
|
||||
CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1);
|
||||
// Zero rows -> always -1.
|
||||
CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1);
|
||||
// At or below canvas.bottom -> always -1, even if rowCount would cover that y.
|
||||
// This guards paint<->hit-test agreement: sampleRowRect does not clamp to canvas,
|
||||
// so without this clip a row that extends past canvas.bottom would hit-test but
|
||||
// never be drawn (or vice versa).
|
||||
CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom) == -1);
|
||||
// Use a large rowCount so index arithmetic would return a valid row without the
|
||||
// canvas.bottom guard — proving the guard fires independently of rowCount.
|
||||
const int bigRows = 1000;
|
||||
CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom) == -1);
|
||||
CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom + 5) == -1);
|
||||
}
|
||||
|
||||
// The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to
|
||||
// that row's index (the same load-bearing invariant as the button).
|
||||
static void testSampleRowHitTestMatchesDrawnRows() {
|
||||
const EditorLayout L = layoutEditor(320, 200);
|
||||
const int rows = 4;
|
||||
for (int i = 0; i < rows; ++i) {
|
||||
const Rect r = sampleRowRect(L, i);
|
||||
if (r.top >= L.canvas.bottom) break; // clipped rows aren't clickable targets
|
||||
const int y = (r.top + r.bottom) / 2;
|
||||
if (y >= L.canvas.bottom) continue;
|
||||
CHECK(sampleRowHitTest(L, rows, r.left + 1, y) == i);
|
||||
}
|
||||
}
|
||||
|
||||
// --- keymap editor (S5 Tier-1 UI) --------------------------------------------
|
||||
|
||||
static void testKeymapLayoutSplitsCanvas() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
// The left sample list and right zone panel partition the canvas with no overlap and
|
||||
// no gap: the list's right edge is the panel's left edge.
|
||||
CHECK(L.sampleList.left == L.base.canvas.left);
|
||||
CHECK(L.sampleList.right == L.zonePanel.left);
|
||||
CHECK(L.zonePanel.right == L.base.canvas.right);
|
||||
CHECK(L.sampleList.top == L.base.canvas.top);
|
||||
CHECK(L.zonePanel.top == L.base.canvas.top);
|
||||
CHECK(L.sampleList.bottom == L.base.canvas.bottom);
|
||||
CHECK(L.zonePanel.bottom == L.base.canvas.bottom);
|
||||
CHECK(L.sampleList.width() > 0 && L.zonePanel.width() > 0);
|
||||
// Add-Zone button caps the panel; zone rows stack below it.
|
||||
CHECK(L.addZoneButton.top == L.zonePanel.top);
|
||||
CHECK(L.addZoneButton.left == L.zonePanel.left && L.addZoneButton.right == L.zonePanel.right);
|
||||
CHECK(L.zoneRowArea.top == L.addZoneButton.bottom);
|
||||
CHECK(L.zoneRowArea.bottom == L.zonePanel.bottom);
|
||||
}
|
||||
|
||||
static void checkNoInversion(const KeymapEditorLayout& L) {
|
||||
CHECK(L.sampleList.right >= L.sampleList.left);
|
||||
CHECK(L.zonePanel.right >= L.zonePanel.left);
|
||||
CHECK(L.addZoneButton.right >= L.addZoneButton.left);
|
||||
CHECK(L.addZoneButton.bottom >= L.addZoneButton.top);
|
||||
CHECK(L.zoneRowArea.right >= L.zoneRowArea.left);
|
||||
CHECK(L.zoneRowArea.bottom >= L.zoneRowArea.top);
|
||||
// Regions stay within the client area.
|
||||
CHECK(L.zonePanel.right <= L.base.canvas.right);
|
||||
}
|
||||
|
||||
static void testKeymapLayoutTinyAndZeroNoInversion() {
|
||||
checkNoInversion(layoutKeymapEditor(30, 30));
|
||||
checkNoInversion(layoutKeymapEditor(0, 0));
|
||||
// A click anywhere on a zero layout hits no zone and no Add button.
|
||||
const KeymapEditorLayout Z = layoutKeymapEditor(0, 0);
|
||||
CHECK(zoneHitTest(Z, 3, 0, 0).zoneIndex == -1);
|
||||
CHECK(!addZoneHitTest(Z, 0, 0));
|
||||
}
|
||||
|
||||
static void testKeymapSampleRowInLeftColumn() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect r0 = keymapSampleRowRect(L, 0);
|
||||
// Rows live in the LEFT column (not the full canvas width).
|
||||
CHECK(r0.left == L.sampleList.left && r0.right == L.sampleList.right);
|
||||
CHECK(r0.right < L.base.canvas.right); // strictly left of the zone panel
|
||||
CHECK(r0.top == L.sampleList.top && r0.height() == kSampleRowHeight);
|
||||
// Hit-test maps a left-column click to the row and rejects a click in the zone panel.
|
||||
const int midY = (r0.top + r0.bottom) / 2;
|
||||
CHECK(keymapSampleRowHitTest(L, 3, r0.left + 2, midY) == 0);
|
||||
CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.left + 2, midY) == -1);
|
||||
}
|
||||
|
||||
static void testAddZoneHitTest() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const int cx = (L.addZoneButton.left + L.addZoneButton.right) / 2;
|
||||
const int cy = (L.addZoneButton.top + L.addZoneButton.bottom) / 2;
|
||||
CHECK(addZoneHitTest(L, cx, cy));
|
||||
// A click in the zone-row area below the button is NOT the Add button.
|
||||
CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.top + 2));
|
||||
// A click in the left list is NOT the Add button.
|
||||
CHECK(!addZoneHitTest(L, L.sampleList.left + 2, L.sampleList.top + 2));
|
||||
}
|
||||
|
||||
static void testZoneRowStacksAndSelects() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect z0 = zoneRowRect(L, 0);
|
||||
const Rect z1 = zoneRowRect(L, 1);
|
||||
CHECK(z0.top == L.zoneRowArea.top && z0.height() == kZoneRowHeight);
|
||||
CHECK(z1.top == z0.bottom); // stacked, no gap
|
||||
CHECK(z0.left == L.zoneRowArea.left && z0.right == L.zoneRowArea.right);
|
||||
// A click on the LABEL area (left part of a zone row) selects the zone with no field.
|
||||
const int labelX = z0.left + 2; // far left = label, not a control
|
||||
const int midY = (z0.top + z0.bottom) / 2;
|
||||
const ZoneHit h = zoneHitTest(L, 2, labelX, midY);
|
||||
CHECK(h.zoneIndex == 0 && h.field == ZoneField::kZoneNone);
|
||||
}
|
||||
|
||||
static void testZoneRowControlsMapToFields() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect row = zoneRowRect(L, 0);
|
||||
const int midY = (row.top + row.bottom) / 2;
|
||||
// The seven controls occupy the rightmost 7*kZoneCtrlWidth px, left-to-right:
|
||||
// low-, low+, high-, high+, root-, root+, delete.
|
||||
const int block = row.right - 7 * kZoneCtrlWidth;
|
||||
const ZoneField expected[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
ZoneField::kDelete,
|
||||
};
|
||||
for (int s = 0; s < 7; ++s) {
|
||||
const int x = block + s * kZoneCtrlWidth + kZoneCtrlWidth / 2; // center of slot s
|
||||
const ZoneHit h = zoneHitTest(L, 1, x, midY);
|
||||
CHECK(h.zoneIndex == 0);
|
||||
CHECK(h.zoneIndex == 0 && h.field == expected[s]);
|
||||
}
|
||||
}
|
||||
|
||||
static void testZoneHitTestMisses() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect row = zoneRowRect(L, 0);
|
||||
const int midY = (row.top + row.bottom) / 2;
|
||||
// Zero zones -> always miss.
|
||||
CHECK(zoneHitTest(L, 0, row.left + 2, midY).zoneIndex == -1);
|
||||
// Below the last zone row -> miss.
|
||||
const Rect last = zoneRowRect(L, 2);
|
||||
CHECK(zoneHitTest(L, 3, row.left + 2, last.bottom + 1).zoneIndex == -1);
|
||||
// Left of the zone panel (in the sample list) -> miss.
|
||||
CHECK(zoneHitTest(L, 3, L.sampleList.left + 2, midY).zoneIndex == -1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testContainsHalfOpen();
|
||||
testContainsDegenerate();
|
||||
testLayoutNormalView();
|
||||
testLayoutTinyViewClampsButton();
|
||||
testLayoutZeroView();
|
||||
testHitTestButton();
|
||||
testHitTestMissesNonButton();
|
||||
testHitTestButtonBoundary();
|
||||
testHitTestMatchesDrawnButton();
|
||||
testSampleRowRectStacks();
|
||||
testSampleRowHitTestMapsClickToRow();
|
||||
testSampleRowHitTestMisses();
|
||||
testSampleRowHitTestMatchesDrawnRows();
|
||||
testKeymapLayoutSplitsCanvas();
|
||||
testKeymapLayoutTinyAndZeroNoInversion();
|
||||
testKeymapSampleRowInLeftColumn();
|
||||
testAddZoneHitTest();
|
||||
testZoneRowStacksAndSelects();
|
||||
testZoneRowControlsMapToFields();
|
||||
testZoneHitTestMisses();
|
||||
|
||||
if (g_fail == 0) std::printf("editor_geometry: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Standalone tests for reasampler::vst::embed_strip — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure tests (editor_geometry et al.): assert the
|
||||
// embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly.
|
||||
//
|
||||
// Covers: layoutEmbed splitting a normal area into keymap + level band, a tiny area
|
||||
// (band yields to the keymap minimum, no inversion), and a zero area (all empty);
|
||||
// zoneSegmentRect mapping the 128-key span linearly, tiling adjacent zones seamlessly,
|
||||
// clamping out-of-range/inverted notes; zoneAtPoint hitting the covering zone, first-match
|
||||
// on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list;
|
||||
// levelFillRect clamping 0..1 and its endpoints.
|
||||
|
||||
#include "../src/vst/embed_strip.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- layoutEmbed --------------------------------------------------------------
|
||||
|
||||
static void testLayoutNormalArea() {
|
||||
// A comfortable inline strip: keymap band on top, thin level band pinned to the bottom.
|
||||
const EmbedLayout L = layoutEmbed(300, 40);
|
||||
CHECK(L.keymap.left == 0 && L.keymap.top == 0 && L.keymap.right == 300);
|
||||
CHECK(L.levelBand.left == 0 && L.levelBand.right == 300);
|
||||
// Level band is the fixed height at the very bottom; keymap fills the rest, contiguous.
|
||||
CHECK(L.levelBand.height() == kEmbedLevelBandHeight);
|
||||
CHECK(L.levelBand.bottom == 40);
|
||||
CHECK(L.keymap.bottom == L.levelBand.top);
|
||||
CHECK(L.keymap.height() == 40 - kEmbedLevelBandHeight);
|
||||
}
|
||||
|
||||
static void testLayoutTinyAreaKeepsKeymap() {
|
||||
// A very short area: the level band must yield so the keymap keeps its minimum, and no
|
||||
// rect inverts.
|
||||
const EmbedLayout L = layoutEmbed(300, 8);
|
||||
CHECK(L.keymap.height() >= 0);
|
||||
CHECK(L.levelBand.height() >= 0);
|
||||
CHECK(L.keymap.bottom == L.levelBand.top);
|
||||
CHECK(L.levelBand.bottom == 8);
|
||||
// The keymap is not starved below its floor when the area allows it.
|
||||
CHECK(L.keymap.height() >= kEmbedKeymapMinHeight || 8 < kEmbedKeymapMinHeight);
|
||||
}
|
||||
|
||||
static void testLayoutZeroArea() {
|
||||
const EmbedLayout L = layoutEmbed(0, 0);
|
||||
CHECK(L.keymap.width() <= 0 && L.keymap.height() <= 0);
|
||||
CHECK(L.levelBand.width() <= 0 && L.levelBand.height() <= 0);
|
||||
// Negative dimensions clamp to a zero-area, non-inverted rect.
|
||||
const EmbedLayout N = layoutEmbed(-50, -50);
|
||||
CHECK(N.keymap.right >= N.keymap.left && N.keymap.bottom >= N.keymap.top);
|
||||
}
|
||||
|
||||
// --- zoneSegmentRect ----------------------------------------------------------
|
||||
|
||||
static void testZoneSegmentFullSpan() {
|
||||
// A zone covering the whole keyboard spans the entire keymap band width.
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
const Rect r = zoneSegmentRect(L, 0, 127);
|
||||
CHECK(r.left == L.keymap.left);
|
||||
CHECK(r.right == L.keymap.right);
|
||||
CHECK(r.top == L.keymap.top && r.bottom == L.keymap.bottom);
|
||||
}
|
||||
|
||||
static void testAdjacentZonesTileSeamlessly() {
|
||||
// 256px band, 128 keys -> 2px/key. Zones 0..59 and 60..127 must abut with no gap or
|
||||
// overlap: the low zone's right == the high zone's left.
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
const Rect lo = zoneSegmentRect(L, 0, 59);
|
||||
const Rect hi = zoneSegmentRect(L, 60, 127);
|
||||
CHECK(lo.left == L.keymap.left);
|
||||
CHECK(hi.right == L.keymap.right);
|
||||
CHECK(lo.right == hi.left); // seamless tile — the load-bearing assertion
|
||||
CHECK(lo.right == L.keymap.left + 60 * 2); // 60 keys * 2px
|
||||
}
|
||||
|
||||
static void testZoneSegmentClampsBadNotes() {
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
// Out-of-range notes clamp into the band; an inverted zone (low > high) collapses to a
|
||||
// zero-or-positive-width rect, never inverts.
|
||||
const Rect over = zoneSegmentRect(L, -10, 200);
|
||||
CHECK(over.left == L.keymap.left && over.right == L.keymap.right);
|
||||
const Rect inv = zoneSegmentRect(L, 100, 20);
|
||||
CHECK(inv.right >= inv.left);
|
||||
}
|
||||
|
||||
// --- zoneAtPoint --------------------------------------------------------------
|
||||
|
||||
static void testZoneAtPointHits() {
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
const EmbedZone zones[2] = {{0, 59}, {60, 127}};
|
||||
// A point inside the low zone's segment resolves to zone 0; inside the high zone, 1.
|
||||
const Rect lo = zoneSegmentRect(L, 0, 59);
|
||||
const Rect hi = zoneSegmentRect(L, 60, 127);
|
||||
const int yMid = (L.keymap.top + L.keymap.bottom) / 2;
|
||||
CHECK(zoneAtPoint(L, zones, 2, lo.left + 1, yMid) == 0);
|
||||
CHECK(zoneAtPoint(L, zones, 2, hi.right - 1, yMid) == 1);
|
||||
}
|
||||
|
||||
static void testZoneAtPointFirstMatchOnOverlap() {
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
// Two overlapping zones; the FIRST in order must win the contested keys.
|
||||
const EmbedZone zones[2] = {{0, 127}, {40, 80}};
|
||||
const int yMid = (L.keymap.top + L.keymap.bottom) / 2;
|
||||
const Rect contested = zoneSegmentRect(L, 40, 80);
|
||||
CHECK(zoneAtPoint(L, zones, 2, contested.left + 1, yMid) == 0); // zone 0 wins
|
||||
}
|
||||
|
||||
static void testZoneAtPointMisses() {
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
const EmbedZone zones[1] = {{60, 72}}; // a narrow zone; most keys uncovered
|
||||
const int yMid = (L.keymap.top + L.keymap.bottom) / 2;
|
||||
// A key left of the zone is uncovered -> -1.
|
||||
CHECK(zoneAtPoint(L, zones, 1, L.keymap.left + 1, yMid) == -1);
|
||||
// A point in the level band (below the keymap) is off the keymap -> -1.
|
||||
CHECK(zoneAtPoint(L, zones, 1, L.levelBand.left + 4, L.levelBand.top) == -1);
|
||||
// Empty / null list -> -1.
|
||||
CHECK(zoneAtPoint(L, zones, 0, L.keymap.left + 1, yMid) == -1);
|
||||
CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.left + 1, yMid) == -1);
|
||||
}
|
||||
|
||||
// --- levelFillRect ------------------------------------------------------------
|
||||
|
||||
static void testLevelFillClamps() {
|
||||
const EmbedLayout L = layoutEmbed(200, 40);
|
||||
// Zero / negative -> empty.
|
||||
CHECK(levelFillRect(L, 0.0).width() <= 0);
|
||||
CHECK(levelFillRect(L, -1.0).width() <= 0);
|
||||
// Full / over-full -> the whole band width.
|
||||
CHECK(levelFillRect(L, 1.0).width() == L.levelBand.width());
|
||||
CHECK(levelFillRect(L, 5.0).width() == L.levelBand.width());
|
||||
// Half -> ~half the band, pinned to the band's left and vertical extent.
|
||||
const Rect half = levelFillRect(L, 0.5);
|
||||
CHECK(half.left == L.levelBand.left);
|
||||
CHECK(half.top == L.levelBand.top && half.bottom == L.levelBand.bottom);
|
||||
CHECK(half.width() == L.levelBand.width() / 2);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLayoutNormalArea();
|
||||
testLayoutTinyAreaKeepsKeymap();
|
||||
testLayoutZeroArea();
|
||||
testZoneSegmentFullSpan();
|
||||
testAdjacentZonesTileSeamlessly();
|
||||
testZoneSegmentClampsBadNotes();
|
||||
testZoneAtPointHits();
|
||||
testZoneAtPointFirstMatchOnOverlap();
|
||||
testZoneAtPointMisses();
|
||||
testLevelFillClamps();
|
||||
|
||||
if (g_fail == 0) std::printf("embed_strip: all tests passed\n");
|
||||
else std::printf("embed_strip: %d FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Standalone tests for reasampler::instrument_drop — no REAPER, no VST3 SDK, no framework.
|
||||
// The S17 drop-and-load blob-construction contract: the extension builds a vst_chunk blob
|
||||
// whose bytes are EXACTLY what ReaSampler 9000's own setState (deserializeComponentState)
|
||||
// accepts, with the dragged capture pre-selected. The round-trip proof (build -> base64
|
||||
// decode -> the instrument's OWN reader -> assert the capture selected) IS the cross-artifact
|
||||
// contract guard — the same pattern assignment_request_tests uses for its wire format.
|
||||
|
||||
#include "../src/instrument_drop.h"
|
||||
#include "../src/vst/sample_map.h" // deserializeComponentState — the instrument's OWN reader
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// A representative project rate for the reader (the legacy-v3 conversion parameter; our v5
|
||||
// blob never consumes it, but the reader signature requires a positive rate).
|
||||
static constexpr double kRate = 48000.0;
|
||||
|
||||
// THE contract test: a blob built for a capture id decodes — through the instrument's OWN
|
||||
// reader — to a ComponentState with THAT id selected, no zones, default mono. If this fails,
|
||||
// the extension would inject bytes the instrument's setState rejects and the drop would load
|
||||
// a silent/wrong instance.
|
||||
static void testBlobRoundTripsThroughInstrumentReader() {
|
||||
const std::string id = "cap-7f3a-guid";
|
||||
const std::string b64 = buildInstrumentDropChunk(id);
|
||||
CHECK(!b64.empty());
|
||||
|
||||
const std::vector<std::uint8_t> bytes = decodeBase64(b64);
|
||||
CHECK(!bytes.empty());
|
||||
// The base64 must decode to EXACTLY the pre-encode state bytes (no corruption).
|
||||
CHECK(bytes == instrumentDropStateBytes(id));
|
||||
|
||||
const ComponentState cs = deserializeComponentState(bytes, kRate);
|
||||
CHECK(cs.selectionId == id); // the capture IS selected — the whole point
|
||||
CHECK(cs.map.zones.empty()); // a drop selects one capture, authors no zones
|
||||
CHECK(cs.channelMode == ChannelMode::Mono); // fresh-instance default
|
||||
CHECK(cs.lastConsumedAssignGeneration == 0); // fresh instance, no consumed assign
|
||||
}
|
||||
|
||||
// A GUID-shaped id with bytes that would trip a naive delimiter-based encoder round-trips
|
||||
// whole (the length-prefixed component-state framing + base64 carry arbitrary bytes).
|
||||
static void testGuidLikeIdRoundTrips() {
|
||||
const std::string id = "{9A2F0C11-4B6E-4D01-8F3A-0011223344FF}";
|
||||
const std::vector<std::uint8_t> bytes = decodeBase64(buildInstrumentDropChunk(id));
|
||||
const ComponentState cs = deserializeComponentState(bytes, kRate);
|
||||
CHECK(cs.selectionId == id);
|
||||
}
|
||||
|
||||
// An empty id yields the empty-state blob: it still decodes cleanly to {"", no zones} — the
|
||||
// S10 silent empty state. (The shell guards against dropping nothing; the pure contract holds.)
|
||||
static void testEmptyIdYieldsEmptyState() {
|
||||
const std::vector<std::uint8_t> bytes = decodeBase64(buildInstrumentDropChunk(""));
|
||||
CHECK(!bytes.empty()); // still a versioned envelope, just an empty selection
|
||||
const ComponentState cs = deserializeComponentState(bytes, kRate);
|
||||
CHECK(cs.selectionId.empty());
|
||||
CHECK(cs.map.zones.empty());
|
||||
}
|
||||
|
||||
// Deterministic: the same id always produces the same blob (no time/random in the path).
|
||||
static void testDeterministic() {
|
||||
CHECK(buildInstrumentDropChunk("abc") == buildInstrumentDropChunk("abc"));
|
||||
CHECK(buildInstrumentDropChunk("abc") != buildInstrumentDropChunk("abd"));
|
||||
}
|
||||
|
||||
// --- base64 codec unit coverage (the encode side the shell actually ships) -----
|
||||
|
||||
static std::vector<std::uint8_t> b(std::initializer_list<int> v) {
|
||||
std::vector<std::uint8_t> out;
|
||||
for (int x : v) out.push_back(static_cast<std::uint8_t>(x));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Known RFC-4648 vectors, incl. every padding case (0/1/2 trailing bytes).
|
||||
static void testBase64KnownVectors() {
|
||||
CHECK(encodeBase64(b({})) == "");
|
||||
CHECK(encodeBase64(b({'f'})) == "Zg==");
|
||||
CHECK(encodeBase64(b({'f', 'o'})) == "Zm8=");
|
||||
CHECK(encodeBase64(b({'f', 'o', 'o'})) == "Zm9v");
|
||||
CHECK(encodeBase64(b({'f', 'o', 'o', 'b'})) == "Zm9vYg==");
|
||||
CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a'})) == "Zm9vYmE=");
|
||||
CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a', 'r'})) == "Zm9vYmFy");
|
||||
}
|
||||
|
||||
// encode -> decode is identity across every residue class + all-byte values.
|
||||
static void testBase64RoundTripAllBytes() {
|
||||
for (int len = 0; len <= 300; ++len) {
|
||||
std::vector<std::uint8_t> in;
|
||||
for (int i = 0; i < len; ++i) in.push_back(static_cast<std::uint8_t>((i * 37 + 11) & 0xFF));
|
||||
CHECK(decodeBase64(encodeBase64(in)) == in);
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed decode inputs return empty (never throw / never UB): bad length, illegal char,
|
||||
// misplaced padding.
|
||||
static void testBase64DecodeRejectsMalformed() {
|
||||
CHECK(decodeBase64("Zg=").empty()); // length not a multiple of 4
|
||||
CHECK(decodeBase64("Zm9v!ba=").empty()); // illegal char '!'
|
||||
CHECK(decodeBase64("Z===").empty()); // illegal char in v1 position
|
||||
CHECK(decodeBase64("Zg==Zg==").empty()); // interior padding (pad before the final quad)
|
||||
}
|
||||
|
||||
int main() {
|
||||
testBlobRoundTripsThroughInstrumentReader();
|
||||
testGuidLikeIdRoundTrips();
|
||||
testEmptyIdYieldsEmptyState();
|
||||
testDeterministic();
|
||||
testBase64KnownVectors();
|
||||
testBase64RoundTripAllBytes();
|
||||
testBase64DecodeRejectsMalformed();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Standalone tests for reasampler::vst::keyboard_strip — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure tests. Assert the capture-first editor's
|
||||
// keyboard-strip layout, root marker, key mapping, zone-bar hit regions, and the drag-delta
|
||||
// note resolver directly — the geometry that backs the single-capture root-set and the opt-in
|
||||
// Zones panel.
|
||||
//
|
||||
// Covers: layoutStrip (normal + zero); keyLeftX monotonic across the 128-key span with the
|
||||
// boundary at 128 == band right; keyRect / rootMarkerRect (rootMarkerRect == keyRect);
|
||||
// keyAtPoint inverting the mapping and clamping/ missing off-band; zoneBarRect spanning
|
||||
// [low,high] inclusive and collapsing (not inverting) a malformed low>high; zoneGrabAt
|
||||
// classifying low-edge / high-edge / body and the narrow-bar midpoint split (low wins the
|
||||
// tie); zoneBarAtPoint first-match on overlap + null-list rejection; resolveDragNote rounding
|
||||
// to the nearest key at the key centre, clamping to [0,127], and the zero-delta / zero-width
|
||||
// no-ops.
|
||||
|
||||
#include "../src/vst/keyboard_strip.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// A comfortable strip: 1280px wide (10px per key) so key math is exact and easy to reason
|
||||
// about.
|
||||
static StripLayout wideStrip() { return layoutStrip(1280, 40); }
|
||||
|
||||
// --- layoutStrip --------------------------------------------------------------
|
||||
|
||||
static void testLayoutNormalArea() {
|
||||
const StripLayout L = layoutStrip(640, 40);
|
||||
CHECK(L.keys.left == 0 && L.keys.top == 0);
|
||||
CHECK(L.keys.right == 640 && L.keys.bottom == 40);
|
||||
}
|
||||
|
||||
static void testLayoutZeroArea() {
|
||||
const StripLayout L = layoutStrip(0, 0);
|
||||
CHECK(L.keys.width() == 0 && L.keys.height() == 0);
|
||||
}
|
||||
|
||||
// --- keyLeftX / keyRect / rootMarkerRect --------------------------------------
|
||||
|
||||
static void testKeyLeftMonotonicAndBounds() {
|
||||
const StripLayout L = wideStrip();
|
||||
// Key 0's left edge is the band left; the 128 boundary is the band right.
|
||||
CHECK(keyLeftX(L, 0) == L.keys.left);
|
||||
CHECK(keyLeftX(L, 128) == L.keys.right);
|
||||
// Strictly non-decreasing across the span.
|
||||
int prev = keyLeftX(L, 0);
|
||||
for (int n = 1; n <= 128; ++n) {
|
||||
const int x = keyLeftX(L, n);
|
||||
CHECK(x >= prev);
|
||||
prev = x;
|
||||
}
|
||||
// At 10px/key, key 12 (one octave) starts at 120px.
|
||||
CHECK(keyLeftX(L, 12) == 120);
|
||||
}
|
||||
|
||||
static void testKeyRectHalfOpen() {
|
||||
const StripLayout L = wideStrip();
|
||||
const Rect k = keyRect(L, 60);
|
||||
CHECK(k.left == keyLeftX(L, 60));
|
||||
CHECK(k.right == keyLeftX(L, 61));
|
||||
CHECK(k.top == L.keys.top && k.bottom == L.keys.bottom);
|
||||
CHECK(k.width() == 10); // 10px/key
|
||||
}
|
||||
|
||||
static void testRootMarkerEqualsKeyRect() {
|
||||
const StripLayout L = wideStrip();
|
||||
const Rect m = rootMarkerRect(L, 64);
|
||||
const Rect k = keyRect(L, 64);
|
||||
CHECK(m.left == k.left && m.right == k.right && m.top == k.top && m.bottom == k.bottom);
|
||||
}
|
||||
|
||||
// --- keyAtPoint ---------------------------------------------------------------
|
||||
|
||||
static void testKeyAtPointInverts() {
|
||||
const StripLayout L = wideStrip();
|
||||
// A point in the middle of key 60's cell resolves to 60.
|
||||
const Rect k = keyRect(L, 60);
|
||||
CHECK(keyAtPoint(L, k.left + 5, k.top + 2) == 60);
|
||||
// The very left of the band is key 0; just inside the right edge is key 127.
|
||||
CHECK(keyAtPoint(L, L.keys.left, 2) == 0);
|
||||
CHECK(keyAtPoint(L, L.keys.right - 1, 2) == 127);
|
||||
}
|
||||
|
||||
static void testKeyAtPointOffBand() {
|
||||
const StripLayout L = wideStrip();
|
||||
CHECK(keyAtPoint(L, -5, 2) == -1); // left of band
|
||||
CHECK(keyAtPoint(L, L.keys.right + 5, 2) == -1); // right of band
|
||||
CHECK(keyAtPoint(L, 100, L.keys.bottom + 5) == -1); // below band
|
||||
}
|
||||
|
||||
// --- zoneBarRect --------------------------------------------------------------
|
||||
|
||||
static void testZoneBarSpansInclusive() {
|
||||
const StripLayout L = wideStrip();
|
||||
const Rect bar = zoneBarRect(L, 12, 23); // C1..B1 inclusive
|
||||
CHECK(bar.left == keyLeftX(L, 12));
|
||||
CHECK(bar.right == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully
|
||||
CHECK(bar.width() == 120); // 12 keys * 10px
|
||||
}
|
||||
|
||||
static void testZoneBarMalformedCollapses() {
|
||||
const StripLayout L = wideStrip();
|
||||
// low > high must collapse, never invert.
|
||||
const Rect bar = zoneBarRect(L, 80, 40);
|
||||
CHECK(bar.width() >= 0);
|
||||
CHECK(bar.right >= bar.left);
|
||||
}
|
||||
|
||||
// --- zoneGrabAt ---------------------------------------------------------------
|
||||
|
||||
static void testZoneGrabEdgesAndBody() {
|
||||
const StripLayout L = wideStrip();
|
||||
const Rect bar = zoneBarRect(L, 20, 60); // wide bar with a clear body
|
||||
const int y = L.keys.top + 2;
|
||||
// Near the left edge -> low; near the right edge -> high; the middle -> body.
|
||||
CHECK(zoneGrabAt(L, 20, 60, bar.left + 1, y) == ZoneGrab::kLowEdge);
|
||||
CHECK(zoneGrabAt(L, 20, 60, bar.right - 1, y) == ZoneGrab::kHighEdge);
|
||||
CHECK(zoneGrabAt(L, 20, 60, bar.left + bar.width() / 2, y) == ZoneGrab::kBody);
|
||||
// Off the bar entirely -> none.
|
||||
CHECK(zoneGrabAt(L, 20, 60, bar.right + 20, y) == ZoneGrab::kNone);
|
||||
}
|
||||
|
||||
static void testZoneGrabNarrowBarSplitsAtMidpointLowWins() {
|
||||
const StripLayout L = wideStrip();
|
||||
// A 1-key bar is narrower than 2*edge: no body; the low edge wins the exact midpoint.
|
||||
const Rect bar = zoneBarRect(L, 50, 50);
|
||||
const int y = L.keys.top + 2;
|
||||
const int mid = bar.left + bar.width() / 2;
|
||||
CHECK(zoneGrabAt(L, 50, 50, mid, y) == ZoneGrab::kLowEdge); // tie -> low
|
||||
CHECK(zoneGrabAt(L, 50, 50, bar.right - 1, y) == ZoneGrab::kHighEdge);
|
||||
}
|
||||
|
||||
// --- zoneBarAtPoint -----------------------------------------------------------
|
||||
|
||||
static void testZoneBarAtPointFirstMatch() {
|
||||
const StripLayout L = wideStrip();
|
||||
const int lows[2] = {20, 30}; // zone 0 and zone 1 overlap on [30,50]
|
||||
const int highs[2] = {50, 70};
|
||||
const Rect overlap = zoneBarRect(L, 30, 50);
|
||||
const int y = L.keys.top + 2;
|
||||
const int cx = overlap.left + overlap.width() / 2;
|
||||
// A point in the overlap resolves to the FIRST covering zone (draw order).
|
||||
const ZoneBarHit hit = zoneBarAtPoint(L, lows, highs, 2, cx, y);
|
||||
CHECK(hit.zoneIndex == 0);
|
||||
CHECK(hit.grab != ZoneGrab::kNone);
|
||||
}
|
||||
|
||||
static void testZoneBarAtPointNullList() {
|
||||
const StripLayout L = wideStrip();
|
||||
const ZoneBarHit hit = zoneBarAtPoint(L, nullptr, nullptr, 0, 100, 2);
|
||||
CHECK(hit.zoneIndex == -1 && hit.grab == ZoneGrab::kNone);
|
||||
}
|
||||
|
||||
// --- resolveDragNote ----------------------------------------------------------
|
||||
|
||||
static void testResolveDragRoundsToNearestKey() {
|
||||
const StripLayout L = wideStrip(); // 10px/key
|
||||
// A +25px drag from key 60 = +2.5 keys -> rounds to +3 (half-key flips at the centre).
|
||||
CHECK(resolveDragNote(L, 60, 25) == 63);
|
||||
// A +24px drag = +2.4 keys -> rounds to +2.
|
||||
CHECK(resolveDragNote(L, 60, 24) == 62);
|
||||
// Symmetric for negative deltas.
|
||||
CHECK(resolveDragNote(L, 60, -25) == 57);
|
||||
CHECK(resolveDragNote(L, 60, -24) == 58);
|
||||
}
|
||||
|
||||
static void testResolveDragClampsAndNoOps() {
|
||||
const StripLayout L = wideStrip();
|
||||
CHECK(resolveDragNote(L, 60, 0) == 60); // zero delta -> unchanged
|
||||
CHECK(resolveDragNote(L, 2, -1000) == 0); // clamps at 0
|
||||
CHECK(resolveDragNote(L, 120, 1000) == 127); // clamps at 127
|
||||
// Zero-width band -> no motion (pins to startNote, clamped).
|
||||
const StripLayout Z = layoutStrip(0, 40);
|
||||
CHECK(resolveDragNote(Z, 60, 500) == 60);
|
||||
}
|
||||
|
||||
static void testResolveDragProportionalNonDivisibleWidth() {
|
||||
// THE REVIEW FINDING: 544px / 128 = 4.25 (non-integer). Old uniform-keyW math used
|
||||
// keyW = 4 (floor), accumulating ~7 keys of drift at the far end. The proportional fix
|
||||
// must agree with keyAtPoint at every point — specifically the far-end invariant:
|
||||
// a drag from note 0 by (width-1) pixels must land at keyAtPoint(width-1), which is 127.
|
||||
const int width = 544;
|
||||
const StripLayout L = layoutStrip(width, 40);
|
||||
CHECK(keyAtPoint(L, width - 1, L.keys.top + 1) == 127);
|
||||
CHECK(resolveDragNote(L, 0, width - 1) == 127);
|
||||
|
||||
// Also verify mid-strip coherence: for each key N, a drag from 0 by N's left-edge
|
||||
// pixel offset should land at N (or N-1 at worst — left-edge pixel is a boundary, so
|
||||
// rounding may round down). The critical direction is that it must NOT over-shoot by
|
||||
// more than 0 (it must reach at least the right key).
|
||||
for (int n = 1; n < kStripKeyCount; ++n) {
|
||||
const int leftPx = keyRect(L, n).left;
|
||||
const int resolved = resolveDragNote(L, 0, leftPx);
|
||||
// The left edge of key N is the first pixel "in" that key, so we expect resolved == N.
|
||||
// Allow resolved == N-1 only when the pixel is at the exact boundary (keyEdgeToX may
|
||||
// produce the same x for adjacent keys when keys share a pixel). Disallow over-shoot.
|
||||
const int expected = keyAtPoint(L, leftPx, L.keys.top + 1);
|
||||
CHECK(resolved >= expected - 1 && resolved <= expected + 1);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLayoutNormalArea();
|
||||
testLayoutZeroArea();
|
||||
testKeyLeftMonotonicAndBounds();
|
||||
testKeyRectHalfOpen();
|
||||
testRootMarkerEqualsKeyRect();
|
||||
testKeyAtPointInverts();
|
||||
testKeyAtPointOffBand();
|
||||
testZoneBarSpansInclusive();
|
||||
testZoneBarMalformedCollapses();
|
||||
testZoneGrabEdgesAndBody();
|
||||
testZoneGrabNarrowBarSplitsAtMidpointLowWins();
|
||||
testZoneBarAtPointFirstMatch();
|
||||
testZoneBarAtPointNullList();
|
||||
testResolveDragRoundsToNearestKey();
|
||||
testResolveDragClampsAndNoOps();
|
||||
testResolveDragProportionalNonDivisibleWidth();
|
||||
|
||||
if (g_fail == 0) std::printf("keyboard_strip: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Standalone tests for reasampler::vst::note_entry — no VST3, no REAPER, no framework.
|
||||
// Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note.
|
||||
//
|
||||
// Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the
|
||||
// C4==60 convention (C-1==0, sharps + flats, negative octaves); out-of-range values CLAMPING to
|
||||
// [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt;
|
||||
// the integer path taking precedence over the note-name path for a leading digit.
|
||||
|
||||
#include "../src/vst/note_entry.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static void testPlainIntegers() {
|
||||
CHECK(parseNoteEntry("60") == 60);
|
||||
CHECK(parseNoteEntry("0") == 0);
|
||||
CHECK(parseNoteEntry("127") == 127);
|
||||
CHECK(parseNoteEntry(" 64 ") == 64); // surrounding whitespace ignored
|
||||
CHECK(parseNoteEntry("+5") == 5);
|
||||
}
|
||||
|
||||
static void testIntegerClamps() {
|
||||
CHECK(parseNoteEntry("200") == 127); // over-range clamps to the ceiling
|
||||
CHECK(parseNoteEntry("-10") == 0); // under-range clamps to the floor
|
||||
CHECK(parseNoteEntry("99999") == 127);
|
||||
}
|
||||
|
||||
static void testNoteNames() {
|
||||
// C4 == 60 (MIDI 0 == C-1).
|
||||
CHECK(parseNoteEntry("C4") == 60);
|
||||
CHECK(parseNoteEntry("c4") == 60); // case-insensitive
|
||||
CHECK(parseNoteEntry("A4") == 69); // A4 = 69 (concert A)
|
||||
CHECK(parseNoteEntry("C-1") == 0); // lowest MIDI note
|
||||
CHECK(parseNoteEntry("G9") == 127); // G9 = 127
|
||||
}
|
||||
|
||||
static void testAccidentals() {
|
||||
CHECK(parseNoteEntry("C#4") == 61);
|
||||
CHECK(parseNoteEntry("Db4") == 61); // enharmonic of C#4
|
||||
CHECK(parseNoteEntry("F#3") == 54);
|
||||
CHECK(parseNoteEntry("Bb3") == 58); // Bb3 = 58
|
||||
}
|
||||
|
||||
static void testNoteNameClamps() {
|
||||
CHECK(parseNoteEntry("C10") == 127); // above the range clamps
|
||||
CHECK(parseNoteEntry("C-5") == 0); // below the range clamps
|
||||
}
|
||||
|
||||
static void testRejects() {
|
||||
CHECK(parseNoteEntry("") == std::nullopt);
|
||||
CHECK(parseNoteEntry(" ") == std::nullopt);
|
||||
CHECK(parseNoteEntry("hello") == std::nullopt);
|
||||
CHECK(parseNoteEntry("C") == std::nullopt); // a bare letter with no octave is ambiguous
|
||||
CHECK(parseNoteEntry("H4") == std::nullopt); // H is not a note letter
|
||||
CHECK(parseNoteEntry("+") == std::nullopt);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPlainIntegers();
|
||||
testIntegerClamps();
|
||||
testNoteNames();
|
||||
testAccidentals();
|
||||
testNoteNameClamps();
|
||||
testRejects();
|
||||
|
||||
if (g_fail == 0) std::printf("note_entry: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Standalone tests for reasampler::vst::param_slider — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure editor tests (capture_browser / keyboard_strip):
|
||||
// assert the S12/S15/S16 control-surface layout, toggle-segment split + hit-test, slider
|
||||
// value<->pixel mapping (round-trip + clamping + endpoints), and point->control routing.
|
||||
//
|
||||
// Covers: layoutControls stacking rows top-down with the label column + control column and the
|
||||
// inter-row gap; an empty list / degenerate panel yielding nothing; toggleSegmentRect splitting
|
||||
// a toggle into two tiling segments (last absorbs the remainder) + toggleSegmentHitTest;
|
||||
// sliderTrackRect insetting a half-handle at each end; sliderHandleRect at value 0/0.5/1 and
|
||||
// out-of-range clamping; valueAtPoint mapping x back to 0..1 (endpoints saturate) as the inverse
|
||||
// of the handle position; controlAtPoint routing a point to the right control id (toggle whole
|
||||
// area vs slider track) and MISSING in the label column, a row gap, and off-panel.
|
||||
|
||||
#include "../src/vst/param_slider.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static bool approx(double a, double b) { return (a - b) < 1e-9 && (b - a) < 1e-9; }
|
||||
|
||||
// --- layoutControls -----------------------------------------------------------
|
||||
|
||||
static void testLayoutStacksRows() {
|
||||
const Rect panel{0, 100, 300, 400};
|
||||
std::vector<ControlDesc> ctl{
|
||||
{1, ControlKind::Toggle},
|
||||
{2, ControlKind::Slider},
|
||||
{3, ControlKind::Slider},
|
||||
};
|
||||
const std::vector<ControlRow> rows = layoutControls(panel, ctl);
|
||||
CHECK(rows.size() == 3);
|
||||
// Row 0 sits at the panel top; each subsequent row is one row-height + gap below.
|
||||
CHECK(rows[0].row.top == 100);
|
||||
CHECK(rows[0].row.bottom == 100 + kControlRowHeight);
|
||||
CHECK(rows[1].row.top == rows[0].row.bottom + kControlRowGap);
|
||||
CHECK(rows[2].row.top == rows[1].row.bottom + kControlRowGap);
|
||||
// Ids + kinds carried through in order.
|
||||
CHECK(rows[0].id == 1 && rows[0].kind == ControlKind::Toggle);
|
||||
CHECK(rows[1].id == 2 && rows[1].kind == ControlKind::Slider);
|
||||
// Label column then control column, contiguous, spanning the panel width.
|
||||
CHECK(rows[0].label.left == panel.left);
|
||||
CHECK(rows[0].control.left == rows[0].label.right);
|
||||
CHECK(rows[0].control.right == panel.right);
|
||||
CHECK(rows[0].label.width() == kControlLabelWidth);
|
||||
}
|
||||
|
||||
static void testLayoutEmptyAndDegenerate() {
|
||||
CHECK(layoutControls(Rect{0, 0, 300, 300}, {}).empty());
|
||||
std::vector<ControlDesc> ctl{{1, ControlKind::Slider}};
|
||||
CHECK(layoutControls(Rect{0, 0, 0, 0}, ctl).empty());
|
||||
CHECK(layoutControls(Rect{0, 0, 300, 0}, ctl).empty());
|
||||
}
|
||||
|
||||
static void testLayoutNarrowPanelClampsLabel() {
|
||||
// A panel narrower than 2*labelWidth clamps the label column to half so a control column
|
||||
// survives.
|
||||
const Rect panel{0, 0, 100, 200};
|
||||
const std::vector<ControlRow> rows = layoutControls(panel, {{1, ControlKind::Slider}});
|
||||
CHECK(rows.size() == 1);
|
||||
CHECK(rows[0].label.width() <= panel.width() / 2 + 1);
|
||||
CHECK(rows[0].control.width() > 0);
|
||||
}
|
||||
|
||||
// --- toggle -------------------------------------------------------------------
|
||||
|
||||
static void testToggleSegmentsTile() {
|
||||
const Rect control{100, 0, 300, 22}; // width 200
|
||||
const Rect s0 = toggleSegmentRect(control, 0);
|
||||
const Rect s1 = toggleSegmentRect(control, 1);
|
||||
CHECK(s0.left == 100 && s0.right == 200);
|
||||
CHECK(s1.left == 200 && s1.right == 300); // last absorbs remainder -> reaches control.right
|
||||
// Out of range.
|
||||
CHECK(toggleSegmentRect(control, 2).width() == 0);
|
||||
CHECK(toggleSegmentRect(control, -1).width() == 0);
|
||||
}
|
||||
|
||||
static void testToggleSegmentRemainderInLast() {
|
||||
const Rect control{0, 0, 201, 22}; // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder)
|
||||
CHECK(toggleSegmentRect(control, 0).width() == 100);
|
||||
CHECK(toggleSegmentRect(control, 1).right == 201);
|
||||
}
|
||||
|
||||
static void testToggleHitTest() {
|
||||
const Rect control{100, 0, 300, 22};
|
||||
CHECK(toggleSegmentHitTest(control, 150, 10) == 0);
|
||||
CHECK(toggleSegmentHitTest(control, 250, 10) == 1);
|
||||
CHECK(toggleSegmentHitTest(control, 50, 10) == -1); // left of control
|
||||
CHECK(toggleSegmentHitTest(control, 150, 40) == -1); // below control
|
||||
}
|
||||
|
||||
// --- slider -------------------------------------------------------------------
|
||||
|
||||
static void testSliderTrackInsetsHalfHandle() {
|
||||
const Rect control{100, 0, 300, 22};
|
||||
const Rect track = sliderTrackRect(control);
|
||||
CHECK(track.left == control.left + kSliderHandleWidth / 2);
|
||||
CHECK(track.right == control.right - kSliderHandleWidth / 2);
|
||||
// A control too narrow for a handle yields an empty track.
|
||||
CHECK(sliderTrackRect(Rect{0, 0, kSliderHandleWidth - 1, 22}).width() == 0);
|
||||
}
|
||||
|
||||
static void testSliderHandleAtEndpointsAndMid() {
|
||||
const Rect control{100, 0, 300, 22};
|
||||
const Rect track = sliderTrackRect(control);
|
||||
const int half = kSliderHandleWidth / 2;
|
||||
// Value 0 -> handle centered at track.left.
|
||||
const Rect h0 = sliderHandleRect(control, 0.0);
|
||||
CHECK(h0.left + half == track.left);
|
||||
// Value 1 -> handle centered at track.right.
|
||||
const Rect h1 = sliderHandleRect(control, 1.0);
|
||||
CHECK(h1.left + half == track.right);
|
||||
// Value 0.5 -> centered at the track middle.
|
||||
const Rect hm = sliderHandleRect(control, 0.5);
|
||||
CHECK(hm.left + half == track.left + track.width() / 2);
|
||||
}
|
||||
|
||||
static void testSliderHandleClampsOutOfRange() {
|
||||
const Rect control{0, 0, 200, 22};
|
||||
CHECK(sliderHandleRect(control, -0.5).left == sliderHandleRect(control, 0.0).left);
|
||||
CHECK(sliderHandleRect(control, 5.0).left == sliderHandleRect(control, 1.0).left);
|
||||
}
|
||||
|
||||
static void testValueAtPointEndpointsSaturate() {
|
||||
const Rect control{100, 0, 300, 22};
|
||||
const Rect track = sliderTrackRect(control);
|
||||
CHECK(approx(valueAtPoint(control, track.left - 20), 0.0));
|
||||
CHECK(approx(valueAtPoint(control, track.left), 0.0));
|
||||
CHECK(approx(valueAtPoint(control, track.right + 20), 1.0));
|
||||
CHECK(approx(valueAtPoint(control, track.right), 1.0));
|
||||
}
|
||||
|
||||
static void testValueAtPointIsHandleInverse() {
|
||||
// Round-trip: a value -> handle center -> valueAtPoint recovers (within one pixel quantum).
|
||||
const Rect control{50, 0, 450, 22}; // wide track for pixel resolution
|
||||
const Rect track = sliderTrackRect(control);
|
||||
for (double v : {0.1, 0.25, 0.5, 0.75, 0.9}) {
|
||||
const Rect h = sliderHandleRect(control, v);
|
||||
const int centerX = h.left + kSliderHandleWidth / 2;
|
||||
const double back = valueAtPoint(control, centerX);
|
||||
CHECK(back >= v - 0.01 && back <= v + 0.01);
|
||||
CHECK(centerX >= track.left && centerX <= track.right);
|
||||
}
|
||||
}
|
||||
|
||||
static void testValueAtPointDegenerateTrack() {
|
||||
CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0));
|
||||
}
|
||||
|
||||
// --- controlAtPoint routing ---------------------------------------------------
|
||||
|
||||
static void testControlAtPointRoutes() {
|
||||
const Rect panel{0, 0, 300, 400};
|
||||
std::vector<ControlDesc> ctl{
|
||||
{10, ControlKind::Toggle},
|
||||
{20, ControlKind::Slider},
|
||||
};
|
||||
const std::vector<ControlRow> rows = layoutControls(panel, ctl);
|
||||
// A point in the toggle's control area routes to the toggle id.
|
||||
const Rect tctl = rows[0].control;
|
||||
CHECK(controlAtPoint(rows, (tctl.left + tctl.right) / 2, (tctl.top + tctl.bottom) / 2) == 10);
|
||||
// A point on the slider's track routes to the slider id.
|
||||
const Rect strack = sliderTrackRect(rows[1].control);
|
||||
CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2,
|
||||
(strack.top + strack.bottom) / 2) == 20);
|
||||
}
|
||||
|
||||
static void testControlAtPointMisses() {
|
||||
const Rect panel{0, 0, 300, 400};
|
||||
const std::vector<ControlRow> rows =
|
||||
layoutControls(panel, {{10, ControlKind::Toggle}, {20, ControlKind::Slider}});
|
||||
// The label column is not interactive.
|
||||
CHECK(controlAtPoint(rows, rows[0].label.left + 2, rows[0].label.top + 4) == -1);
|
||||
// The gap between rows is a miss.
|
||||
const int gapY = rows[0].row.bottom + kControlRowGap / 2;
|
||||
CHECK(controlAtPoint(rows, 200, gapY) == -1);
|
||||
// Off-panel below.
|
||||
CHECK(controlAtPoint(rows, 200, 5000) == -1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLayoutStacksRows();
|
||||
testLayoutEmptyAndDegenerate();
|
||||
testLayoutNarrowPanelClampsLabel();
|
||||
testToggleSegmentsTile();
|
||||
testToggleSegmentRemainderInLast();
|
||||
testToggleHitTest();
|
||||
testSliderTrackInsetsHalfHandle();
|
||||
testSliderHandleAtEndpointsAndMid();
|
||||
testSliderHandleClampsOutOfRange();
|
||||
testValueAtPointEndpointsSaturate();
|
||||
testValueAtPointIsHandleInverse();
|
||||
testValueAtPointDegenerateTrack();
|
||||
testControlAtPointRoutes();
|
||||
testControlAtPointMisses();
|
||||
|
||||
if (g_fail == 0) std::printf("param_slider: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Standalone tests for reasampler::PitchShifter — the S16 Preserve-engine DSP core. No VST3,
|
||||
// no REAPER, no vendor, no test framework. The compile-time proof it does NOT drag the WDL
|
||||
// <windows.h> chain is the CMake target linking only pitch_shift (+ peaks).
|
||||
//
|
||||
// Covers (PLAN.md S16 / CONTEXT.md §Pitch engine modes — Preserve):
|
||||
// 1. duration invariance — N inputs yield N outputs at every shift ratio (the load-bearing
|
||||
// Preserve property: a transposed render is the SAME frame length as the un-transposed one).
|
||||
// 2. unity pass-through fidelity — ratio 1.0 reproduces the input closely (a shifter at unity
|
||||
// must not mangle the signal).
|
||||
// 3. transpose direction — an octave-up shift raises the observed pitch (period shortens), an
|
||||
// octave-down lowers it (period lengthens), measured on a synthesized sine.
|
||||
// 4. RT discipline surrogate — after configure()+warm() (the off-thread setup), a long
|
||||
// process() run never resizes the ring (checked via window() constancy) and never returns
|
||||
// NaN/inf; pass-through (unconfigured) returns input verbatim.
|
||||
|
||||
#include "../src/vst/pitch_shift.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; }
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
// A sine of `cycles` periods over `frames` frames.
|
||||
static std::vector<AudioSample> sine(std::size_t frames, double cycles) {
|
||||
std::vector<AudioSample> s(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) {
|
||||
s[i] = static_cast<float>(std::sin(2.0 * kPi * cycles *
|
||||
static_cast<double>(i) / static_cast<double>(frames)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Average spacing between positive-going zero crossings (the observed period).
|
||||
static double observedPeriod(const std::vector<AudioSample>& out, std::size_t from) {
|
||||
std::vector<std::size_t> up;
|
||||
for (std::size_t i = from + 1; i < out.size(); ++i) {
|
||||
if (out[i - 1] <= 0.0f && out[i] > 0.0f) up.push_back(i);
|
||||
}
|
||||
if (up.size() < 2) return 0.0;
|
||||
double sum = 0.0;
|
||||
for (std::size_t i = 1; i < up.size(); ++i) sum += static_cast<double>(up[i] - up[i - 1]);
|
||||
return sum / static_cast<double>(up.size() - 1);
|
||||
}
|
||||
|
||||
// --- 1. Duration invariance across shift ratios. ---
|
||||
static void testDurationInvariance() {
|
||||
// The core Preserve property: whatever the shift ratio, one input frame yields one output
|
||||
// frame. So a shifter fed N frames produces exactly N frames — a transposed render is the
|
||||
// same length as an un-transposed one (unlike Varispeed, where an octave up halves length).
|
||||
const std::size_t n = 4000;
|
||||
const std::vector<AudioSample> in = sine(n, 40.0);
|
||||
const double ratios[] = {0.5, 1.0, 2.0, std::pow(2.0, 7.0 / 12.0)};
|
||||
for (double r : ratios) {
|
||||
PitchShifter ps;
|
||||
ps.configure(2205); // ~50 ms @ 44.1k
|
||||
ps.warm();
|
||||
ps.setShiftRatio(r);
|
||||
std::size_t produced = 0;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const AudioSample o = ps.process(in[i]);
|
||||
(void)o;
|
||||
++produced; // exactly one output per input, unconditionally.
|
||||
}
|
||||
CHECK(produced == n); // duration held at every ratio.
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Unity pass-through fidelity. ---
|
||||
static void testUnityRoughlyReproduces() {
|
||||
// At ratio 1.0 the shifter should reproduce the input's PITCH faithfully (the OLA taps run
|
||||
// in lockstep with the writer). Amplitude/phase warble is allowed (basic OLA), but the
|
||||
// observed period must match the source period within a small tolerance past the warm-up.
|
||||
const std::size_t n = 8000;
|
||||
const double cycles = 40.0;
|
||||
const double nativePeriod = static_cast<double>(n) / cycles; // 200
|
||||
const std::vector<AudioSample> in = sine(n, cycles);
|
||||
PitchShifter ps;
|
||||
ps.configure(2205);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(1.0);
|
||||
std::vector<AudioSample> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
|
||||
// Measure past the initial half-window latency region.
|
||||
const double p = observedPeriod(out, 3000);
|
||||
CHECK(p > 0.0);
|
||||
CHECK(approx(p, nativePeriod, nativePeriod * 0.10)); // within 10% of source period
|
||||
}
|
||||
|
||||
// --- 3. Transpose direction: up shortens the period, down lengthens it. ---
|
||||
static void testTransposeDirection() {
|
||||
const std::size_t n = 12000;
|
||||
const double cycles = 60.0;
|
||||
const double nativePeriod = static_cast<double>(n) / cycles; // 200
|
||||
const std::vector<AudioSample> in = sine(n, cycles);
|
||||
|
||||
// Octave up: output period ~ half the source period (higher pitch).
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(2205);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(2.0);
|
||||
std::vector<AudioSample> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
|
||||
const double p = observedPeriod(out, 4000);
|
||||
CHECK(p > 0.0);
|
||||
CHECK(approx(p, nativePeriod / 2.0, nativePeriod * 0.15)); // period halves
|
||||
}
|
||||
// Octave down: output period ~ double the source period (lower pitch).
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(2205);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(0.5);
|
||||
std::vector<AudioSample> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
|
||||
const double p = observedPeriod(out, 4000);
|
||||
CHECK(p > 0.0);
|
||||
CHECK(approx(p, nativePeriod * 2.0, nativePeriod * 0.30)); // period doubles
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. RT discipline surrogate + pass-through. ---
|
||||
static void testRtDisciplineAndPassthrough() {
|
||||
// Unconfigured shifter passes input through verbatim (a Varispeed voice never allocates one).
|
||||
{
|
||||
PitchShifter ps;
|
||||
CHECK(!ps.configured());
|
||||
CHECK(ps.process(0.37f) == 0.37f); // exact pass-through
|
||||
CHECK(ps.process(-0.9f) == -0.9f);
|
||||
}
|
||||
// Configured: the window is fixed at configure() and never changes across a long run (no
|
||||
// per-frame Resize), and no output is NaN/inf (numerically well-behaved OLA).
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(1024);
|
||||
ps.warm();
|
||||
const std::int64_t w = ps.window();
|
||||
CHECK(w == 1024);
|
||||
ps.setShiftRatio(std::pow(2.0, 5.0 / 12.0));
|
||||
const std::vector<AudioSample> in = sine(20000, 100.0);
|
||||
for (std::size_t i = 0; i < in.size(); ++i) {
|
||||
const AudioSample o = ps.process(in[i]);
|
||||
CHECK(std::isfinite(o));
|
||||
}
|
||||
CHECK(ps.window() == w); // window unchanged -> ring never resized mid-run
|
||||
}
|
||||
// A non-positive shift ratio is ignored (keeps the last valid ratio) — never stalls/reverses.
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(512);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(1.0);
|
||||
ps.setShiftRatio(-2.0); // ignored
|
||||
ps.setShiftRatio(0.0); // ignored
|
||||
for (int i = 0; i < 2000; ++i) CHECK(std::isfinite(ps.process(0.5f)));
|
||||
}
|
||||
// Degenerate window (<= 1) stays pass-through even after configure.
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(1);
|
||||
CHECK(!ps.configured());
|
||||
CHECK(ps.process(0.25f) == 0.25f);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDurationInvariance();
|
||||
testUnityRoughlyReproduces();
|
||||
testTransposeDirection();
|
||||
testRtDisciplineAndPassthrough();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all pitch_shift tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("%d pitch_shift check(s) failed\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
// Standalone tests for reasampler::vst::waveform_view — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure tests. Assert the S11 waveform surface's
|
||||
// frame<->pixel mapping, marker grab regions, drag-delta frame resolver (with clamps), and
|
||||
// the zero-crossing snap — the geometry + snap that back the draggable start/loop markers.
|
||||
//
|
||||
// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width);
|
||||
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection);
|
||||
// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width
|
||||
// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower,
|
||||
// no-crossing keeps target, target clamp, degenerate buffers).
|
||||
|
||||
#include "../src/vst/waveform_view.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
using reasampler::AudioSample;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs).
|
||||
static Rect wideArea() { return Rect{20, 10, 1020, 90}; } // width 1000
|
||||
|
||||
// --- frameToX / xToFrame ------------------------------------------------------
|
||||
|
||||
static void testFrameToXEndpoints() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(frameToX(a, 1000, 0) == a.left); // frame 0 -> left edge
|
||||
CHECK(frameToX(a, 1000, 1000) == a.right); // frameCount -> right edge
|
||||
CHECK(frameToX(a, 1000, 500) == a.left + 500); // midpoint (1:1 here)
|
||||
}
|
||||
|
||||
static void testFrameToXClampsOutOfRange() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(frameToX(a, 1000, -50) == a.left); // below 0 pins left
|
||||
CHECK(frameToX(a, 1000, 5000) == a.right); // above count pins right
|
||||
}
|
||||
|
||||
static void testFrameToXDegenerate() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(frameToX(a, 0, 100) == a.left); // no frames -> left
|
||||
const Rect z = Rect{5, 5, 5, 45}; // zero width
|
||||
CHECK(frameToX(z, 1000, 500) == z.left);
|
||||
}
|
||||
|
||||
static void testXToFrameInverse() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(xToFrame(a, 1000, a.left) == 0);
|
||||
CHECK(xToFrame(a, 1000, a.right) == 1000);
|
||||
CHECK(xToFrame(a, 1000, a.left + 250) == 250); // 1:1 map here
|
||||
}
|
||||
|
||||
static void testXToFrameClampsOutside() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(xToFrame(a, 1000, a.left - 100) == 0); // left of area -> 0
|
||||
CHECK(xToFrame(a, 1000, a.right + 100) == 1000); // right of area -> frameCount
|
||||
CHECK(xToFrame(a, 0, a.left + 10) == 0); // no frames -> 0
|
||||
}
|
||||
|
||||
static void testFrameToXRoundTrip() {
|
||||
// Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then
|
||||
// xToFrame should land within a couple frames (rounding both directions).
|
||||
const Rect a = Rect{0, 0, 800, 60};
|
||||
for (std::int64_t f = 0; f <= 2000; f += 137) {
|
||||
const int x = frameToX(a, 2000, f);
|
||||
const std::int64_t back = xToFrame(a, 2000, x);
|
||||
CHECK(back >= f - 3 && back <= f + 3);
|
||||
}
|
||||
}
|
||||
|
||||
// --- markerAtPoint ------------------------------------------------------------
|
||||
|
||||
static void testMarkerAtPointGrabsWithinBand() {
|
||||
const Rect a = wideArea();
|
||||
// Markers at frames 100, 500, 900 -> x = left+100, left+500, left+900.
|
||||
const std::int64_t frames[3] = {100, 500, 900};
|
||||
const int midY = a.top + a.height() / 2;
|
||||
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 100, midY) == 0);
|
||||
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, midY) == 1);
|
||||
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 900, midY) == 2);
|
||||
// Within the grab band on either side of the line.
|
||||
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 + kMarkerGrabWidth, midY) == 1);
|
||||
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 - kMarkerGrabWidth, midY) == 1);
|
||||
}
|
||||
|
||||
static void testMarkerAtPointMissesBetween() {
|
||||
const Rect a = wideArea();
|
||||
const std::int64_t frames[3] = {100, 500, 900};
|
||||
const int midY = a.top + a.height() / 2;
|
||||
// Well away from any marker line.
|
||||
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 300, midY) == -1);
|
||||
// Off the area vertically.
|
||||
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, a.top - 5) == -1);
|
||||
}
|
||||
|
||||
static void testMarkerAtPointFirstMatchOnOverlap() {
|
||||
const Rect a = wideArea();
|
||||
// Two markers at the same frame -> first in order wins.
|
||||
const std::int64_t frames[2] = {400, 400};
|
||||
const int midY = a.top + a.height() / 2;
|
||||
CHECK(markerAtPoint(a, 1000, frames, 2, a.left + 400, midY) == 0);
|
||||
}
|
||||
|
||||
static void testMarkerAtPointRejectsNullEmpty() {
|
||||
const Rect a = wideArea();
|
||||
const int midY = a.top + a.height() / 2;
|
||||
CHECK(markerAtPoint(a, 1000, nullptr, 3, a.left + 100, midY) == -1);
|
||||
const std::int64_t frames[1] = {100};
|
||||
CHECK(markerAtPoint(a, 1000, frames, 0, a.left + 100, midY) == -1);
|
||||
}
|
||||
|
||||
// --- resolveDragFrame ---------------------------------------------------------
|
||||
|
||||
static void testResolveDragFrameShift() {
|
||||
const Rect a = wideArea(); // 1:1 (1000px / 1000 frames)
|
||||
CHECK(resolveDragFrame(a, 1000, 300, 0) == 300); // zero delta -> unchanged
|
||||
CHECK(resolveDragFrame(a, 1000, 300, 100) == 400); // +100px -> +100 frames
|
||||
CHECK(resolveDragFrame(a, 1000, 300, -50) == 250); // -50px -> -50 frames
|
||||
}
|
||||
|
||||
static void testResolveDragFrameClamps() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(resolveDragFrame(a, 1000, 50, -500) == 0); // clamp low
|
||||
CHECK(resolveDragFrame(a, 1000, 950, 500) == 1000); // clamp high (== frameCount)
|
||||
}
|
||||
|
||||
static void testResolveDragFrameRounds() {
|
||||
// 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is
|
||||
// at the frame centre. Use a scale where a fractional result appears.
|
||||
const Rect a = Rect{0, 0, 300, 60}; // 1000 frames / 300px = 3.33 frames/px
|
||||
// +3px -> 3*1000/300 = 10.0 -> 10 frames.
|
||||
CHECK(resolveDragFrame(a, 1000, 100, 3) == 110);
|
||||
// +1px -> 1000/300 = 3.33 -> rounds to 3.
|
||||
CHECK(resolveDragFrame(a, 1000, 100, 1) == 103);
|
||||
}
|
||||
|
||||
static void testResolveDragFrameDegenerate() {
|
||||
const Rect z = Rect{0, 0, 0, 60}; // zero width
|
||||
CHECK(resolveDragFrame(z, 1000, 300, 100) == 300); // pinned to start
|
||||
const Rect a = wideArea();
|
||||
CHECK(resolveDragFrame(a, 0, 300, 100) == 0); // no frames -> clamp(start)=0
|
||||
// startFrame out of range is clamped first.
|
||||
CHECK(resolveDragFrame(a, 1000, 5000, 0) == 1000);
|
||||
}
|
||||
|
||||
// --- nearestZeroCrossing ------------------------------------------------------
|
||||
|
||||
static void testZeroCrossingNearest() {
|
||||
// Crossings (sign change from i-1 to i): i=4 (1->-1), i=5 (-1->1), i=10 (1->-1).
|
||||
std::vector<AudioSample> pcm = {1, 1, 1, 1, -1, 1, 1, 1, 1, 1, -1, -1};
|
||||
// Target 4 is itself a crossing -> 4.
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 4);
|
||||
// Nearest to 6: crossing 5 (dist 1) beats 4 (dist 2) and 10 (dist 4) -> 5.
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 6) == 5);
|
||||
// Nearest to 9: crossing 10 (dist 1) beats 5 (dist 4) -> 10.
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 9) == 10);
|
||||
}
|
||||
|
||||
static void testZeroCrossingSampleOnZero() {
|
||||
// A sample exactly 0 is its own crossing (frame index of the zero sample).
|
||||
std::vector<AudioSample> pcm = {1, 1, 0, 1, 1};
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2);
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 3) == 2);
|
||||
}
|
||||
|
||||
static void testZeroCrossingEquidistantTieToLower() {
|
||||
// Crossings at i=2 (1->-1) and i=6 (-1->1). Target 4 is equidistant (dist 2) -> lower (2).
|
||||
std::vector<AudioSample> pcm = {1, 1, -1, -1, -1, -1, 1, 1};
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 2);
|
||||
}
|
||||
|
||||
static void testZeroCrossingNoneKeepsTarget() {
|
||||
// All one sign -> no crossing -> the (clamped) target comes back unchanged.
|
||||
std::vector<AudioSample> pcm = {0.5f, 0.6f, 0.7f, 0.8f};
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2);
|
||||
}
|
||||
|
||||
static void testZeroCrossingClampsTarget() {
|
||||
std::vector<AudioSample> pcm = {1, -1, 1, -1}; // crossings at 1,2,3
|
||||
// Target beyond the end clamps to frames-1 (3) then finds crossing at 3.
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 999) == 3);
|
||||
// Negative target clamps to 0; nearest crossing is 1.
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), -999) == 1);
|
||||
}
|
||||
|
||||
static void testZeroCrossingDegenerate() {
|
||||
CHECK(nearestZeroCrossing(nullptr, 0, 5) == 0);
|
||||
std::vector<AudioSample> one = {1};
|
||||
CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFrameToXEndpoints();
|
||||
testFrameToXClampsOutOfRange();
|
||||
testFrameToXDegenerate();
|
||||
testXToFrameInverse();
|
||||
testXToFrameClampsOutside();
|
||||
testFrameToXRoundTrip();
|
||||
|
||||
testMarkerAtPointGrabsWithinBand();
|
||||
testMarkerAtPointMissesBetween();
|
||||
testMarkerAtPointFirstMatchOnOverlap();
|
||||
testMarkerAtPointRejectsNullEmpty();
|
||||
|
||||
testResolveDragFrameShift();
|
||||
testResolveDragFrameClamps();
|
||||
testResolveDragFrameRounds();
|
||||
testResolveDragFrameDegenerate();
|
||||
|
||||
testZeroCrossingNearest();
|
||||
testZeroCrossingSampleOnZero();
|
||||
testZeroCrossingEquidistantTieToLower();
|
||||
testZeroCrossingNoneKeepsTarget();
|
||||
testZeroCrossingClampsTarget();
|
||||
testZeroCrossingDegenerate();
|
||||
|
||||
if (g_fail == 0) std::printf("waveform_view: all tests passed\n");
|
||||
else std::printf("waveform_view: %d FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
+1
Submodule vendor/vst3sdk added at dfff2e399c
Reference in New Issue
Block a user