Merge ps-w6-capture-first: S10 capture-first ReaSampler 9000 editor
This commit is contained in:
+41
-5
@@ -642,6 +642,23 @@ add_library(sample_map STATIC src/vst/sample_map.cpp)
|
|||||||
target_include_directories(sample_map PUBLIC src/vst src)
|
target_include_directories(sample_map PUBLIC src/vst src)
|
||||||
target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core)
|
target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core)
|
||||||
|
|
||||||
|
# capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the
|
||||||
|
# capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly
|
||||||
|
# grid/tab arithmetic lives here, unit-tested outside the DAW; the editor shell draws each
|
||||||
|
# card's peak thumbnail + name + badge and routes clicks into it. Links editor_geometry for
|
||||||
|
# the shared Rect + contains(). NEITHER SDK.
|
||||||
|
add_library(capture_browser STATIC src/vst/capture_browser.cpp)
|
||||||
|
target_include_directories(capture_browser PUBLIC src/vst)
|
||||||
|
target_link_libraries(capture_browser PUBLIC editor_geometry)
|
||||||
|
|
||||||
|
# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects +
|
||||||
|
# edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's
|
||||||
|
# keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of
|
||||||
|
# embed_strip; links editor_geometry for the shared Rect. NEITHER SDK.
|
||||||
|
add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp)
|
||||||
|
target_include_directories(keyboard_strip PUBLIC src/vst)
|
||||||
|
target_link_libraries(keyboard_strip PUBLIC editor_geometry)
|
||||||
|
|
||||||
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
|
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
|
||||||
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
|
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
|
||||||
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
|
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
|
||||||
@@ -661,6 +678,14 @@ add_executable(sample_map_tests tests/test_sample_map.cpp)
|
|||||||
target_link_libraries(sample_map_tests PRIVATE sample_map)
|
target_link_libraries(sample_map_tests PRIVATE sample_map)
|
||||||
add_test(NAME sample_map_tests COMMAND sample_map_tests)
|
add_test(NAME sample_map_tests COMMAND sample_map_tests)
|
||||||
|
|
||||||
|
add_executable(capture_browser_tests tests/test_capture_browser.cpp)
|
||||||
|
target_link_libraries(capture_browser_tests PRIVATE capture_browser)
|
||||||
|
add_test(NAME capture_browser_tests COMMAND capture_browser_tests)
|
||||||
|
|
||||||
|
add_executable(keyboard_strip_tests tests/test_keyboard_strip.cpp)
|
||||||
|
target_link_libraries(keyboard_strip_tests PRIVATE keyboard_strip)
|
||||||
|
add_test(NAME keyboard_strip_tests COMMAND keyboard_strip_tests)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -763,8 +788,13 @@ endif()
|
|||||||
# (VSTGUI/examples/tests are NOT). After `git submodule update --init vendor/vst3sdk`:
|
# (VSTGUI/examples/tests are NOT). After `git submodule update --init vendor/vst3sdk`:
|
||||||
# cd vendor/vst3sdk && git submodule update --init pluginterfaces base public.sdk
|
# cd vendor/vst3sdk && git submodule update --init pluginterfaces base public.sdk
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
if(WIN32)
|
set(VST3_SDK ${CMAKE_CURRENT_SOURCE_DIR}/vendor/vst3sdk)
|
||||||
set(VST3_SDK ${CMAKE_CURRENT_SOURCE_DIR}/vendor/vst3sdk)
|
# The VST3 module needs the nested vst3sdk slice (pluginterfaces / base / public.sdk)
|
||||||
|
# checked out — the one-time step documented above. When it is absent (a fresh clone
|
||||||
|
# that ran only the top-level `git submodule update --init`), skip the module rather than
|
||||||
|
# fail configure on missing sources: the pure geometry/mapping libraries + their CTest
|
||||||
|
# targets still build and test without the SDK. Probe one representative source file.
|
||||||
|
if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||||
|
|
||||||
# --- 5a) The bounded slice of the Steinberg VST3 SDK this spike needs. --------
|
# --- 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
|
# Enumerated (not add_subdirectory of the whole SDK) to keep the build hermetic and
|
||||||
@@ -832,12 +862,18 @@ if(WIN32)
|
|||||||
# app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so
|
# 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
|
# the instrument reads the SAME namespace the extension writes; its PUBLIC include dir
|
||||||
# (build/generated) carries version_generated.h for the channel bit.
|
# (build/generated) carries version_generated.h for the channel bit.
|
||||||
|
# capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip
|
||||||
|
# geometry the capture-first editor draws + hit-tests against; both link editor_geometry
|
||||||
|
# transitively (shared Rect).
|
||||||
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
||||||
sample_map capture_paths embed_strip app_version)
|
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip)
|
||||||
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
|
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
|
||||||
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
|
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
|
||||||
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
|
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
# A .vst3 is a DLL with a .vst3 extension and no lib-prefix.
|
# A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk
|
||||||
|
# product name (S-NAME-1, SETTLED 2026-07-26): reasampler_9000.vst3, matching the
|
||||||
|
# "ReaSampler 9000" display strings. The VST3 class UID (reasampler_vst.h) is unchanged
|
||||||
|
# — the compat anchor a saved instance rebinds by (save-rename-reopen is a DAW-verify).
|
||||||
set_target_properties(reasampler_vst PROPERTIES PREFIX "" SUFFIX ".vst3"
|
set_target_properties(reasampler_vst PROPERTIES PREFIX "" SUFFIX ".vst3"
|
||||||
OUTPUT_NAME "reasampler_vst")
|
OUTPUT_NAME "reasampler_9000")
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -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,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
|
||||||
+608
-183
@@ -1,22 +1,30 @@
|
|||||||
// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge.
|
// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the
|
||||||
// Windows-only (D5); the whole file is guarded so a non-Windows build (not a target,
|
// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is
|
||||||
// but keeps the TU honest) degrades to the CPluginView defaults.
|
// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults.
|
||||||
|
|
||||||
#include "reasampler_editor.h"
|
#include "reasampler_editor.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "editor_geometry.h"
|
#include "capture_browser.h"
|
||||||
|
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||||
|
#include "editor_geometry.h" // Rect, contains
|
||||||
#include "ext_keys.h"
|
#include "ext_keys.h"
|
||||||
|
#include "keyboard_strip.h"
|
||||||
|
#include "peaks.h" // computeEnvelope
|
||||||
#include "reaper_bridge.h"
|
#include "reaper_bridge.h"
|
||||||
#include "reasampler_processor.h"
|
#include "reasampler_processor.h"
|
||||||
|
#include "reasampler_vst.h" // kPluginName (the editor title band)
|
||||||
#include "sample_map.h"
|
#include "sample_map.h"
|
||||||
|
#include "wav_trim.h" // parseWavLayout, extractFloatFrames
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
||||||
|
|
||||||
// LICE — the same drawing stack bank_panel uses. On Windows LICE routes through native
|
|
||||||
// GDI; no SWELL needed for the child window itself.
|
|
||||||
#include "wdltypes.h"
|
#include "wdltypes.h"
|
||||||
#include "lice/lice.h"
|
#include "lice/lice.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -27,91 +35,194 @@ namespace reasampler::vst {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
constexpr const wchar_t* kChildClassName = L"ReaSamplerVstEditor";
|
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
|
||||||
|
|
||||||
// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads
|
// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry
|
||||||
// as the same tool.
|
// is in capture_browser / keyboard_strip). The title band names the plugin + a live
|
||||||
|
// readout; the toggle band carries the Browser/Zones switch; the setup band (single-
|
||||||
|
// capture face) hosts the keyboard strip + level readout under the browser.
|
||||||
|
constexpr int kTitleHeight = 24;
|
||||||
|
constexpr int kToggleHeight = 22;
|
||||||
|
constexpr int kSetupHeight = 96; // the single-capture setup surface (strip + labels)
|
||||||
|
constexpr int kStripBandHeight = 40;
|
||||||
|
|
||||||
|
// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as
|
||||||
|
// the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.)
|
||||||
const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255);
|
const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255);
|
||||||
const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255);
|
const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255);
|
||||||
const LICE_pixel kColBtnBg = LICE_RGBA(44, 44, 48, 255);
|
const LICE_pixel kColCardBg = LICE_RGBA(44, 44, 48, 255);
|
||||||
const LICE_pixel kColBtnHitBg = LICE_RGBA(58, 96, 84, 255);
|
const LICE_pixel kColCardSelBg = LICE_RGBA(48, 72, 64, 255);
|
||||||
const LICE_pixel kColBtnBorder = LICE_RGBA(120, 200, 160, 255);
|
const LICE_pixel kColCardBorder = LICE_RGBA(70, 70, 76, 255);
|
||||||
|
const LICE_pixel kColCardSelBorder = LICE_RGBA(120, 200, 160, 255);
|
||||||
|
const LICE_pixel kColTabBg = LICE_RGBA(36, 36, 40, 255);
|
||||||
|
const LICE_pixel kColTabActiveBg = LICE_RGBA(58, 96, 84, 255);
|
||||||
|
const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255);
|
||||||
|
const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255);
|
||||||
|
const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255);
|
||||||
|
const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255);
|
||||||
|
const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255);
|
||||||
|
const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255);
|
||||||
const COLORREF kRgbText = RGB(210, 230, 220);
|
const COLORREF kRgbText = RGB(210, 230, 220);
|
||||||
|
const COLORREF kRgbDim = RGB(140, 150, 146);
|
||||||
|
|
||||||
const LICE_pixel kColZoneSelBg = LICE_RGBA(48, 72, 64, 255);
|
void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col,
|
||||||
const LICE_pixel kColCtrlBg = LICE_RGBA(60, 60, 66, 255);
|
UINT fmt = DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX) {
|
||||||
|
|
||||||
void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
|
|
||||||
// LICE has no built-in font handle here; use GDI text into the bitmap DC, matching
|
|
||||||
// bank_panel's drawCenteredText approach (SetTextColor + DrawText on getDC()).
|
|
||||||
HDC dc = bmp->getDC();
|
HDC dc = bmp->getDC();
|
||||||
SetBkMode(dc, TRANSPARENT);
|
SetBkMode(dc, TRANSPARENT);
|
||||||
SetTextColor(dc, col);
|
SetTextColor(dc, col);
|
||||||
RECT gr{r.left, r.top, r.right, r.bottom};
|
RECT gr{r.left, r.top, r.right, r.bottom};
|
||||||
DrawTextA(dc, s, -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
|
DrawTextA(dc, s, -1, &gr, fmt | DT_END_ELLIPSIS);
|
||||||
}
|
}
|
||||||
|
|
||||||
void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
|
void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
|
||||||
HDC dc = bmp->getDC();
|
drawText(bmp, r, s, col, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
|
||||||
SetBkMode(dc, TRANSPARENT);
|
|
||||||
SetTextColor(dc, col);
|
|
||||||
RECT gr{r.left, r.top, r.right, r.bottom};
|
|
||||||
DrawTextA(dc, s, -1, &gr, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp a MIDI note to [0, 127].
|
// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the
|
||||||
int clampNote(int n) { return n < 0 ? 0 : (n > 127 ? 127 : n); }
|
// common DAW convention REAPER uses).
|
||||||
|
std::string noteLabel(int note) {
|
||||||
|
static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F",
|
||||||
|
"F#", "G", "G#", "A", "A#", "B"};
|
||||||
|
if (note < 0) note = 0;
|
||||||
|
if (note > 127) note = 127;
|
||||||
|
const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4
|
||||||
|
return std::string(kNames[note % 12]) + std::to_string(octave);
|
||||||
|
}
|
||||||
|
|
||||||
// A short display name for a bank sample id, from the snapshotted list (id if unnamed,
|
// Draw a mono peak envelope centered vertically in `r` (mirror of bank_panel::drawThumbnail,
|
||||||
// "?" if the id no longer resolves — e.g. a stale zone).
|
// single channel). Each bin is a vertical line from its min to its max about the midline.
|
||||||
|
void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
|
||||||
|
if (r.width() <= 0 || r.height() <= 0 || env.empty() || env[0].empty()) return;
|
||||||
|
const ChannelEnvelope& ch = env[0];
|
||||||
|
const int mid = r.top + r.height() / 2;
|
||||||
|
const int halfH = r.height() / 2;
|
||||||
|
const int bins = static_cast<int>(ch.size());
|
||||||
|
for (int x = 0; x < r.width() && x < bins; ++x) {
|
||||||
|
const MinMax& mm = ch[static_cast<std::size_t>(x)];
|
||||||
|
const int yTop = mid - static_cast<int>(mm.max * halfH);
|
||||||
|
const int yBot = mid - static_cast<int>(mm.min * halfH);
|
||||||
|
LICE_Line(bmp, r.left + x, yTop, r.left + x, yBot, kColThumb, 1.0f, 0, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A display name for a bank sample id from the snapshotted list ("?" if the id no longer
|
||||||
|
// resolves — e.g. a zone naming a deleted sample).
|
||||||
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
|
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
|
||||||
for (const SampleChoice& c : samples) {
|
for (const SampleChoice& c : samples) {
|
||||||
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
|
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
|
||||||
}
|
}
|
||||||
return "?"; // stale: id not in the live bank
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin
|
||||||
|
// per horizontal pixel.
|
||||||
|
int thumbBins(const BrowserLayout& layout) {
|
||||||
|
return (std::max)(1, cardThumbnailRect(layout, 0).width());
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||||||
: CPluginView(nullptr), processor_(processor) {
|
: CPluginView(nullptr), processor_(processor) {
|
||||||
// Default view size; the host may resize (canResize() == true).
|
// Default view size — sized to show a couple of card rows + the setup strip.
|
||||||
ViewRect r(0, 0, 420, 260);
|
ViewRect r(0, 0, 560, 400);
|
||||||
setRect(r);
|
setRect(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::refreshSampleList() {
|
void ReaSamplerEditor::refreshFromBank() {
|
||||||
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
|
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
|
||||||
|
thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks
|
||||||
if (!processor_) {
|
if (!processor_) {
|
||||||
samples_.clear();
|
samples_.clear();
|
||||||
|
banks_.clear();
|
||||||
|
visible_.clear();
|
||||||
selectedId_.clear();
|
selectedId_.clear();
|
||||||
map_.zones.clear();
|
map_.zones.clear();
|
||||||
selectedZone_ = -1;
|
selectedZone_ = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||||
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
|
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
|
||||||
|
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
|
||||||
selectedId_ = processor_->selectedSampleId();
|
selectedId_ = processor_->selectedSampleId();
|
||||||
map_ = processor_->performanceMap();
|
map_ = processor_->performanceMap();
|
||||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||||
|
// Drop a filter that names a bank no longer present.
|
||||||
|
if (!activeFilterBankId_.empty()) {
|
||||||
|
bool found = false;
|
||||||
|
for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true;
|
||||||
|
if (!found) activeFilterBankId_.clear();
|
||||||
|
}
|
||||||
|
rebuildVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::commitMapAndReload() {
|
void ReaSamplerEditor::rebuildVisible() {
|
||||||
// UI thread only. Publish the edited map to the processor, then rebuild the instrument
|
visible_.clear();
|
||||||
// off the audio thread (reloadFromBank bakes the zones into the live Keymap).
|
for (const SampleChoice& s : samples_) {
|
||||||
|
if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_)
|
||||||
|
visible_.push_back(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::commitAndReload() {
|
||||||
|
// UI thread only. Publish the edited selection + zones to the processor, then rebuild
|
||||||
|
// the instrument off the audio thread (reloadFromBank bakes them into the live Keymap).
|
||||||
if (!processor_) return;
|
if (!processor_) return;
|
||||||
|
processor_->setSelectedSampleId(selectedId_);
|
||||||
processor_->setPerformanceMap(map_);
|
processor_->setPerformanceMap(map_);
|
||||||
processor_->reloadFromBank();
|
processor_->reloadFromBank();
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
|
invalidate();
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
|
||||||
|
const std::string key = sampleId + "|" + std::to_string(binCount);
|
||||||
|
auto it = thumbCache_.find(key);
|
||||||
|
if (it != thumbCache_.end()) return it->second;
|
||||||
|
|
||||||
|
// SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so
|
||||||
|
// resolve the path from the live bank blob (selectSample) and decode via the shared WAV
|
||||||
|
// parse — the mirror of the processor's decodeRelative. Every failure path caches an
|
||||||
|
// empty envelope so a broken/missing file is not re-decoded on every paint.
|
||||||
|
std::string relativePath;
|
||||||
|
Envelope env;
|
||||||
|
if (processor_) {
|
||||||
|
auto banksJson =
|
||||||
|
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||||
|
if (banksJson) {
|
||||||
|
if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath;
|
||||||
|
}
|
||||||
|
if (!relativePath.empty()) {
|
||||||
|
const std::string projectDir = processor_->bridge().activeProjectDir();
|
||||||
|
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||||
|
std::vector<std::uint8_t> bytes;
|
||||||
|
std::ifstream f(abs, std::ios::binary | std::ios::ate);
|
||||||
|
if (f) {
|
||||||
|
const std::streamoff size = f.tellg();
|
||||||
|
if (size > 0) {
|
||||||
|
f.seekg(0, std::ios::beg);
|
||||||
|
bytes.resize(static_cast<std::size_t>(size));
|
||||||
|
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const WavLayout layout = parseWavLayout(bytes);
|
||||||
|
if (layout.valid) {
|
||||||
|
std::vector<AudioSample> interleaved =
|
||||||
|
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
||||||
|
std::vector<AudioSample> mono =
|
||||||
|
downmixToMono(interleaved, layout.channelCount);
|
||||||
|
env = computeEnvelope(mono, 1, mono.size(),
|
||||||
|
static_cast<std::size_t>((std::max)(1, binCount)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto ins = thumbCache_.emplace(key, std::move(env));
|
||||||
|
return ins.first->second;
|
||||||
|
}
|
||||||
|
|
||||||
ReaSamplerEditor::~ReaSamplerEditor() {
|
ReaSamplerEditor::~ReaSamplerEditor() {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
// Defensive teardown: the host normally calls removed() (which destroys the child)
|
|
||||||
// before releasing us, but if we're destroyed while still attached, don't leak the
|
|
||||||
// window — mirror the create in attachedToParent().
|
|
||||||
if (childHwnd_) {
|
if (childHwnd_) {
|
||||||
DestroyWindow(childHwnd_);
|
DestroyWindow(childHwnd_);
|
||||||
childHwnd_ = nullptr;
|
childHwnd_ = nullptr;
|
||||||
@@ -132,17 +243,18 @@ tresult PLUGIN_API ReaSamplerEditor::canResize() {
|
|||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
|
||||||
|
void ReaSamplerEditor::invalidate() {
|
||||||
|
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::attachedToParent() {
|
void ReaSamplerEditor::attachedToParent() {
|
||||||
// systemWindow is the parent HWND the host created (CPluginView::attached set it
|
|
||||||
// from the void* parent when the type is HWND).
|
|
||||||
HWND parent = static_cast<HWND>(systemWindow);
|
HWND parent = static_cast<HWND>(systemWindow);
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
|
|
||||||
HINSTANCE hInst = reinterpret_cast<HINSTANCE>(
|
HINSTANCE hInst =
|
||||||
GetWindowLongPtr(parent, GWLP_HINSTANCE));
|
reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE));
|
||||||
if (!hInst) hInst = GetModuleHandle(nullptr);
|
if (!hInst) hInst = GetModuleHandle(nullptr);
|
||||||
|
|
||||||
// Register the child window class once per module.
|
|
||||||
static bool classRegistered = false;
|
static bool classRegistered = false;
|
||||||
if (!classRegistered) {
|
if (!classRegistered) {
|
||||||
WNDCLASSW wc{};
|
WNDCLASSW wc{};
|
||||||
@@ -155,17 +267,13 @@ void ReaSamplerEditor::attachedToParent() {
|
|||||||
classRegistered = true;
|
classRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the live bank so the first paint shows the sample list.
|
refreshFromBank();
|
||||||
refreshSampleList();
|
|
||||||
|
|
||||||
const ViewRect& r = getRect();
|
const ViewRect& r = getRect();
|
||||||
childHwnd_ = CreateWindowExW(
|
childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0,
|
||||||
0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(),
|
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
|
||||||
r.getHeight(), parent, nullptr, hInst, nullptr);
|
|
||||||
if (childHwnd_) {
|
if (childHwnd_) {
|
||||||
// Stash `this` so wndProc can route messages back to the instance.
|
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
|
||||||
SetWindowLongPtr(childHwnd_, GWLP_USERDATA,
|
|
||||||
reinterpret_cast<LONG_PTR>(this));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,15 +285,57 @@ void ReaSamplerEditor::removedFromParent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
|
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
|
||||||
// Let CPluginView latch the new rect, then resize the child window to match so the
|
|
||||||
// LICE surface fills the host-provided seat.
|
|
||||||
tresult res = CPluginView::onSize(newSize);
|
tresult res = CPluginView::onSize(newSize);
|
||||||
if (childHwnd_ && newSize) {
|
if (childHwnd_ && newSize) {
|
||||||
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
|
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
|
||||||
|
thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The client bands: title (top), toggle (below title), then the mode content. In the
|
||||||
|
// browser view the content is the browser grid on top of the single-capture setup band
|
||||||
|
// (when a capture is picked); in the zones view the content is the zones strip + list.
|
||||||
|
namespace {
|
||||||
|
struct EditorBands {
|
||||||
|
Rect title;
|
||||||
|
Rect toggleBrowser; // left half of the toggle band
|
||||||
|
Rect toggleZones; // right half
|
||||||
|
Rect content; // below the toggle band: the mode's own area
|
||||||
|
};
|
||||||
|
EditorBands computeBands(int w, int h) {
|
||||||
|
EditorBands b;
|
||||||
|
const int titleH = (std::min)(kTitleHeight, h);
|
||||||
|
b.title = Rect{0, 0, w, titleH};
|
||||||
|
const int toggleTop = titleH;
|
||||||
|
const int toggleBot = (std::min)(h, toggleTop + kToggleHeight);
|
||||||
|
b.toggleBrowser = Rect{0, toggleTop, w / 2, toggleBot};
|
||||||
|
b.toggleZones = Rect{w / 2, toggleTop, w, toggleBot};
|
||||||
|
b.content = Rect{0, toggleBot, w, h};
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The keyboard strip rectangle inside the setup area (single-capture root-drag face).
|
||||||
|
// `area` is the full setup Rect; the strip is anchored at the bottom with an 8px horizontal
|
||||||
|
// pad. All three call sites (paintSetup, onMouseDown, onMouseMove) use this single formula.
|
||||||
|
Rect setupStripArea(const Rect& area) {
|
||||||
|
constexpr int pad = 8;
|
||||||
|
const int stripTop = area.bottom - kStripBandHeight;
|
||||||
|
return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the
|
||||||
|
// mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20)
|
||||||
|
// with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown,
|
||||||
|
// onMouseMove) use this single formula — the inline arithmetic in onMouseMove was the drift.
|
||||||
|
Rect zonesStripArea(const EditorBands& bands) {
|
||||||
|
constexpr int pad = 8;
|
||||||
|
const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12
|
||||||
|
return Rect{bands.content.left + pad, stripTop, bands.content.right - pad,
|
||||||
|
stripTop + kStripBandHeight};
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void ReaSamplerEditor::paint(HDC hdc) {
|
void ReaSamplerEditor::paint(HDC hdc) {
|
||||||
RECT cr{};
|
RECT cr{};
|
||||||
GetClientRect(childHwnd_, &cr);
|
GetClientRect(childHwnd_, &cr);
|
||||||
@@ -193,173 +343,427 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
|||||||
const int h = cr.bottom - cr.top;
|
const int h = cr.bottom - cr.top;
|
||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
// Same double-buffered LICE pattern as bank_panel::paintPanel: draw into a
|
|
||||||
// LICE_SysBitmap, then BitBlt to the window DC.
|
|
||||||
LICE_SysBitmap bmp(w, h);
|
LICE_SysBitmap bmp(w, h);
|
||||||
LICE_Clear(&bmp, kColBackground);
|
LICE_Clear(&bmp, kColBackground);
|
||||||
|
|
||||||
const KeymapEditorLayout layout = layoutKeymapEditor(w, h);
|
const EditorBands bands = computeBands(w, h);
|
||||||
const EditorLayout& base = layout.base;
|
|
||||||
|
|
||||||
// Title band: the plugin name + a live-state / mode readout.
|
// Title band: product name + live readout.
|
||||||
LICE_FillRect(&bmp, base.titleBar.left, base.titleBar.top, base.titleBar.width(),
|
LICE_FillRect(&bmp, bands.title.left, bands.title.top, bands.title.width(),
|
||||||
base.titleBar.height(), kColTitleBg, 1.0f, 0);
|
bands.title.height(), kColTitleBg, 1.0f, 0);
|
||||||
std::string title = "ReaSampler Instrument";
|
std::string title = kPluginName;
|
||||||
if (processor_ && processor_->bridge().isConnected()) {
|
if (processor_ && processor_->bridge().isConnected()) {
|
||||||
if (samples_.empty()) {
|
if (samples_.empty()) title += " [bank empty]";
|
||||||
title += " [bank: empty]";
|
else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]";
|
||||||
} else if (map_.zones.empty()) {
|
else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
|
||||||
title += " [Tier 0: pick a sample - Add Zone for a keymap]";
|
else title += " [" + sampleLabel(samples_, selectedId_) + "]";
|
||||||
} else {
|
|
||||||
title += " [keymap: " + std::to_string(map_.zones.size()) + " zone(s)]";
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
title += " [host: no bridge]";
|
title += " [host: no bridge]";
|
||||||
}
|
}
|
||||||
Rect titleText{base.titleBar.left + 8, base.titleBar.top, base.titleBar.right - 8,
|
Rect titleText{bands.title.left + 8, bands.title.top, bands.title.right - 8,
|
||||||
base.titleBar.bottom};
|
bands.title.bottom};
|
||||||
drawText(&bmp, titleText, title.c_str(), kRgbText);
|
drawText(&bmp, titleText, title.c_str(), kRgbText);
|
||||||
|
|
||||||
// LEFT column: the bank-sample list. The selected id (fallback + "sample to add" for
|
// Toggle band: Browser | Zones.
|
||||||
// a new zone) is highlighted. Clip rows past the visible list.
|
const bool inZones = (view_ == View::kZones);
|
||||||
for (int i = 0; i < static_cast<int>(samples_.size()); ++i) {
|
LICE_FillRect(&bmp, bands.toggleBrowser.left, bands.toggleBrowser.top,
|
||||||
const Rect row = keymapSampleRowRect(layout, i);
|
bands.toggleBrowser.width(), bands.toggleBrowser.height(),
|
||||||
if (row.top >= layout.sampleList.bottom) break;
|
inZones ? kColTabBg : kColTabActiveBg, 1.0f, 0);
|
||||||
const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_;
|
LICE_FillRect(&bmp, bands.toggleZones.left, bands.toggleZones.top,
|
||||||
LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(),
|
bands.toggleZones.width(), bands.toggleZones.height(),
|
||||||
sel ? kColBtnHitBg : kColBtnBg, 1.0f, 0);
|
inZones ? kColTabActiveBg : kColTabBg, 1.0f, 0);
|
||||||
if (sel) {
|
drawTextCentered(&bmp, bands.toggleBrowser, "Browser", kRgbText);
|
||||||
LICE_DrawRect(&bmp, row.left, row.top, row.width() - 1, row.height() - 1,
|
drawTextCentered(&bmp, bands.toggleZones, "Zones", kRgbText);
|
||||||
kColBtnBorder, 1.0f, 0);
|
|
||||||
}
|
|
||||||
Rect textR{row.left + 8, row.top, row.right - 8, row.bottom};
|
|
||||||
const std::string& name = samples_[i].displayName;
|
|
||||||
drawText(&bmp, textR, name.empty() ? samples_[i].id.c_str() : name.c_str(),
|
|
||||||
kRgbText);
|
|
||||||
}
|
|
||||||
|
|
||||||
// RIGHT column: the zone panel. "Add Zone" button on top, then one row per zone.
|
if (view_ == View::kZones) {
|
||||||
LICE_FillRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top,
|
paintZones(&bmp, w, h);
|
||||||
layout.addZoneButton.width(), layout.addZoneButton.height(), kColBtnBg,
|
} else {
|
||||||
1.0f, 0);
|
paintBrowser(&bmp, w, h);
|
||||||
LICE_DrawRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top,
|
|
||||||
layout.addZoneButton.width() - 1, layout.addZoneButton.height() - 1,
|
|
||||||
kColBtnBorder, 1.0f, 0);
|
|
||||||
drawTextCentered(&bmp, layout.addZoneButton, "+ Add Zone", kRgbText);
|
|
||||||
|
|
||||||
// The seven per-row controls, laid out left-to-right pinned to the right edge.
|
|
||||||
const char* kCtrlGlyphs[7] = {"-", "+", "-", "+", "-", "+", "x"};
|
|
||||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
|
||||||
const Rect row = zoneRowRect(layout, i);
|
|
||||||
if (row.top >= layout.zoneRowArea.bottom) break; // past the visible panel
|
|
||||||
const bool sel = (i == selectedZone_);
|
|
||||||
LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(),
|
|
||||||
sel ? kColZoneSelBg : kColBtnBg, 1.0f, 0);
|
|
||||||
|
|
||||||
const PerformanceZone& z = map_.zones[i];
|
|
||||||
std::string label = sampleLabel(samples_, z.sampleId);
|
|
||||||
label += " " + std::to_string(z.lowNote) + "-" + std::to_string(z.highNote);
|
|
||||||
label += " r" + (z.rootOverride ? std::to_string(*z.rootOverride) + "*"
|
|
||||||
: std::string("(bank)"));
|
|
||||||
// Label area stops where the control block begins (7 * ctrl width from the right).
|
|
||||||
const int ctrlBlockLeft = row.right - 7 * kZoneCtrlWidth;
|
|
||||||
Rect textR{row.left + 6, row.top, ctrlBlockLeft - 4, row.bottom};
|
|
||||||
drawText(&bmp, textR, label.c_str(), kRgbText);
|
|
||||||
|
|
||||||
// Draw the 7 mini-buttons.
|
|
||||||
for (int s = 0; s < 7; ++s) {
|
|
||||||
const int bx = ctrlBlockLeft + s * kZoneCtrlWidth;
|
|
||||||
Rect cell{bx, row.top + 2, bx + kZoneCtrlWidth - 1, row.bottom - 2};
|
|
||||||
LICE_FillRect(&bmp, cell.left, cell.top, cell.width(), cell.height(),
|
|
||||||
kColCtrlBg, 1.0f, 0);
|
|
||||||
drawTextCentered(&bmp, cell, kCtrlGlyphs[s], kRgbText);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::onClick(int x, int y) {
|
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
|
||||||
|
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
|
||||||
|
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
|
||||||
|
const char* msg = samples_.empty()
|
||||||
|
? "No captures in this project yet — capture audio into the bank to play it here."
|
||||||
|
: "No captures in this bank filter. Choose another bank tab above.";
|
||||||
|
drawTextCentered(bmp, area, msg, kRgbDim);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
|
||||||
|
const EditorBands bands = computeBands(w, h);
|
||||||
|
// When a capture is picked, the setup band takes the bottom; the browser gets the rest.
|
||||||
|
const bool havePick = !selectedId_.empty();
|
||||||
|
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
||||||
|
: bands.content.bottom;
|
||||||
|
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
||||||
|
|
||||||
|
// The browser tabs + card grid, laid out by the pure module over the browser sub-area.
|
||||||
|
// capture_browser lays out from (0,0); offset the draw by browserArea's origin.
|
||||||
|
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
|
||||||
|
const int ox = browserArea.left;
|
||||||
|
const int oy = browserArea.top;
|
||||||
|
|
||||||
|
// Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights.
|
||||||
|
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||||
|
for (int i = 0; i < tabCount; ++i) {
|
||||||
|
Rect t = filterTabRect(bl, tabCount, i);
|
||||||
|
t = Rect{t.left + ox, t.top + oy, t.right + ox, t.bottom + oy};
|
||||||
|
const std::string label = (i == 0) ? "All" : banks_[static_cast<std::size_t>(i - 1)].displayName;
|
||||||
|
const bool active = (i == 0) ? activeFilterBankId_.empty()
|
||||||
|
: (banks_[static_cast<std::size_t>(i - 1)].id == activeFilterBankId_);
|
||||||
|
LICE_FillRect(bmp, t.left, t.top, t.width(), t.height(),
|
||||||
|
active ? kColTabActiveBg : kColTabBg, 1.0f, 0);
|
||||||
|
drawTextCentered(bmp, t, label.c_str(), kRgbText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cards: one per visible sample. Clip at the browser area bottom (scroll is S12).
|
||||||
|
const int bins = thumbBins(bl);
|
||||||
|
for (int i = 0; i < static_cast<int>(visible_.size()); ++i) {
|
||||||
|
Rect content = cardContentRect(bl, i);
|
||||||
|
if (content.top + oy >= browserArea.bottom) break; // past the visible grid
|
||||||
|
Rect thumb = cardThumbnailRect(bl, i);
|
||||||
|
Rect labelR = cardLabelRect(bl, i);
|
||||||
|
content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy};
|
||||||
|
thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy};
|
||||||
|
labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy};
|
||||||
|
|
||||||
|
const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
|
||||||
|
const bool sel = (s.id == selectedId_);
|
||||||
|
LICE_FillRect(bmp, content.left, content.top, content.width(), content.height(),
|
||||||
|
sel ? kColCardSelBg : kColCardBg, 1.0f, 0);
|
||||||
|
LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1,
|
||||||
|
sel ? kColCardSelBorder : kColCardBorder, 1.0f, 0);
|
||||||
|
drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins));
|
||||||
|
|
||||||
|
// Name + root/key badge under the thumbnail.
|
||||||
|
std::string caption = s.displayName.empty() ? s.id : s.displayName;
|
||||||
|
Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2};
|
||||||
|
Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom};
|
||||||
|
drawText(bmp, nameR, caption.c_str(), kRgbText);
|
||||||
|
std::string badge;
|
||||||
|
if (s.rootNote) badge = "root " + noteLabel(*s.rootNote);
|
||||||
|
else if (s.key) badge = *s.key;
|
||||||
|
else badge = "root —";
|
||||||
|
drawText(bmp, badgeR, badge.c_str(), kRgbDim);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (havePick) {
|
||||||
|
paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom});
|
||||||
|
} else if (visible_.empty()) {
|
||||||
|
paintEmptyState(bmp, browserArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) {
|
||||||
|
// The guided single-capture setup: the picked capture's name + root/level, and a
|
||||||
|
// keyboard strip with its root marker (drag to set root).
|
||||||
|
LICE_FillRect(bmp, area.left, area.top, area.width(), area.height(),
|
||||||
|
kColTitleBg, 1.0f, 0);
|
||||||
|
|
||||||
|
// Effective root: the picked sample's rootNote intrinsic (or middle C when unset).
|
||||||
|
// Read from samples_ (the full unfiltered list) so a bank-filter that hides the
|
||||||
|
// picked sample's bank doesn't mask its intrinsic root with the C4 default.
|
||||||
|
int root = 60;
|
||||||
|
for (const SampleChoice& s : samples_) {
|
||||||
|
if (s.id == selectedId_ && s.rootNote) root = *s.rootNote;
|
||||||
|
}
|
||||||
|
// If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root.
|
||||||
|
for (const PerformanceZone& z : map_.zones) {
|
||||||
|
if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int pad = 8;
|
||||||
|
Rect headerR{area.left + pad, area.top + 4, area.right - pad, area.top + 22};
|
||||||
|
std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root);
|
||||||
|
drawText(bmp, headerR, header.c_str(), kRgbText);
|
||||||
|
|
||||||
|
Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16};
|
||||||
|
drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim);
|
||||||
|
|
||||||
|
// Keyboard strip with the root marker.
|
||||||
|
const Rect stripArea = setupStripArea(area);
|
||||||
|
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
|
||||||
|
const int sx = stripArea.left;
|
||||||
|
const int sy = stripArea.top;
|
||||||
|
LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(),
|
||||||
|
kColStripBg, 1.0f, 0);
|
||||||
|
// Faint per-octave key ticks for orientation.
|
||||||
|
for (int n = 0; n <= 127; n += 12) {
|
||||||
|
Rect k = keyRect(sl, n);
|
||||||
|
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey,
|
||||||
|
1.0f, 0, false);
|
||||||
|
}
|
||||||
|
Rect marker = rootMarkerRect(sl, root);
|
||||||
|
LICE_FillRect(bmp, marker.left + sx, sy, (std::max)(2, marker.width()), stripArea.height(),
|
||||||
|
kColRootMarker, 1.0f, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
|
||||||
|
const EditorBands bands = computeBands(w, h);
|
||||||
|
const int pad = 8;
|
||||||
|
|
||||||
|
// A single "+ Add Zone" affordance at the top of the content, then the keyboard strip
|
||||||
|
// with one bar per zone. Delete is a small × on the selected zone (keystroke also).
|
||||||
|
Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
|
||||||
|
bands.content.top + 4 + 20};
|
||||||
|
LICE_FillRect(bmp, addR.left, addR.top, addR.width(), addR.height(), kColCardBg, 1.0f, 0);
|
||||||
|
LICE_DrawRect(bmp, addR.left, addR.top, addR.width() - 1, addR.height() - 1,
|
||||||
|
kColCardSelBorder, 1.0f, 0);
|
||||||
|
drawTextCentered(bmp, addR, "+ Add Zone", kRgbText);
|
||||||
|
|
||||||
|
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
|
||||||
|
if (selectedZone_ >= 0) {
|
||||||
|
LICE_FillRect(bmp, delR.left, delR.top, delR.width(), delR.height(), kColCardBg, 1.0f, 0);
|
||||||
|
LICE_DrawRect(bmp, delR.left, delR.top, delR.width() - 1, delR.height() - 1,
|
||||||
|
kColCardSelBorder, 1.0f, 0);
|
||||||
|
drawTextCentered(bmp, delR, "Delete", kRgbText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zones strip.
|
||||||
|
const Rect stripArea = zonesStripArea(bands);
|
||||||
|
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
|
||||||
|
const int sx = stripArea.left;
|
||||||
|
const int sy = stripArea.top;
|
||||||
|
LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(),
|
||||||
|
kColStripBg, 1.0f, 0);
|
||||||
|
for (int n = 0; n <= 127; n += 12) {
|
||||||
|
Rect k = keyRect(sl, n);
|
||||||
|
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey,
|
||||||
|
1.0f, 0, false);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||||
|
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||||
|
Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
|
||||||
|
const bool sel = (i == selectedZone_);
|
||||||
|
LICE_FillRect(bmp, bar.left + sx, sy, (std::max)(2, bar.width()), stripArea.height(),
|
||||||
|
sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A one-line legend of the selected zone below the strip.
|
||||||
|
Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26};
|
||||||
|
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||||
|
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||||
|
std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) +
|
||||||
|
" – " + noteLabel(z.highNote) + " root " +
|
||||||
|
(z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)"));
|
||||||
|
drawText(bmp, infoR, info.c_str(), kRgbText);
|
||||||
|
} else if (map_.zones.empty()) {
|
||||||
|
drawText(bmp, infoR,
|
||||||
|
"No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Input: the drag-state machine -------------------------------------------
|
||||||
|
|
||||||
|
void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||||
if (!processor_) return;
|
if (!processor_) return;
|
||||||
RECT cr{};
|
RECT cr{};
|
||||||
GetClientRect(childHwnd_, &cr);
|
GetClientRect(childHwnd_, &cr);
|
||||||
const KeymapEditorLayout layout =
|
const int w = cr.right - cr.left;
|
||||||
layoutKeymapEditor(cr.right - cr.left, cr.bottom - cr.top);
|
const int h = cr.bottom - cr.top;
|
||||||
|
const EditorBands bands = computeBands(w, h);
|
||||||
|
|
||||||
// 1. Left list: pick the Tier-0 fallback / the sample a new zone will use.
|
// Toggle band: switch views.
|
||||||
const int row =
|
if (contains(bands.toggleBrowser, x, y)) { view_ = View::kBrowser; invalidate(); return; }
|
||||||
keymapSampleRowHitTest(layout, static_cast<int>(samples_.size()), x, y);
|
if (contains(bands.toggleZones, x, y)) { view_ = View::kZones; invalidate(); return; }
|
||||||
if (row >= 0) {
|
|
||||||
processor_->setSelectedSampleId(samples_[row].id);
|
if (view_ == View::kBrowser) {
|
||||||
selectedId_ = samples_[row].id;
|
const bool havePick = !selectedId_.empty();
|
||||||
// A fallback change only affects playback when the map is empty; reload so the
|
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
||||||
// Tier-0 case updates immediately.
|
: bands.content.bottom;
|
||||||
processor_->reloadFromBank();
|
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
||||||
InvalidateRect(childHwnd_, nullptr, FALSE);
|
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
|
||||||
|
const int bx = x - browserArea.left;
|
||||||
|
const int by = y - browserArea.top;
|
||||||
|
|
||||||
|
// Filter tabs.
|
||||||
|
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||||
|
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||||
|
if (tab >= 0) {
|
||||||
|
activeFilterBankId_ = (tab == 0) ? std::string()
|
||||||
|
: banks_[static_cast<std::size_t>(tab - 1)].id;
|
||||||
|
rebuildVisible();
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture).
|
||||||
|
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by);
|
||||||
|
if (card >= 0) {
|
||||||
|
selectedId_ = visible_[static_cast<std::size_t>(card)].id;
|
||||||
|
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The setup strip: grab the root marker (drag to set the picked capture's root).
|
||||||
|
if (havePick) {
|
||||||
|
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
|
||||||
|
const Rect stripArea = setupStripArea(area);
|
||||||
|
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
|
||||||
|
const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top);
|
||||||
|
if (note >= 0) {
|
||||||
|
drag_ = DragKind::kRootMarker;
|
||||||
|
dragStartX_ = x;
|
||||||
|
dragStartRoot_ = note;
|
||||||
|
// A click sets the root immediately (drag then refines); the override lives on
|
||||||
|
// a one-zone map entry for the picked capture (D-B, never written to the bank).
|
||||||
|
onMouseMove(x, y); // apply the click position as the first delta==0 set
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. "Add Zone": append a full-keyboard zone for the currently-selected sample (root
|
// Zones view.
|
||||||
// from the bank intrinsic — no override until the user nudges it).
|
const int pad = 8;
|
||||||
if (addZoneHitTest(layout, x, y)) {
|
Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
|
||||||
if (selectedId_.empty()) return; // nothing selected to add
|
bands.content.top + 4 + 20};
|
||||||
|
if (contains(addR, x, y)) {
|
||||||
|
// Add a full-keyboard zone for the picked capture (or the first visible sample as a
|
||||||
|
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
|
||||||
|
// already exists, select it rather than appending a duplicate (mirrors the upsert the
|
||||||
|
// root-marker drag path already performs, preventing overlapping identical zones).
|
||||||
|
std::string seed = !selectedId_.empty() ? selectedId_
|
||||||
|
: (!visible_.empty() ? visible_.front().id : std::string());
|
||||||
|
if (seed.empty()) return;
|
||||||
|
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||||
|
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||||
|
if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) {
|
||||||
|
selectedZone_ = i;
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
PerformanceZone z;
|
PerformanceZone z;
|
||||||
z.sampleId = selectedId_;
|
z.sampleId = seed;
|
||||||
z.lowNote = 0;
|
z.lowNote = 0;
|
||||||
z.highNote = 127;
|
z.highNote = 127;
|
||||||
map_.zones.push_back(z);
|
map_.zones.push_back(z);
|
||||||
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
|
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
|
||||||
commitMapAndReload();
|
commitAndReload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
|
||||||
// 3. Zone rows: select a zone, nudge its range/root, or delete it.
|
if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||||||
const ZoneHit hit =
|
map_.zones.erase(map_.zones.begin() + selectedZone_);
|
||||||
zoneHitTest(layout, static_cast<int>(map_.zones.size()), x, y);
|
|
||||||
if (hit.zoneIndex < 0) return;
|
|
||||||
selectedZone_ = hit.zoneIndex;
|
|
||||||
|
|
||||||
if (hit.field == ZoneField::kZoneNone) {
|
|
||||||
InvalidateRect(childHwnd_, nullptr, FALSE); // select only, no reload
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hit.field == ZoneField::kDelete) {
|
|
||||||
map_.zones.erase(map_.zones.begin() + hit.zoneIndex);
|
|
||||||
selectedZone_ = -1;
|
selectedZone_ = -1;
|
||||||
commitMapAndReload();
|
commitAndReload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PerformanceZone& z = map_.zones[hit.zoneIndex];
|
// The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the
|
||||||
switch (hit.field) {
|
// selected zone's root.
|
||||||
case ZoneField::kLowDown: z.lowNote = clampNote(z.lowNote - 1); break;
|
const Rect stripArea = zonesStripArea(bands);
|
||||||
case ZoneField::kLowUp: z.lowNote = clampNote(z.lowNote + 1); break;
|
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
|
||||||
case ZoneField::kHighDown: z.highNote = clampNote(z.highNote - 1); break;
|
const int lx = x - stripArea.left;
|
||||||
case ZoneField::kHighUp: z.highNote = clampNote(z.highNote + 1); break;
|
const int ly = y - stripArea.top;
|
||||||
case ZoneField::kRootDown: {
|
|
||||||
const int base = z.rootOverride ? *z.rootOverride : 60;
|
std::vector<int> lows, highs;
|
||||||
z.rootOverride = clampNote(base - 1); // first nudge establishes the override
|
lows.reserve(map_.zones.size());
|
||||||
break;
|
highs.reserve(map_.zones.size());
|
||||||
|
for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); }
|
||||||
|
const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(),
|
||||||
|
highs.empty() ? nullptr : highs.data(),
|
||||||
|
static_cast<int>(map_.zones.size()), lx, ly);
|
||||||
|
if (hit.zoneIndex >= 0) {
|
||||||
|
selectedZone_ = hit.zoneIndex;
|
||||||
|
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(hit.zoneIndex)];
|
||||||
|
dragStartX_ = x;
|
||||||
|
dragStartLow_ = z.lowNote;
|
||||||
|
dragStartHigh_ = z.highNote;
|
||||||
|
switch (hit.grab) {
|
||||||
|
case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
|
||||||
|
case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
|
||||||
|
case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break;
|
||||||
|
default: drag_ = DragKind::kNone; break;
|
||||||
}
|
}
|
||||||
case ZoneField::kRootUp: {
|
invalidate();
|
||||||
const int base = z.rootOverride ? *z.rootOverride : 60;
|
return;
|
||||||
z.rootOverride = clampNote(base + 1);
|
}
|
||||||
break;
|
// A bare key-click inside the strip sets the selected zone's root override.
|
||||||
|
if (contains(stripArea, x, y) && selectedZone_ >= 0 &&
|
||||||
|
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||||
|
const int note = keyAtPoint(sl, lx, ly);
|
||||||
|
if (note >= 0) {
|
||||||
|
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
|
||||||
|
commitAndReload();
|
||||||
}
|
}
|
||||||
default: break; // kZoneNone/kDelete handled above
|
|
||||||
}
|
}
|
||||||
// Keep low <= high after a range nudge (clamp the moved edge against its partner).
|
}
|
||||||
if (z.lowNote > z.highNote) {
|
|
||||||
if (hit.field == ZoneField::kLowUp) z.lowNote = z.highNote;
|
void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||||
else if (hit.field == ZoneField::kHighDown) z.highNote = z.lowNote;
|
if (drag_ == DragKind::kNone) return;
|
||||||
|
RECT cr{};
|
||||||
|
GetClientRect(childHwnd_, &cr);
|
||||||
|
const int w = cr.right - cr.left;
|
||||||
|
const int h = cr.bottom - cr.top;
|
||||||
|
const EditorBands bands = computeBands(w, h);
|
||||||
|
const int dx = x - dragStartX_;
|
||||||
|
|
||||||
|
if (drag_ == DragKind::kRootMarker) {
|
||||||
|
// The single-capture root strip lives in the setup band.
|
||||||
|
const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight);
|
||||||
|
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
|
||||||
|
const Rect stripArea = setupStripArea(area);
|
||||||
|
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
|
||||||
|
const int note = resolveDragNote(sl, dragStartRoot_, dx);
|
||||||
|
// The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone),
|
||||||
|
// so setting the single capture's root materializes a full-keyboard zone carrying the
|
||||||
|
// override. This plays identically to the un-zoned single-capture path (one chromatic
|
||||||
|
// zone over the whole keyboard) and round-trips through the v3 component state; the
|
||||||
|
// zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a
|
||||||
|
// repeated drag edits the same zone rather than stacking duplicates.
|
||||||
|
bool found = false;
|
||||||
|
for (PerformanceZone& z : map_.zones) {
|
||||||
|
if (z.sampleId == selectedId_) { z.rootOverride = note; found = true; break; }
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
PerformanceZone z;
|
||||||
|
z.sampleId = selectedId_;
|
||||||
|
z.lowNote = 0;
|
||||||
|
z.highNote = 127;
|
||||||
|
z.rootOverride = note;
|
||||||
|
map_.zones.push_back(z);
|
||||||
|
}
|
||||||
|
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
commitMapAndReload();
|
|
||||||
|
// Zone edits: recompute the grabbed field(s) against the pure resolver, live.
|
||||||
|
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||||
|
const Rect stripArea = zonesStripArea(bands);
|
||||||
|
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
|
||||||
|
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||||
|
if (drag_ == DragKind::kZoneLow) {
|
||||||
|
z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote);
|
||||||
|
} else if (drag_ == DragKind::kZoneHigh) {
|
||||||
|
z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote);
|
||||||
|
} else if (drag_ == DragKind::kZoneBody) {
|
||||||
|
// Move the whole span: apply the SAME delta to both edges so the span is preserved,
|
||||||
|
// clamping so neither edge escapes [0,127] (the span shifts, never shrinks).
|
||||||
|
const int newLow = resolveDragNote(sl, dragStartLow_, dx);
|
||||||
|
const int newHigh = resolveDragNote(sl, dragStartHigh_, dx);
|
||||||
|
const int span = dragStartHigh_ - dragStartLow_;
|
||||||
|
if (newLow < 0) { z.lowNote = 0; z.highNote = span; }
|
||||||
|
else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; }
|
||||||
|
else { z.lowNote = newLow; z.highNote = newHigh; }
|
||||||
|
}
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
|
||||||
|
if (drag_ == DragKind::kNone) return;
|
||||||
|
drag_ = DragKind::kNone;
|
||||||
|
// One coherent edit lands on release: publish the in-flight map + reload off-thread.
|
||||||
|
commitAndReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||||
LPARAM lParam) {
|
LPARAM lParam) {
|
||||||
auto* self = reinterpret_cast<ReaSamplerEditor*>(
|
auto* self =
|
||||||
GetWindowLongPtr(hwnd, GWLP_USERDATA));
|
reinterpret_cast<ReaSamplerEditor*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));
|
||||||
switch (msg) {
|
switch (msg) {
|
||||||
case WM_PAINT: {
|
case WM_PAINT: {
|
||||||
PAINTSTRUCT ps{};
|
PAINTSTRUCT ps{};
|
||||||
@@ -369,10 +773,31 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
case WM_LBUTTONDOWN:
|
case WM_LBUTTONDOWN:
|
||||||
if (self) self->onClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
if (self) {
|
||||||
|
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
|
||||||
|
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
case WM_MOUSEMOVE:
|
||||||
|
if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||||
|
return 0;
|
||||||
|
case WM_LBUTTONUP:
|
||||||
|
if (self) {
|
||||||
|
self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||||
|
ReleaseCapture();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
case WM_CAPTURECHANGED:
|
||||||
|
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — reset the drag
|
||||||
|
// state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
|
||||||
|
// Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler.
|
||||||
|
if (self && self->drag_ != DragKind::kNone) {
|
||||||
|
self->drag_ = DragKind::kNone;
|
||||||
|
self->invalidate();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case WM_ERASEBKGND:
|
case WM_ERASEBKGND:
|
||||||
return 1; // we fully repaint in WM_PAINT; skip the flicker-inducing erase
|
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
|
||||||
default:
|
default:
|
||||||
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-42
@@ -1,90 +1,135 @@
|
|||||||
// reasampler_editor.h — the VST3 IPlugView LICE editor (Phase S1 bridge spike). THIN
|
// reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000
|
||||||
// shell: hosts a LICE-drawn child window inside the host's IPlugView seat and routes
|
// capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the
|
||||||
// host paint/click into the pure editor_geometry hit-test. Windows-only (D5).
|
// host's IPlugView seat and routes host paint/mouse into the pure geometry modules
|
||||||
|
// (capture_browser, keyboard_strip) + the pure mapping (sample_map). Windows-only (D5).
|
||||||
//
|
//
|
||||||
// This is the phase's ONE genuine unknown (per CONTEXT.md §Phase S / S1): wiring LICE
|
// The default face is the CAPTURE BROWSER: a bank-filter tab strip over a grid of
|
||||||
// into an IPlugView. It reuses the bank_panel LICE/SWELL competence — a LICE_SysBitmap
|
// scannable capture cards (peak thumbnail + name + root/key badge). A fresh instance with
|
||||||
// blitted in WM_PAINT, GET_X/Y_LPARAM hit-testing in WM_LBUTTONDOWN — but hangs it off a
|
// no pick shows a "pick a capture" EMPTY STATE and plays silence (the S10 policy reversal
|
||||||
// child HWND created in IPlugView::attached() rather than a docked SWELL dialog.
|
// of the S4 first-sample auto-play). Picking a card loads that one capture and reveals a
|
||||||
|
// guided SINGLE-CAPTURE SETUP surface (a keyboard strip with the capture's root marker +
|
||||||
|
// a level readout). Multi-zone keymap editing is a demoted, opt-in ZONES panel (S10-Z),
|
||||||
|
// reached by a toggle and driven by the same keyboard_strip drag machine.
|
||||||
//
|
//
|
||||||
// Subclasses CPluginView (public.sdk/source/common/pluginview.h) for the IPlugView
|
// All layout/hit-test/drag math lives in the pure modules; this shell only draws + routes
|
||||||
// refcount + attached/removed/getSize/onSize boilerplate; we override the attach/remove
|
// (a LICE_SysBitmap blitted in WM_PAINT, a WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP
|
||||||
// hooks to create/destroy the child window and onSize to resize it.
|
// 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
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "public.sdk/source/common/pluginview.h"
|
#include "public.sdk/source/common/pluginview.h"
|
||||||
|
|
||||||
#include "sample_map.h" // SampleChoice (the list the editor draws)
|
#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
|
||||||
|
#include "peaks.h" // Envelope (the cached peak thumbnail)
|
||||||
|
#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only in the .cpp
|
||||||
|
|
||||||
namespace reasampler::vst {
|
namespace reasampler::vst {
|
||||||
|
|
||||||
class ReaSamplerProcessor;
|
class ReaSamplerProcessor;
|
||||||
|
|
||||||
class ReaSamplerEditor : public Steinberg::CPluginView {
|
class ReaSamplerEditor : public Steinberg::CPluginView {
|
||||||
public:
|
public:
|
||||||
// `processor` owns this editor's lifetime domain and outlives it; the editor reads
|
// `processor` owns this editor's lifetime domain and outlives it; the editor reads the
|
||||||
// the live bank through it (the sample list) and drives selection + reload when the
|
// live bank through it and drives selection/zone edits + reload on user input. May be
|
||||||
// user clicks a row. May be null (defensive — a real host always supplies one).
|
// null (defensive — a real host always supplies one).
|
||||||
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||||
~ReaSamplerEditor() override;
|
~ReaSamplerEditor() override;
|
||||||
|
|
||||||
// Accept only the Windows HWND platform type (D5: Windows-only).
|
|
||||||
Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
|
Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
|
||||||
Steinberg::FIDString type) override;
|
Steinberg::FIDString type) override;
|
||||||
|
|
||||||
// The view is user-resizable in the spike so we exercise the onSize path.
|
|
||||||
Steinberg::tresult PLUGIN_API canResize() override;
|
Steinberg::tresult PLUGIN_API canResize() override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
// CPluginView hooks: systemWindow is set by the time attachedToParent() fires.
|
|
||||||
void attachedToParent() override;
|
void attachedToParent() override;
|
||||||
void removedFromParent() override;
|
void removedFromParent() override;
|
||||||
|
|
||||||
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
|
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Which face the editor shows. The browser is the default; the Zones panel is the
|
||||||
|
// demoted opt-in view reached by the toggle. Both draw over the same snapshotted bank.
|
||||||
|
enum class View { kBrowser, kZones };
|
||||||
|
|
||||||
|
// What a mouse drag is currently editing (the drag-state machine). kNone = no drag in
|
||||||
|
// flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the
|
||||||
|
// single-capture root drag on the setup strip.
|
||||||
|
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody };
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
// Draw the current surface into the child window's DC via a LICE bitmap.
|
|
||||||
void paint(HDC hdc);
|
void paint(HDC hdc);
|
||||||
// Route a client-space click: bank-sample pick (left list), zone edit (right panel),
|
void paintBrowser(LICE_IBitmap* bmp, int w, int h);
|
||||||
// or the "Add Zone" button.
|
void paintSetup(LICE_IBitmap* bmp, const Rect& area);
|
||||||
void onClick(int x, int y);
|
void paintZones(LICE_IBitmap* bmp, int w, int h);
|
||||||
|
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
||||||
|
|
||||||
|
void onMouseDown(int x, int y);
|
||||||
|
void onMouseMove(int x, int y);
|
||||||
|
void onMouseUp(int x, int y);
|
||||||
|
|
||||||
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||||
|
void invalidate();
|
||||||
|
|
||||||
HWND childHwnd_ = nullptr;
|
HWND childHwnd_ = nullptr;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Re-read the bank's sample list from the live bridge into `samples_`, and snapshot the
|
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
|
||||||
// instrument's performance map. Main/UI thread only (reads ext-state); called on attach
|
// selection + performance map. Main/UI thread only. Called on attach and after any edit.
|
||||||
// and after any edit that reloads the instrument.
|
void refreshFromBank();
|
||||||
void refreshSampleList();
|
|
||||||
|
|
||||||
// Push the edited performance map to the processor and rebuild the instrument OFF the
|
// Publish the edited zones/selection to the processor, then rebuild the instrument OFF
|
||||||
// audio thread. UI thread only. Centralizes the "commit an edit" path so every zone
|
// the audio thread. UI thread only. One place so every edit commits identically.
|
||||||
// mutation reloads identically.
|
void commitAndReload();
|
||||||
void commitMapAndReload();
|
|
||||||
|
// Recompute the capture cards visible under the current bank filter (samples_ narrowed by
|
||||||
|
// activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change.
|
||||||
|
void rebuildVisible();
|
||||||
|
|
||||||
|
// The peak thumbnail for a bank sample id at `binCount` bins, computed once from the
|
||||||
|
// decoded WAV (mirror of bank_panel::thumbnailFor) and cached by (id, binCount). Returns
|
||||||
|
// an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O).
|
||||||
|
const Envelope& thumbnailFor(const std::string& sampleId, int binCount);
|
||||||
|
|
||||||
ReaSamplerProcessor* processor_ = nullptr;
|
ReaSamplerProcessor* processor_ = nullptr;
|
||||||
// The bank's samples, snapshotted for the current paint. Refreshed off the audio
|
|
||||||
// thread; the paint just draws it.
|
// --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
|
||||||
std::vector<SampleChoice> samples_;
|
std::vector<SampleChoice> samples_; // every bank sample, bank order
|
||||||
// The currently-selected sample id (Tier-0 fallback + the "sample to add" for a new
|
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
|
||||||
// zone), mirrored for the paint's highlight.
|
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
|
||||||
std::string selectedId_;
|
std::string selectedId_; // the single-capture pick ("" = empty state)
|
||||||
// The instrument's performance map, snapshotted for edit + paint. Edits mutate this
|
PerformanceMap map_; // the opt-in zones (empty = no zones)
|
||||||
// copy then commit it to the processor via commitMapAndReload.
|
|
||||||
PerformanceMap map_;
|
// --- Transient UI state (not persisted; component state carries selection + zones) ---
|
||||||
// The zone row currently highlighted (for the paint); -1 = none.
|
View view_ = View::kBrowser; // default face is the browser
|
||||||
int selectedZone_ = -1;
|
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
||||||
|
int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none
|
||||||
|
|
||||||
|
// --- Drag-state machine ------------------------------------------------------
|
||||||
|
DragKind drag_ = DragKind::kNone;
|
||||||
|
int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver
|
||||||
|
int dragStartLow_ = 0; // the grabbed field's note at grab time
|
||||||
|
int dragStartHigh_ = 0;
|
||||||
|
int dragStartRoot_ = 60;
|
||||||
|
|
||||||
|
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
|
||||||
|
// Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so
|
||||||
|
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
|
||||||
|
std::unordered_map<std::string, Envelope> thumbCache_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace reasampler::vst
|
} // namespace reasampler::vst
|
||||||
|
|||||||
@@ -151,8 +151,9 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
|||||||
const EmbedLayout layout = layoutEmbed(w, h);
|
const EmbedLayout layout = layoutEmbed(w, h);
|
||||||
|
|
||||||
if (map_.zones.empty()) {
|
if (map_.zones.empty()) {
|
||||||
// No keymap authored yet: show a single faint band spanning the keymap area so the
|
// No opt-in zones authored: show a single faint band spanning the keymap area so the
|
||||||
// strip still reads as "present but empty" (Tier-0 fallback plays chromatically).
|
// 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(),
|
LICE_FillRect(bmp, layout.keymap.left, layout.keymap.top, layout.keymap.width(),
|
||||||
layout.keymap.height(), kColEmpty, 0.5f, 0);
|
layout.keymap.height(), kColEmpty, 0.5f, 0);
|
||||||
HDC dc = bmp->getDC();
|
HDC dc = bmp->getDC();
|
||||||
@@ -161,7 +162,7 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
|||||||
RECT gr{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right,
|
RECT gr{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right,
|
||||||
layout.keymap.bottom};
|
layout.keymap.bottom};
|
||||||
const std::string label =
|
const std::string label =
|
||||||
samples_.empty() ? "ReaSampler (bank empty)" : "ReaSampler (no zones)";
|
samples_.empty() ? "ReaSampler 9000 (bank empty)" : "ReaSampler 9000 (no zones)";
|
||||||
DrawTextA(dc, label.c_str(), -1, &gr,
|
DrawTextA(dc, label.c_str(), -1, &gr,
|
||||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
|
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
|||||||
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
|
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
|
||||||
// I/O — off-thread only), and downmix to the core's mono contract. Returns nullopt when
|
// I/O — off-thread only), and downmix to the core's mono contract. Returns nullopt when
|
||||||
// the path fails to resolve, the file is unreadable, the WAV is malformed, or the decode
|
// the path fails to resolve, the file is unreadable, the WAV is malformed, or the decode
|
||||||
// yields no frames — the caller drops the zone (Tier 1) or plays silence (Tier 0). Shared
|
// yields no frames — the caller drops the zone (zoned map) or plays silence (single capture).
|
||||||
// by the zoned build and the Tier-0 fallback so both decode identically.
|
// Shared by the zoned build and the single-capture path so both decode identically.
|
||||||
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||||
const std::string& relativePath) {
|
const std::string& relativePath) {
|
||||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||||
@@ -165,14 +165,16 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
|
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
|
||||||
bytes.insert(bytes.end(), chunk, chunk + got);
|
bytes.insert(bytes.end(), chunk, chunk + got);
|
||||||
}
|
}
|
||||||
// Component state IS the performance map (Tier 1, D-B). deserializePerformance lifts a
|
// Component state (v3, S10) is {single-capture selection id, opt-in zones}. The
|
||||||
// v1 (S4 single-selection) blob to a one-zone map, so already-saved Tier-0 instances
|
// selection and the zones are DISTINCT — the default face is one picked capture, zones
|
||||||
// restore cleanly. When the restored map is empty, the instrument falls back to the
|
// are a demoted overlay — so both are restored explicitly (no more inferring a selection
|
||||||
// Tier-0 first-sample in reloadFromBank; if any single-zone restored map is present we
|
// from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only
|
||||||
// seed the fallback selection from it so the editor reflects the restored pick.
|
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
|
||||||
const PerformanceMap map = deserializePerformance(bytes);
|
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
|
||||||
setPerformanceMap(map);
|
// silent empty state (no first-sample fallback in reloadFromBank).
|
||||||
if (map.zones.size() == 1) setSelectedSampleId(map.zones.front().sampleId);
|
const ComponentState cs = deserializeComponentState(bytes);
|
||||||
|
setSelectedSampleId(cs.selectionId);
|
||||||
|
setPerformanceMap(cs.map);
|
||||||
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
||||||
reloadFromBank();
|
reloadFromBank();
|
||||||
return kResultOk;
|
return kResultOk;
|
||||||
@@ -180,10 +182,15 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||||
if (!state) return kResultFalse;
|
if (!state) return kResultFalse;
|
||||||
// Persist the performance map (the instrument's own Tier-1 state, D-B — NEVER written
|
// Persist the full instance state (v3, S10): the single-capture selection id AND the
|
||||||
// to the "reasampler" bank ext-state). An empty map serializes to just the version +
|
// opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler"
|
||||||
// zero-count header (restores as empty -> Tier-0 fallback).
|
// bank ext-state. An instance with no pick and no zones serializes to {"", no zones}
|
||||||
const std::vector<std::uint8_t> bytes = serializePerformance(performanceMap());
|
// and restores as the S10 empty state (silence + "pick a capture"), never auto-playing
|
||||||
|
// sample #1.
|
||||||
|
ComponentState state_out;
|
||||||
|
state_out.selectionId = selectedSampleId();
|
||||||
|
state_out.map = performanceMap();
|
||||||
|
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
|
||||||
if (!bytes.empty()) {
|
if (!bytes.empty()) {
|
||||||
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||||
static_cast<int32>(bytes.size()), nullptr);
|
static_cast<int32>(bytes.size()), nullptr);
|
||||||
@@ -260,9 +267,13 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Tier-0 fallback: an empty (or fully-unresolvable) performance map plays the
|
// 3. Single-capture fast path (S10): an empty performance map plays the ONE
|
||||||
// selected fallback sample chromatically across the whole keyboard — preserving
|
// deliberately-selected capture chromatically across the whole keyboard. This is
|
||||||
// the S4 "the bank plays" behavior for an un-authored instrument.
|
// 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) {
|
if (!haveKeymap) {
|
||||||
std::optional<SelectedSample> sel =
|
std::optional<SelectedSample> sel =
|
||||||
selectSample(*banksJson, selectedSampleId());
|
selectSample(*banksJson, selectedSampleId());
|
||||||
@@ -273,9 +284,7 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
|||||||
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
|
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
|
||||||
sel->rootNote, sel->loop);
|
sel->rootNote, sel->loop);
|
||||||
haveKeymap = true;
|
haveKeymap = true;
|
||||||
// Record which id actually resolved so a first-sample fallback
|
resolvedId = selectedSampleId(); // the concrete pick that resolved
|
||||||
// (empty stored id) becomes the concrete selection.
|
|
||||||
resolvedId = selectedSampleId();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,10 +115,10 @@ public:
|
|||||||
// The bridge, for the editor's live-state readout + sample list. Owned here; the
|
// The bridge, for the editor's live-state readout + sample list. Owned here; the
|
||||||
// editor borrows it (outlives the editor).
|
// editor borrows it (outlives the editor).
|
||||||
ReaperBridge& bridge() { return bridge_; }
|
ReaperBridge& bridge() { return bridge_; }
|
||||||
// The current selection id (main/UI thread reads for the editor). Guarded by
|
// The current single-capture selection id (main/UI thread reads for the editor). Guarded
|
||||||
// selectionMutex_ — never touched on the audio thread. In Tier 1 the selection is the
|
// by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked
|
||||||
// Tier-0 FALLBACK sample (played chromatically when the performance map is empty); the
|
// capture the default face plays chromatically when the performance map is empty; an EMPTY
|
||||||
// zoned map, when non-empty, supersedes it.
|
// id resolves to SILENCE (no first-sample fallback). A non-empty zoned map supersedes it.
|
||||||
std::string selectedSampleId();
|
std::string selectedSampleId();
|
||||||
void setSelectedSampleId(const std::string& id);
|
void setSelectedSampleId(const std::string& id);
|
||||||
|
|
||||||
@@ -166,8 +166,9 @@ private:
|
|||||||
std::vector<GraveyardEntry> graveyard_; // drained on reclaim + setActive(false) + terminate
|
std::vector<GraveyardEntry> graveyard_; // drained on reclaim + setActive(false) + terminate
|
||||||
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||||
|
|
||||||
// The selected sample id (Tier-0 fallback sample). Off-thread only; a small mutex
|
// The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence).
|
||||||
// guards the string against a getState/editor race. NOT read on the audio thread.
|
// Off-thread only; a small mutex guards the string against a getState/editor race. NOT
|
||||||
|
// read on the audio thread.
|
||||||
std::mutex selectionMutex_;
|
std::mutex selectionMutex_;
|
||||||
std::string selectedSampleId_;
|
std::string selectedSampleId_;
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,15 @@
|
|||||||
|
|
||||||
namespace reasampler::vst {
|
namespace reasampler::vst {
|
||||||
|
|
||||||
// Human-facing identity. "ReaSampler" is the tool; the instrument surfaces as
|
// Human-facing identity (S-NAME-1, SETTLED 2026-07-26). "ReaSampler" is the extension
|
||||||
// "ReaSampler Instrument" in REAPER's FX browser to distinguish it from the extension.
|
// (capture + organization); the MIDI-playback instrument's product name is
|
||||||
inline constexpr const char* kPluginName = "ReaSampler Instrument";
|
// "ReaSampler 9000" — the string that surfaces in REAPER's FX browser, the factory
|
||||||
|
// display name, the editor title band, and the embed-strip label. The on-disk module is
|
||||||
|
// renamed to match (CMake OUTPUT_NAME reasampler_9000.vst3). The VST3 class UID below is
|
||||||
|
// the compat anchor and is NOT changed by the rename — a saved REAPER project rebinds a
|
||||||
|
// saved instance by class UID, so the display/filename rename keeps existing instances
|
||||||
|
// resolving (compat is a DAW-verify; see PLAN.md §S-NAME-1).
|
||||||
|
inline constexpr const char* kPluginName = "ReaSampler 9000";
|
||||||
inline constexpr const char* kVendorName = "ReaSampler";
|
inline constexpr const char* kVendorName = "ReaSampler";
|
||||||
inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/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";
|
inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com";
|
||||||
|
|||||||
+99
-31
@@ -39,28 +39,23 @@ SelectedSample distill(const Sample& s) {
|
|||||||
|
|
||||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||||
const std::string& sampleId) {
|
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;
|
if (banksJson.empty()) return std::nullopt;
|
||||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||||
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
|
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
|
||||||
|
|
||||||
// Search every bank (pool first, then named — banks() is ordinal order) for the
|
// 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.
|
// stored id. A sample lives in exactly one bank, so first hit wins.
|
||||||
if (!sampleId.empty()) {
|
|
||||||
for (const Bank& b : book->banks()) {
|
|
||||||
if (const Sample* s = b.index.query(sampleId)) {
|
|
||||||
return distill(*s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No stored id, or the id no longer resolves (the sample was deleted/moved out):
|
|
||||||
// fall back to the FIRST sample in ordinal order so a fresh instance plays.
|
|
||||||
for (const Bank& b : book->banks()) {
|
for (const Bank& b : book->banks()) {
|
||||||
if (!b.index.all().empty()) {
|
if (const Sample* s = b.index.query(sampleId)) {
|
||||||
return distill(b.index.all().front());
|
return distill(*s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return std::nullopt; // bank has zero samples anywhere
|
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
|
||||||
|
// the editor reflects the missing pick with its empty state rather than masking it.
|
||||||
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
|
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
|
||||||
@@ -70,12 +65,23 @@ std::vector<SampleChoice> listSamples(const std::string& banksJson) {
|
|||||||
if (!book) return out;
|
if (!book) return out;
|
||||||
for (const Bank& b : book->banks()) {
|
for (const Bank& b : book->banks()) {
|
||||||
for (const Sample& s : b.index.all()) {
|
for (const Sample& s : b.index.all()) {
|
||||||
out.push_back(SampleChoice{s.id, s.displayName});
|
out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
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,
|
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||||
int channelCount) {
|
int channelCount) {
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
@@ -211,11 +217,9 @@ struct ByteReader {
|
|||||||
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
|
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace
|
// Append the zones payload (zone count + per-zone records) — the shared body of the v2
|
||||||
|
// performance blob and the v3 component blob, so both write zones identically.
|
||||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||||
std::vector<std::uint8_t> out;
|
|
||||||
putU32le(out, kPerformanceStateVersion);
|
|
||||||
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
|
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||||
for (const PerformanceZone& z : map.zones) {
|
for (const PerformanceZone& z : map.zones) {
|
||||||
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||||
@@ -228,6 +232,33 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
|||||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a zones payload (zone count + per-zone records) from `r` into `map`. Shared by the
|
||||||
|
// v2 performance parse and the v3 component parse. A truncated mid-zone read keeps the zones
|
||||||
|
// that parsed cleanly and drops the rest; the reader position is left after the last byte
|
||||||
|
// read successfully.
|
||||||
|
void readZonesPayload(ByteReader& r, PerformanceMap& map) {
|
||||||
|
const std::uint32_t count = r.u32();
|
||||||
|
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||||
|
PerformanceZone z;
|
||||||
|
const std::uint32_t idLen = r.u32();
|
||||||
|
z.sampleId = r.str(idLen);
|
||||||
|
z.lowNote = r.i32();
|
||||||
|
z.highNote = r.i32();
|
||||||
|
const std::uint8_t hasOverride = r.u8();
|
||||||
|
if (hasOverride) z.rootOverride = r.i32();
|
||||||
|
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||||
|
map.zones.push_back(std::move(z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||||
|
std::vector<std::uint8_t> out;
|
||||||
|
putU32le(out, kPerformanceStateVersion);
|
||||||
|
putZonesPayload(out, map);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,21 +283,58 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes) {
|
|||||||
}
|
}
|
||||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
||||||
|
|
||||||
const std::uint32_t count = r.u32();
|
readZonesPayload(r, map);
|
||||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
|
||||||
PerformanceZone z;
|
|
||||||
const std::uint32_t idLen = r.u32();
|
|
||||||
z.sampleId = r.str(idLen);
|
|
||||||
z.lowNote = r.i32();
|
|
||||||
z.highNote = r.i32();
|
|
||||||
const std::uint8_t hasOverride = r.u8();
|
|
||||||
if (hasOverride) z.rootOverride = r.i32();
|
|
||||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
|
||||||
map.zones.push_back(std::move(z));
|
|
||||||
}
|
|
||||||
return map;
|
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);
|
||||||
|
// 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) {
|
||||||
|
ComponentState out;
|
||||||
|
ByteReader r(bytes);
|
||||||
|
const std::uint32_t version = r.u32();
|
||||||
|
if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state)
|
||||||
|
|
||||||
|
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
|
||||||
|
// * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard
|
||||||
|
// zone} so the old pick survives as BOTH the selection and a one-zone map.
|
||||||
|
// * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate
|
||||||
|
// single-capture selection.
|
||||||
|
if (version == kSelectionStateVersion) {
|
||||||
|
out.selectionId = deserializeSelection(bytes);
|
||||||
|
if (!out.selectionId.empty()) {
|
||||||
|
PerformanceZone z;
|
||||||
|
z.sampleId = out.selectionId;
|
||||||
|
z.lowNote = 0;
|
||||||
|
z.highNote = 127;
|
||||||
|
out.map.zones.push_back(std::move(z));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (version == kPerformanceStateVersion) {
|
||||||
|
readZonesPayload(r, out.map); // v2 body starts right after the version tag
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (version != kComponentStateVersion) return out; // unknown -> empty
|
||||||
|
|
||||||
|
const std::uint32_t idLen = r.u32();
|
||||||
|
out.selectionId = r.str(idLen);
|
||||||
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
|
readZonesPayload(r, out.map);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
|
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
|
||||||
std::vector<std::uint8_t> out;
|
std::vector<std::uint8_t> out;
|
||||||
out.resize(4 + sampleId.size());
|
out.resize(4 + sampleId.size());
|
||||||
|
|||||||
+75
-14
@@ -44,28 +44,50 @@ struct SelectedSample {
|
|||||||
//
|
//
|
||||||
// Precedence, all pure:
|
// Precedence, all pure:
|
||||||
// * empty / malformed banksJson -> nullopt (nothing to play)
|
// * 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 names a sample in ANY bank -> that sample (searched pool + named)
|
||||||
// * sampleId empty or not found, bank has -> the FIRST sample in ordinal order
|
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved;
|
||||||
// >= 1 sample (a sensible default so a fresh
|
// the editor returns to the empty state)
|
||||||
// instance plays SOMETHING; the UI can
|
|
||||||
// then pick a specific one)
|
|
||||||
// * bank has zero samples -> nullopt
|
|
||||||
//
|
//
|
||||||
// The "first sample" fallback is deliberate: Tier 0 is "the bank plays", and a brand-
|
// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh
|
||||||
// new instance with no stored selection should map the bank's first sample rather than
|
// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first
|
||||||
// stay silent until the user opens the editor.
|
// 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,
|
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||||
const std::string& sampleId);
|
const std::string& sampleId);
|
||||||
|
|
||||||
// All (id, displayName) pairs across every bank in ordinal order (pool first), for the
|
// One entry in the capture browser's card list: the stable id + display name plus the S2
|
||||||
// selection UI to list. Empty for an empty / malformed blob. Pure projection over the
|
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
|
||||||
// shared parse — the UI never parses JSON itself.
|
// 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 {
|
struct SampleChoice {
|
||||||
std::string id;
|
std::string id;
|
||||||
std::string displayName;
|
std::string displayName;
|
||||||
|
std::optional<int> rootNote;
|
||||||
|
std::optional<std::string> key;
|
||||||
|
std::string bankId;
|
||||||
};
|
};
|
||||||
std::vector<SampleChoice> listSamples(const std::string& banksJson);
|
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:
|
// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields:
|
||||||
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
|
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
|
||||||
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
|
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
|
||||||
@@ -175,7 +197,12 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
|||||||
// BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is
|
// BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance
|
// 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
|
// saved under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob
|
||||||
// deserializes to an EMPTY map (the instrument falls back to Tier-0 first-sample).
|
// deserializes to an EMPTY map.
|
||||||
|
//
|
||||||
|
// These two functions serialize the ZONES only. Since S10 the instrument's full component
|
||||||
|
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
|
||||||
|
// below, the v3 format the processor actually reads/writes. serializePerformance/
|
||||||
|
// deserializePerformance are retained for the v3 zones payload + the v1/v2 back-compat lift.
|
||||||
|
|
||||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||||
|
|
||||||
@@ -186,6 +213,38 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
|||||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes);
|
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
|
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
|
||||||
|
//
|
||||||
|
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||||
|
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||||
|
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||||
|
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
|
||||||
|
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||||
|
// state), never auto-playing sample #1.
|
||||||
|
//
|
||||||
|
// Format (v3): 4-byte LE version tag (== 3), then a 4-byte LE selection-id length +
|
||||||
|
// id bytes, then the v2 zones payload (4-byte LE zone count + per-zone records, identical
|
||||||
|
// to serializePerformance's body). BACK-COMPAT on read:
|
||||||
|
// * v3 blob -> {selectionId, zones} parsed directly.
|
||||||
|
// * v2 blob -> {"", zones}: an S5 instance had zones but no separate selection.
|
||||||
|
// * v1 blob -> {id, one full-keyboard zone}: the S4 single-selection lift, so the
|
||||||
|
// old pick survives as both the selection AND a one-zone map.
|
||||||
|
// * empty/unknown -> {"", no zones}: EMPTY (the S10 silent empty state).
|
||||||
|
struct ComponentState {
|
||||||
|
std::string selectionId; // the single-capture pick; "" = no pick (empty state)
|
||||||
|
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||||
|
};
|
||||||
|
|
||||||
|
inline constexpr std::uint32_t kComponentStateVersion = 3;
|
||||||
|
|
||||||
|
// The full instance state serialized to bytes for IBStream (getState).
|
||||||
|
std::vector<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.
|
||||||
|
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||||
//
|
//
|
||||||
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
||||||
@@ -196,8 +255,10 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes);
|
|||||||
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
|
// 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
|
// 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
|
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
|
||||||
// by returning "" (no selection — the instrument falls back to the bank's first sample),
|
// by returning "" (no selection — under the S10 policy reversal an empty selection is
|
||||||
// never throwing across the host boundary.
|
// 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;
|
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+147
-18
@@ -9,13 +9,15 @@
|
|||||||
// and the selection / downmix / keymap / state values are checked against independently
|
// and the selection / downmix / keymap / state values are checked against independently
|
||||||
// computed expectations.
|
// computed expectations.
|
||||||
//
|
//
|
||||||
// Covers: selectSample by-id hit (across pool + named banks), first-sample fallback for
|
// Covers: selectSample by-id hit (across pool + named banks), the S10 policy reversal
|
||||||
// an empty / unknown id, empty & malformed blob -> nullopt, zero-samples -> nullopt,
|
// (empty / stale id -> SILENCE nullopt, not the first sample), empty & malformed blob ->
|
||||||
// rootNote/loop intrinsic threading incl. the middle-C default; listSamples ordinal
|
// nullopt, zero-samples -> nullopt, rootNote/loop intrinsic threading incl. the middle-C
|
||||||
// order + empty/malformed; downmixToMono mono passthrough / stereo average / 3-ch
|
// default; listSamples ordinal order + the card metadata (rootNote/key/bankId) + empty/
|
||||||
// average / zero-stride / empty; buildTier0Keymap single full-keyboard zone with the
|
// malformed; listBanks ordinal order (pool first) + empty/malformed; downmixToMono mono
|
||||||
// root + loop + rate threaded and rate defaulting; selection state round-trip + empty id
|
// passthrough / stereo average / 3-ch average / zero-stride / empty; buildTier0Keymap single
|
||||||
// + wrong-version / truncated -> "".
|
// full-keyboard zone with the root + loop + rate threaded and rate defaulting; selection
|
||||||
|
// state round-trip + empty id + wrong-version / truncated -> ""; component state (v3)
|
||||||
|
// round-trip + v1/v2 back-compat lift + empty/unknown -> empty.
|
||||||
// wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave-
|
// wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave-
|
||||||
// stride contract across the seam (that the byte stride wav_trim reports matches the
|
// stride contract across the seam (that the byte stride wav_trim reports matches the
|
||||||
// channel-count stride downmixToMono divides by).
|
// channel-count stride downmixToMono divides by).
|
||||||
@@ -78,24 +80,25 @@ static void testSelectByIdHit() {
|
|||||||
CHECK(sel && sel->rootNote == 38);
|
CHECK(sel && sel->rootNote == 38);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testSelectFirstSampleFallbackOnEmptyId() {
|
static void testSelectEmptyIdIsSilence() {
|
||||||
const std::string json = bookJson(
|
const std::string json = bookJson(
|
||||||
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
|
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
|
||||||
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
|
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
|
||||||
// No stored selection -> the FIRST sample in ordinal order (pool first).
|
// POLICY REVERSAL (S10): no stored selection resolves to SILENCE (nullopt), NOT the
|
||||||
|
// bank's first sample. A fresh instance plays nothing and shows the "pick a capture"
|
||||||
|
// empty state — the deliberate reversal of the S4 first-sample auto-play.
|
||||||
auto sel = selectSample(json, "");
|
auto sel = selectSample(json, "");
|
||||||
CHECK(sel.has_value());
|
CHECK(!sel.has_value());
|
||||||
CHECK(sel && sel->relativePath == "reasampler_bank/a.wav");
|
|
||||||
CHECK(sel && sel->rootNote == 36);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testSelectFirstSampleFallbackOnUnknownId() {
|
static void testSelectUnknownIdIsSilence() {
|
||||||
const std::string json = bookJson(
|
const std::string json = bookJson(
|
||||||
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {});
|
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {});
|
||||||
// A stored id that no longer resolves falls back to the first sample, not silence.
|
// A stale stored id (deleted/moved-out sample) resolves to SILENCE, not a substituted
|
||||||
|
// first sample — the editor reflects the missing pick with its empty state rather than
|
||||||
|
// masking it with a mystery sample.
|
||||||
auto sel = selectSample(json, "deleted-id");
|
auto sel = selectSample(json, "deleted-id");
|
||||||
CHECK(sel.has_value());
|
CHECK(!sel.has_value());
|
||||||
CHECK(sel && sel->relativePath == "reasampler_bank/a.wav");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testSelectRootNoteDefault() {
|
static void testSelectRootNoteDefault() {
|
||||||
@@ -155,12 +158,53 @@ static void testListSamplesOrdinalOrder() {
|
|||||||
CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare");
|
CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void testListSamplesCarriesCardMetadata() {
|
||||||
|
// The browser card needs rootNote/key badge + the bank id (for the filter). A pool sample
|
||||||
|
// reports the pool bank id; a named-bank sample reports "drums-id"; an un-rooted sample
|
||||||
|
// reports no rootNote (the badge shows "root —", never a guessed value).
|
||||||
|
Sample rooted = makeSample("a", "Kick", "reasampler_bank/a.wav", 36);
|
||||||
|
rooted.key = "Cm";
|
||||||
|
Sample unrooted = makeSample("u", "Loop", "reasampler_bank/u.wav", std::nullopt);
|
||||||
|
const std::string json = bookJson({rooted, unrooted},
|
||||||
|
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
|
||||||
|
const std::vector<SampleChoice> list = listSamples(json);
|
||||||
|
CHECK(list.size() == 3);
|
||||||
|
// Pool sample "a": rooted + keyed, pool bank id.
|
||||||
|
CHECK(list[0].id == "a" && list[0].rootNote.has_value() && *list[0].rootNote == 36);
|
||||||
|
CHECK(list[0].key.has_value() && *list[0].key == "Cm");
|
||||||
|
CHECK(!list[0].bankId.empty()); // the pool has an id; the filter matches on it
|
||||||
|
// Pool sample "u": no root intrinsic -> no rootNote (badge shows "root —").
|
||||||
|
CHECK(list[1].id == "u" && !list[1].rootNote.has_value());
|
||||||
|
// Named-bank sample "b": its bank id distinguishes it from the pool for the filter.
|
||||||
|
CHECK(list[2].id == "b" && list[2].bankId == "drums-id");
|
||||||
|
CHECK(list[2].bankId != list[0].bankId); // pool vs. named bank differ (filterable apart)
|
||||||
|
}
|
||||||
|
|
||||||
static void testListSamplesEmptyAndMalformed() {
|
static void testListSamplesEmptyAndMalformed() {
|
||||||
CHECK(listSamples("").empty());
|
CHECK(listSamples("").empty());
|
||||||
CHECK(listSamples("{garbage").empty());
|
CHECK(listSamples("{garbage").empty());
|
||||||
CHECK(listSamples(bookJson({}, {})).empty());
|
CHECK(listSamples(bookJson({}, {})).empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void testListBanksOrdinalOrder() {
|
||||||
|
const std::string json = bookJson(
|
||||||
|
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
|
||||||
|
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
|
||||||
|
const std::vector<BankChoice> banks = listBanks(json);
|
||||||
|
// Pool first (bank-zero), then the named bank "Drums". Both ids are present so the filter
|
||||||
|
// tab strip can key on them.
|
||||||
|
CHECK(banks.size() == 2);
|
||||||
|
CHECK(banks.size() == 2 && banks[1].id == "drums-id" && banks[1].displayName == "Drums");
|
||||||
|
CHECK(banks.size() == 2 && !banks[0].id.empty()); // the pool bank has an id too
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testListBanksEmptyAndMalformed() {
|
||||||
|
CHECK(listBanks("").empty());
|
||||||
|
CHECK(listBanks("{garbage").empty());
|
||||||
|
// A valid book with no samples still has the pool bank -> one entry.
|
||||||
|
CHECK(listBanks(bookJson({}, {})).size() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
// --- downmixToMono ------------------------------------------------------------
|
// --- downmixToMono ------------------------------------------------------------
|
||||||
|
|
||||||
static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; }
|
static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; }
|
||||||
@@ -558,10 +602,86 @@ static void testPerformanceStateNegativeNotesRoundTrip() {
|
|||||||
*back.zones[0].rootOverride == 0);
|
*back.zones[0].rootOverride == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Combined component state (v3, S10) --------------------------------------
|
||||||
|
|
||||||
|
static void testComponentStateRoundTrip() {
|
||||||
|
// The v3 state carries the single-capture selection AND the opt-in zones, distinctly.
|
||||||
|
ComponentState s;
|
||||||
|
s.selectionId = "picked-capture";
|
||||||
|
s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt));
|
||||||
|
s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48));
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
||||||
|
CHECK(back.selectionId == "picked-capture");
|
||||||
|
CHECK(back.map.zones.size() == 2);
|
||||||
|
CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" &&
|
||||||
|
back.map.zones[0].highNote == 59 && !back.map.zones[0].rootOverride.has_value());
|
||||||
|
CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" &&
|
||||||
|
back.map.zones[1].rootOverride.has_value() && *back.map.zones[1].rootOverride == 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateSelectionOnlyNoZones() {
|
||||||
|
// A single-capture instance: a pick, no zones. Must restore the pick with an empty map
|
||||||
|
// (NOT synthesize a zone) — the default face is one capture, zones are opt-in.
|
||||||
|
ComponentState s;
|
||||||
|
s.selectionId = "just-a-pick";
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
||||||
|
CHECK(back.selectionId == "just-a-pick");
|
||||||
|
CHECK(back.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateEmptyIsEmpty() {
|
||||||
|
// No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample.
|
||||||
|
const ComponentState s; // selectionId "", empty map
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
||||||
|
CHECK(back.selectionId.empty());
|
||||||
|
CHECK(back.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateV1BackCompat() {
|
||||||
|
// A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick
|
||||||
|
// survives as BOTH the selection and a one-zone map.
|
||||||
|
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-id");
|
||||||
|
const ComponentState back = deserializeComponentState(v1);
|
||||||
|
CHECK(back.selectionId == "legacy-id");
|
||||||
|
CHECK(back.map.zones.size() == 1);
|
||||||
|
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" &&
|
||||||
|
back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127);
|
||||||
|
// A v1 blob with an EMPTY id -> empty state (no selection, no zone).
|
||||||
|
const ComponentState empty = deserializeComponentState(serializeSelection(""));
|
||||||
|
CHECK(empty.selectionId.empty() && empty.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateV2BackCompat() {
|
||||||
|
// A v2 S5 blob (zones-only) lifts to {"", zones}: that instance had zones but no separate
|
||||||
|
// single-capture selection.
|
||||||
|
PerformanceMap m;
|
||||||
|
m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt));
|
||||||
|
const std::vector<std::uint8_t> v2 = serializePerformance(m);
|
||||||
|
const ComponentState back = deserializeComponentState(v2);
|
||||||
|
CHECK(back.selectionId.empty());
|
||||||
|
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" &&
|
||||||
|
back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateGarbage() {
|
||||||
|
// Empty / unknown version -> empty (never throws across the host).
|
||||||
|
CHECK(deserializeComponentState({}).selectionId.empty());
|
||||||
|
CHECK(deserializeComponentState({}).map.zones.empty());
|
||||||
|
const std::vector<std::uint8_t> unknown{0xAA, 0xBB, 0xCC, 0xDD};
|
||||||
|
CHECK(deserializeComponentState(unknown).map.zones.empty());
|
||||||
|
CHECK(deserializeComponentState(unknown).selectionId.empty());
|
||||||
|
// A v3 header claiming a longer id than the blob holds -> empty (bounded read).
|
||||||
|
std::vector<std::uint8_t> t;
|
||||||
|
t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0); // version 3
|
||||||
|
t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0); // id length 200 (absent)
|
||||||
|
CHECK(deserializeComponentState(t).selectionId.empty());
|
||||||
|
CHECK(deserializeComponentState(t).map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testSelectByIdHit();
|
testSelectByIdHit();
|
||||||
testSelectFirstSampleFallbackOnEmptyId();
|
testSelectEmptyIdIsSilence();
|
||||||
testSelectFirstSampleFallbackOnUnknownId();
|
testSelectUnknownIdIsSilence();
|
||||||
testSelectRootNoteDefault();
|
testSelectRootNoteDefault();
|
||||||
testSelectLoopThreaded();
|
testSelectLoopThreaded();
|
||||||
testSelectNoLoopIsAbsent();
|
testSelectNoLoopIsAbsent();
|
||||||
@@ -569,7 +689,10 @@ int main() {
|
|||||||
testSelectMalformedBlob();
|
testSelectMalformedBlob();
|
||||||
testSelectZeroSamples();
|
testSelectZeroSamples();
|
||||||
testListSamplesOrdinalOrder();
|
testListSamplesOrdinalOrder();
|
||||||
|
testListSamplesCarriesCardMetadata();
|
||||||
testListSamplesEmptyAndMalformed();
|
testListSamplesEmptyAndMalformed();
|
||||||
|
testListBanksOrdinalOrder();
|
||||||
|
testListBanksEmptyAndMalformed();
|
||||||
testDownmixMonoPassthrough();
|
testDownmixMonoPassthrough();
|
||||||
testDownmixStereoAverages();
|
testDownmixStereoAverages();
|
||||||
testDownmixThreeChannelAverages();
|
testDownmixThreeChannelAverages();
|
||||||
@@ -597,6 +720,12 @@ int main() {
|
|||||||
testPerformanceStateV1BackCompat();
|
testPerformanceStateV1BackCompat();
|
||||||
testPerformanceStateGarbage();
|
testPerformanceStateGarbage();
|
||||||
testPerformanceStateNegativeNotesRoundTrip();
|
testPerformanceStateNegativeNotesRoundTrip();
|
||||||
|
testComponentStateRoundTrip();
|
||||||
|
testComponentStateSelectionOnlyNoZones();
|
||||||
|
testComponentStateEmptyIsEmpty();
|
||||||
|
testComponentStateV1BackCompat();
|
||||||
|
testComponentStateV2BackCompat();
|
||||||
|
testComponentStateGarbage();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
||||||
return g_fail != 0;
|
return g_fail != 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user